363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
"use client";
|
|
import { useState, useTransition } from "react";
|
|
import { Save, Pencil, X, Check, Trash2, Star, Eye, EyeOff, FileJson } from "lucide-react";
|
|
import { useRouter } from "next/navigation";
|
|
import { saveCoverMeta } from "@/app/actions/coverMeta";
|
|
import { deleteImage } from "@/app/actions/bulk";
|
|
import { useUndoDeleteToast } from "@/components/select/UndoDeleteToast";
|
|
import { useSettings } from "@/components/settings/SettingsProvider";
|
|
import { ChipInput, type ChipSuggestion } from "./ChipInput";
|
|
import { NfoImportDialog } from "./NfoImportDialog";
|
|
import type { NfoMetadata } from "@/lib/jav/nfoParser";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export interface CoverEditorInitial {
|
|
imageId: number;
|
|
code: string | null;
|
|
title: string | null;
|
|
releaseDate: string | null;
|
|
runtimeMin: number | null;
|
|
director: string | null;
|
|
studio: string | null;
|
|
label: string | null;
|
|
series: string | null;
|
|
rating: number | null;
|
|
watched: boolean;
|
|
notes: string | null;
|
|
actresses: string[];
|
|
genres: string[];
|
|
}
|
|
|
|
export function CoverEditor({
|
|
initial,
|
|
actressSuggestions,
|
|
genreSuggestions,
|
|
}: {
|
|
initial: CoverEditorInitial;
|
|
actressSuggestions?: ChipSuggestion[];
|
|
genreSuggestions?: ChipSuggestion[];
|
|
}) {
|
|
const empty = !initial.code && !initial.title && initial.actresses.length === 0;
|
|
const [editing, setEditing] = useState(empty);
|
|
const [code, setCode] = useState(initial.code ?? "");
|
|
const [title, setTitle] = useState(initial.title ?? "");
|
|
const [releaseDate, setReleaseDate] = useState(initial.releaseDate ?? "");
|
|
const [runtime, setRuntime] = useState(initial.runtimeMin?.toString() ?? "");
|
|
const [director, setDirector] = useState(initial.director ?? "");
|
|
const [studio, setStudio] = useState(initial.studio ?? "");
|
|
const [label, setLabel] = useState(initial.label ?? "");
|
|
const [series, setSeries] = useState(initial.series ?? "");
|
|
const [rating, setRating] = useState<number | null>(initial.rating);
|
|
const [watched, setWatched] = useState(initial.watched);
|
|
const [notes, setNotes] = useState(initial.notes ?? "");
|
|
const [actresses, setActresses] = useState<string[]>(initial.actresses);
|
|
const [genres, setGenres] = useState<string[]>(initial.genres);
|
|
const [saved, setSaved] = useState(false);
|
|
const [importing, setImporting] = useState(false);
|
|
const [pending, start] = useTransition();
|
|
const router = useRouter();
|
|
|
|
const applyImported = (m: NfoMetadata) => {
|
|
if (m.code) setCode(m.code);
|
|
if (m.title) setTitle(m.title);
|
|
if (m.releaseDate) setReleaseDate(m.releaseDate);
|
|
if (m.runtimeMin != null) setRuntime(String(m.runtimeMin));
|
|
if (m.director) setDirector(m.director);
|
|
if (m.studio) setStudio(m.studio);
|
|
if (m.series) setSeries(m.series);
|
|
if (m.actresses && m.actresses.length) setActresses(Array.from(new Set([...actresses, ...m.actresses])));
|
|
if (m.genres && m.genres.length) setGenres(Array.from(new Set([...genres, ...m.genres])));
|
|
if (m.notes) setNotes(m.notes);
|
|
setEditing(true);
|
|
};
|
|
const { settings } = useSettings();
|
|
const { show: showUndo } = useUndoDeleteToast();
|
|
|
|
const save = () => {
|
|
start(async () => {
|
|
await saveCoverMeta({
|
|
imageId: initial.imageId,
|
|
code: code || null,
|
|
title: title || null,
|
|
releaseDate: releaseDate || null,
|
|
runtimeMin: runtime ? Number(runtime) : null,
|
|
director: director || null,
|
|
studio: studio || null,
|
|
label: label || null,
|
|
series: series || null,
|
|
rating,
|
|
watched,
|
|
notes: notes || null,
|
|
actresses,
|
|
genres,
|
|
});
|
|
router.refresh();
|
|
setSaved(true);
|
|
setEditing(false);
|
|
setTimeout(() => setSaved(false), 1600);
|
|
});
|
|
};
|
|
|
|
const cancel = () => {
|
|
setCode(initial.code ?? "");
|
|
setTitle(initial.title ?? "");
|
|
setReleaseDate(initial.releaseDate ?? "");
|
|
setRuntime(initial.runtimeMin?.toString() ?? "");
|
|
setDirector(initial.director ?? "");
|
|
setStudio(initial.studio ?? "");
|
|
setLabel(initial.label ?? "");
|
|
setSeries(initial.series ?? "");
|
|
setRating(initial.rating);
|
|
setWatched(initial.watched);
|
|
setNotes(initial.notes ?? "");
|
|
setActresses(initial.actresses);
|
|
setGenres(initial.genres);
|
|
setEditing(false);
|
|
};
|
|
|
|
const onDelete = (e: React.MouseEvent) => {
|
|
const permanent = e.shiftKey || !settings.useRecycleBin;
|
|
if (permanent && !confirm("Permanently delete this cover? Cannot be undone.")) return;
|
|
start(async () => {
|
|
await deleteImage(initial.imageId, permanent ? { permanent: true } : undefined);
|
|
if (!permanent) showUndo([initial.imageId]);
|
|
router.push("/");
|
|
});
|
|
};
|
|
|
|
if (!editing) {
|
|
return (
|
|
<>
|
|
<div className="grid grid-cols-3 gap-chip">
|
|
<button
|
|
onClick={() => setEditing(true)}
|
|
className="flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] min-w-0"
|
|
>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
<span className="truncate">Edit Metadata</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setImporting(true)}
|
|
className="flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg glass glass-hover text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] min-w-0"
|
|
>
|
|
<FileJson className="w-3.5 h-3.5" />
|
|
<span className="truncate">Import .nfo / JSON</span>
|
|
</button>
|
|
<button
|
|
onClick={onDelete}
|
|
className="flex items-center justify-center gap-1.5 text-xs px-3 py-1.5 rounded-lg border border-[var(--color-coral)]/30 bg-[var(--color-coral)]/10 text-[var(--color-coral)] hover:bg-[var(--color-coral)]/20 hover:border-[var(--color-coral)]/50 min-w-0"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
<span className="truncate">Delete</span>
|
|
</button>
|
|
</div>
|
|
{/* Status row: fixed height so the toast/hint never reflows the
|
|
buttons above. Empty placeholder retains the line so the
|
|
transition between states stays CLS-free. */}
|
|
<div className="mt-1 h-4 text-xs flex items-center">
|
|
{saved ? (
|
|
<span className="flex items-center gap-1 text-[var(--color-mint)]">
|
|
<Check className="w-3 h-3" /> Saved
|
|
</span>
|
|
) : empty ? (
|
|
<span className="text-[var(--color-fg-muted)] italic">
|
|
No metadata yet — click Edit to fill in details
|
|
</span>
|
|
) : (
|
|
<span aria-hidden> </span>
|
|
)}
|
|
</div>
|
|
{importing && <NfoImportDialog onClose={() => setImporting(false)} onApply={applyImported} />}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="glass-strong rounded-2xl p-5 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium">{empty ? "Add Metadata" : "Edit Metadata"}</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onDelete}
|
|
disabled={pending}
|
|
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg border border-[var(--color-coral)]/30 bg-[var(--color-coral)]/10 text-[var(--color-coral)] hover:bg-[var(--color-coral)]/20 hover:border-[var(--color-coral)]/50 mr-auto"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" /> Delete cover
|
|
</button>
|
|
<button
|
|
onClick={() => setImporting(true)}
|
|
disabled={pending}
|
|
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg glass glass-hover"
|
|
>
|
|
<FileJson className="w-3.5 h-3.5" /> Import
|
|
</button>
|
|
{!empty && (
|
|
<button
|
|
onClick={cancel}
|
|
disabled={pending}
|
|
className="flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg glass hover:text-[var(--color-fg)]"
|
|
>
|
|
<X className="w-3.5 h-3.5" /> Cancel
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={save}
|
|
disabled={pending}
|
|
className={cn(
|
|
"flex items-center gap-1 text-xs px-3 py-1.5 rounded-lg font-medium",
|
|
"bg-[var(--color-cyan)] text-black hover:shadow-[var(--shadow-glow-cyan)] disabled:opacity-50",
|
|
)}
|
|
>
|
|
<Save className="w-3.5 h-3.5" />
|
|
{pending ? "Saving…" : "Save"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<Field label="Code">
|
|
<Input value={code} onChange={setCode} placeholder="SSIS-001" mono uppercase />
|
|
</Field>
|
|
<Field label="Release Date">
|
|
<Input value={releaseDate} onChange={setReleaseDate} placeholder="YYYY-MM-DD" mono />
|
|
</Field>
|
|
<Field label="Runtime (min)">
|
|
<Input value={runtime} onChange={setRuntime} type="number" placeholder="120" mono />
|
|
</Field>
|
|
</div>
|
|
|
|
<Field label="Title">
|
|
<Input value={title} onChange={setTitle} placeholder="Full release title" />
|
|
</Field>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
<Field label="Studio">
|
|
<Input value={studio} onChange={setStudio} placeholder="e.g. S1 NO.1 STYLE" />
|
|
</Field>
|
|
<Field label="Label">
|
|
<Input value={label} onChange={setLabel} placeholder="Sub-label" />
|
|
</Field>
|
|
<Field label="Series">
|
|
<Input value={series} onChange={setSeries} placeholder="Series name" />
|
|
</Field>
|
|
</div>
|
|
|
|
<Field label="Director">
|
|
<Input value={director} onChange={setDirector} placeholder="Optional" />
|
|
</Field>
|
|
|
|
<Field label="Actresses" hint="Press Enter to add. Type a name then Enter — duplicates are ignored.">
|
|
<ChipInput values={actresses} onChange={setActresses} placeholder="Add actress…" accent="violet" suggestions={actressSuggestions} />
|
|
</Field>
|
|
|
|
<Field label="Genres" hint="Press Enter to add.">
|
|
<ChipInput values={genres} onChange={setGenres} placeholder="Add genre…" accent="cyan" suggestions={genreSuggestions} />
|
|
</Field>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
<Field label="Rating">
|
|
<RatingPicker value={rating} onChange={setRating} />
|
|
</Field>
|
|
<Field label="Watched">
|
|
<button
|
|
type="button"
|
|
onClick={() => setWatched((v) => !v)}
|
|
className={cn(
|
|
"flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors w-full",
|
|
watched
|
|
? "bg-[var(--color-mint)]/15 border-[var(--color-mint)]/40 text-[var(--color-mint)]"
|
|
: "glass text-[var(--color-fg-dim)] hover:text-[var(--color-fg)]",
|
|
)}
|
|
>
|
|
{watched ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
|
{watched ? "Watched" : "Not watched"}
|
|
</button>
|
|
</Field>
|
|
</div>
|
|
|
|
<Field label="Notes">
|
|
<textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
rows={4}
|
|
placeholder="Personal notes, plot summary, anything you want to remember."
|
|
className="w-full bg-[var(--color-bg-0)]/40 rounded-lg p-3 text-sm outline-none border border-[var(--color-glass-border)] focus:border-[var(--color-cyan)] resize-y leading-relaxed"
|
|
/>
|
|
</Field>
|
|
|
|
{importing && <NfoImportDialog onClose={() => setImporting(false)} onApply={applyImported} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<label className="block text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-1.5">
|
|
{label}
|
|
</label>
|
|
{children}
|
|
{hint && <div className="text-[10px] text-[var(--color-fg-muted)] mt-1 italic">{hint}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Input({
|
|
value, onChange, type = "text", placeholder, mono, uppercase,
|
|
}: {
|
|
value: string;
|
|
onChange: (s: string) => void;
|
|
type?: "text" | "number";
|
|
placeholder?: string;
|
|
mono?: boolean;
|
|
uppercase?: boolean;
|
|
}) {
|
|
return (
|
|
<input
|
|
value={value}
|
|
onChange={(e) => onChange(uppercase ? e.target.value.toUpperCase() : e.target.value)}
|
|
type={type}
|
|
placeholder={placeholder}
|
|
className={cn(
|
|
"w-full bg-[var(--color-bg-0)]/40 rounded-md px-2.5 py-1.5 text-sm outline-none border border-[var(--color-glass-border)] focus:border-[var(--color-cyan)]",
|
|
mono && "font-mono",
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function RatingPicker({ value, onChange }: { value: number | null; onChange: (n: number | null) => void }) {
|
|
return (
|
|
<div className="flex items-center gap-1 px-2 py-2 rounded-lg glass">
|
|
{[1, 2, 3, 4, 5].map((n) => {
|
|
const filled = value != null && n <= value;
|
|
return (
|
|
<button
|
|
key={n}
|
|
type="button"
|
|
onClick={() => onChange(value === n ? null : n)}
|
|
aria-label={`${n} star${n === 1 ? "" : "s"}`}
|
|
className="p-0.5 transition-transform hover:scale-110"
|
|
>
|
|
<Star
|
|
className={cn(
|
|
"w-5 h-5 transition-colors",
|
|
filled ? "fill-[var(--color-cyan)] text-[var(--color-cyan)]" : "text-[var(--color-fg-muted)]",
|
|
)}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
{value != null && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange(null)}
|
|
className="ml-2 text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] hover:text-[var(--color-fg)]"
|
|
>
|
|
clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|