Initial commit
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
import { useRef, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Plus, Trash2, Loader2 } from "lucide-react";
|
||||
import { deleteAttachedImage } from "@/app/actions/attachments";
|
||||
import { imageUrl } from "@/lib/assetUrls";
|
||||
|
||||
interface Attached {
|
||||
id: number;
|
||||
thumbPath: string;
|
||||
width: number;
|
||||
height: number;
|
||||
filename: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export function AttachedImages({ parentId, items }: { parentId: number; items: Attached[] }) {
|
||||
const router = useRouter();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, start] = useTransition();
|
||||
|
||||
async function upload(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
// Drop-zone bypasses the Add button's `disabled` state, so a second
|
||||
// drop while a previous upload is in flight would race the busy
|
||||
// flag (and clobber fileRef.value mid-flight).
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (const file of Array.from(files)) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("parentImageId", String(parentId));
|
||||
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error ?? `upload failed (${res.status})`);
|
||||
}
|
||||
}
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 glass rounded-2xl p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)]">
|
||||
Back covers / extras {items.length > 0 && <span className="ml-1 text-[var(--color-fg-dim)]">({items.length})</span>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-[var(--color-cyan)] text-black font-medium disabled:opacity-50"
|
||||
>
|
||||
{busy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
{busy ? "Uploading…" : "Add"}
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
hidden
|
||||
onChange={(e) => upload(e.target.files)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-2 text-xs text-red-400">{error}</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}
|
||||
className="rounded-xl border border-dashed border-[var(--color-glass-border)] py-6 text-center text-xs text-[var(--color-fg-muted)]"
|
||||
>
|
||||
No back covers yet. Drag & drop here or click <span className="text-[var(--color-cyan)]">Add</span>.
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDragOver={(e) => { e.preventDefault(); }}
|
||||
onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files); }}
|
||||
className="flex flex-col gap-3"
|
||||
>
|
||||
{items.map((it) => (
|
||||
<div key={it.id} className="relative group rounded-2xl overflow-hidden glass">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageUrl({ id: it.id, code: null, ext: it.filename.match(/\.[^.]+$/)?.[0] ?? ".jpg", v: it.sha256.slice(0, 12) })}
|
||||
alt={it.filename}
|
||||
width={it.width}
|
||||
height={it.height}
|
||||
className="block w-full h-auto max-w-[800px] max-h-[538px]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => start(async () => { await deleteAttachedImage(it.id); router.refresh(); })}
|
||||
title="Remove"
|
||||
aria-label="Remove"
|
||||
className="absolute top-2 right-2 w-8 h-8 grid place-items-center rounded-md bg-black/70 text-red-300 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-black/90"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
import { useEffect, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Eye, EyeOff, Gem, Star, Package } from "lucide-react";
|
||||
import { setWatched, setCoverVip, setCoverFavorite, setCoverOwned } from "@/app/actions/coverMeta";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Kind = "watched" | "vip" | "favorite" | "owned";
|
||||
|
||||
const CONFIG: Record<Kind, {
|
||||
onLabel: string;
|
||||
offLabel: string;
|
||||
OnIcon: React.ComponentType<{ className?: string }>;
|
||||
OffIcon: React.ComponentType<{ className?: string }>;
|
||||
onClass: string;
|
||||
offClass: string;
|
||||
action: (id: number, on: boolean) => Promise<void>;
|
||||
}> = {
|
||||
watched: {
|
||||
onLabel: "Watched",
|
||||
offLabel: "Not Watched",
|
||||
OnIcon: Eye,
|
||||
OffIcon: EyeOff,
|
||||
onClass: "bg-[var(--color-mint)]/10 border-[var(--color-mint)]/30 text-[var(--color-mint)] hover:bg-[var(--color-mint)]/20",
|
||||
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-[var(--color-fg)] hover:border-[var(--color-glass-border-strong)]",
|
||||
action: setWatched,
|
||||
},
|
||||
vip: {
|
||||
onLabel: "VIP",
|
||||
offLabel: "VIP",
|
||||
OnIcon: Gem,
|
||||
OffIcon: Gem,
|
||||
onClass: "bg-cyan-400/15 border-cyan-400/40 text-cyan-200 hover:bg-cyan-400/25",
|
||||
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-cyan-200 hover:border-cyan-400/40",
|
||||
action: setCoverVip,
|
||||
},
|
||||
favorite: {
|
||||
onLabel: "Favorite",
|
||||
offLabel: "Favorite",
|
||||
OnIcon: Star,
|
||||
OffIcon: Star,
|
||||
onClass: "bg-amber-400/15 border-amber-400/40 text-amber-200 hover:bg-amber-400/25",
|
||||
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-amber-200 hover:border-amber-400/40",
|
||||
action: setCoverFavorite,
|
||||
},
|
||||
owned: {
|
||||
onLabel: "Owned",
|
||||
offLabel: "Owned",
|
||||
OnIcon: Package,
|
||||
OffIcon: Package,
|
||||
onClass: "bg-[var(--color-violet)]/15 border-[var(--color-violet)]/40 text-[var(--color-violet)] hover:bg-[var(--color-violet)]/25",
|
||||
offClass: "border-[var(--color-glass-border)] text-[var(--color-fg-muted)] hover:text-[var(--color-violet)] hover:border-[var(--color-violet)]/40",
|
||||
action: setCoverOwned,
|
||||
},
|
||||
};
|
||||
|
||||
// Custom event that lets siblings predict the server-side mutual
|
||||
// exclusion between VIP and Favorite (one being set on clears the
|
||||
// other). Without this, the just-cleared pill would show as "on"
|
||||
// optimistically until router.refresh() round-trips the new initial
|
||||
// prop. The event lets the affected sibling clear its local state
|
||||
// immediately on the same render tick.
|
||||
const MUTEX_EVENT = "pinkudex:cover-flag-mutex";
|
||||
interface MutexDetail { imageId: number; clearedKind: Kind }
|
||||
|
||||
export function CoverFlagToggle({
|
||||
kind,
|
||||
imageId,
|
||||
initial,
|
||||
}: {
|
||||
kind: Kind;
|
||||
imageId: number;
|
||||
initial: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const cfg = CONFIG[kind];
|
||||
const [on, setLocal] = useState(initial);
|
||||
const [, start] = useTransition();
|
||||
// Sync to fresh server state — needed so VIP and Favorite stay mutually exclusive
|
||||
// when the other one is toggled and the page refreshes.
|
||||
useEffect(() => { setLocal(initial); }, [initial]);
|
||||
|
||||
// Listen for sibling toggles that would mutex-clear our flag.
|
||||
useEffect(() => {
|
||||
if (kind !== "vip" && kind !== "favorite") return;
|
||||
const handler = (ev: Event) => {
|
||||
const d = (ev as CustomEvent<MutexDetail>).detail;
|
||||
if (d && d.imageId === imageId && d.clearedKind === kind) {
|
||||
setLocal(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener(MUTEX_EVENT, handler);
|
||||
return () => window.removeEventListener(MUTEX_EVENT, handler);
|
||||
}, [imageId, kind]);
|
||||
|
||||
const Icon = on ? cfg.OnIcon : cfg.OffIcon;
|
||||
|
||||
function toggle() {
|
||||
const next = !on;
|
||||
setLocal(next);
|
||||
// Server clears the opposite flag when VIP/Favorite is turned on.
|
||||
// Tell our sibling instance now so its UI doesn't lag the action.
|
||||
if (next && (kind === "vip" || kind === "favorite")) {
|
||||
const cleared: Kind = kind === "vip" ? "favorite" : "vip";
|
||||
window.dispatchEvent(new CustomEvent<MutexDetail>(MUTEX_EVENT, {
|
||||
detail: { imageId, clearedKind: cleared },
|
||||
}));
|
||||
}
|
||||
start(async () => {
|
||||
await cfg.action(imageId, next);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
title={`Toggle ${cfg.onLabel}`}
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center justify-center gap-1.5 px-2 py-1.5 rounded-full text-[10px] uppercase tracking-wider font-mono border transition-colors cursor-pointer",
|
||||
on ? cfg.onClass : cfg.offClass,
|
||||
)}
|
||||
>
|
||||
<Icon className={cn("w-3 h-3", kind === "favorite" && on && "fill-amber-200")} />
|
||||
{on ? cfg.onLabel : cfg.offLabel}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { deleteImage } from "@/app/actions/bulk";
|
||||
import { useUndoDeleteToast } from "@/components/select/UndoDeleteToast";
|
||||
import { useSettings } from "@/components/settings/SettingsProvider";
|
||||
|
||||
export function DetailDeleteButton({ id }: { id: number }) {
|
||||
const [pending, start] = useTransition();
|
||||
const router = useRouter();
|
||||
const { show: showUndo } = useUndoDeleteToast();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const onClick = (e: React.MouseEvent) => {
|
||||
const permanent = e.shiftKey || !settings.useRecycleBin;
|
||||
if (permanent && !confirm("Permanently delete this cover? Cannot be undone.")) return;
|
||||
start(async () => {
|
||||
await deleteImage(id, permanent ? { permanent: true } : undefined);
|
||||
if (!permanent) showUndo([id]);
|
||||
router.push("/");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={pending}
|
||||
title={settings.useRecycleBin ? "Send to trash · Shift-click for permanent delete" : "Delete permanently"}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium border border-[var(--color-coral)]/40 bg-[var(--color-coral)]/10 text-[var(--color-coral)] hover:bg-[var(--color-coral)]/20 transition-colors whitespace-nowrap disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
{pending ? "Deleting…" : "Delete"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import Link from "next/link";
|
||||
import { Star, Calendar, Clock, Building2, Film, Tag as TagIcon, Captions } from "lucide-react";
|
||||
import { CoverFlagToggle } from "./CoverFlagToggle";
|
||||
import { getImageDetail, listAllCollections, listAttachedImages, listAllActresses, listAllGenres } from "@/lib/db/queries";
|
||||
import { CoverEditor } from "@/components/cover/CoverEditor";
|
||||
import { CoverPlayButton } from "@/components/video/CoverPlayButton";
|
||||
import { TagEditor } from "@/components/tags/TagEditor";
|
||||
import { CollectionPicker } from "@/components/collections/CollectionPicker";
|
||||
import { AttachedImages } from "@/components/image/AttachedImages";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
import { imageUrl } from "@/lib/assetUrls";
|
||||
import { formatBitrate, formatDuration, formatResolution, formatBytes as formatVideoBytes, formatVideoSummary, listStoredVideoMetadataForCode } from "@/lib/video/metadata";
|
||||
import { Panel, PanelStack, PanelSection, PanelHeader, ChipCluster } from "@/components/ui/panel";
|
||||
import path from "node:path";
|
||||
|
||||
export function ImageDetailView({ imageId }: { imageId: number }) {
|
||||
const detail = getImageDetail(imageId);
|
||||
if (!detail) return null;
|
||||
const allCollections = listAllCollections().map((c) => ({ id: c.id, name: c.name, slug: c.slug }));
|
||||
const attached = listAttachedImages(detail.image.id);
|
||||
const actressSuggestions = listAllActresses().map((a) => {
|
||||
const primaryAliases: string[] = [];
|
||||
const tokens = a.name.trim().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length >= 2) primaryAliases.push(tokens.slice().reverse().join(" "));
|
||||
const aliases: string[] = [];
|
||||
if (a.altNames) {
|
||||
for (const part of a.altNames.split(/[,、,]/)) {
|
||||
const t = part.trim();
|
||||
if (t) aliases.push(t);
|
||||
}
|
||||
}
|
||||
return { name: a.name, primaryAliases, aliases };
|
||||
});
|
||||
const genreSuggestions = listAllGenres().map((g) => g.name);
|
||||
const { image, studio, label, series, actresses, genres, tags, collections } = detail;
|
||||
const videoMetas = image.hasVideo ? listStoredVideoMetadataForCode(image.code) : [];
|
||||
const videoSummary = videoMetas.map((meta) => formatVideoSummary(meta)).find(Boolean);
|
||||
// Pull the first probed-clean meta for the per-stat hero strip. Falls
|
||||
// back to the very first row if none have a usable probe yet.
|
||||
const heroMeta = videoMetas.find((m) => !m.probeError) ?? videoMetas[0] ?? null;
|
||||
const heroStats = heroMeta && !heroMeta.probeError
|
||||
? {
|
||||
resolution: formatResolution(heroMeta.width, heroMeta.height),
|
||||
bitrate: formatBitrate(heroMeta.videoBitrate),
|
||||
// Sum across parts so the user sees the actual disk footprint
|
||||
// for the whole title, not just the first file.
|
||||
size: formatVideoBytes(videoMetas.reduce((acc, m) => acc + (m.sizeBytes ?? 0), 0)),
|
||||
// Same for length — total runtime across all parts.
|
||||
length: formatDuration(videoMetas.reduce((acc, m) => acc + (m.durationSec ?? 0), 0)),
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={image.id} className="pb-12 fade-in">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[800px_minmax(0,1fr)] gap-6">
|
||||
<div>
|
||||
<div className="glass rounded-2xl overflow-hidden relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageUrl({ id: image.id, code: image.code, ext: path.extname(image.filename), v: image.sha256.slice(0, 12) })}
|
||||
alt={image.title ?? image.code ?? image.filename}
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
className="block w-full h-auto max-w-[800px] max-h-[538px]"
|
||||
/>
|
||||
<CoverPlayButton
|
||||
code={image.code}
|
||||
actresses={actresses.map((a) => ({ id: a.id, name: a.name, slug: a.slug }))}
|
||||
/>
|
||||
</div>
|
||||
<AttachedImages parentId={image.id} items={attached} />
|
||||
</div>
|
||||
|
||||
<PanelStack as="aside">
|
||||
<Panel>
|
||||
<PanelSection>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1.5 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{image.code ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base uppercase tracking-wider font-mono font-semibold text-[var(--color-cyan)]">
|
||||
{image.code}
|
||||
</span>
|
||||
{image.hasSubtitle && (
|
||||
<span
|
||||
title="Subtitle file available"
|
||||
className="inline-flex items-center gap-1 text-[10px] uppercase tracking-wider font-mono px-1.5 py-0.5 rounded border border-[var(--color-mint)]/40 bg-[var(--color-mint)]/10 text-[var(--color-mint)]"
|
||||
>
|
||||
<Captions className="w-3 h-3" /> CC
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{image.rating != null && (
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`w-3.5 h-3.5 ${i < image.rating! ? "fill-[var(--color-cyan)] text-[var(--color-cyan)]" : "text-[var(--color-fg-muted)]"}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-lg font-medium truncate" title={image.title ?? image.filename}>
|
||||
{image.title || <span className="text-[var(--color-fg-muted)] italic">Untitled</span>}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-chip text-xs w-full">
|
||||
<CoverFlagToggle kind="vip" imageId={image.id} initial={image.isVip} />
|
||||
<CoverFlagToggle kind="favorite" imageId={image.id} initial={image.isFavorite} />
|
||||
<CoverFlagToggle kind="watched" imageId={image.id} initial={image.watched} />
|
||||
<CoverFlagToggle kind="owned" imageId={image.id} initial={image.isOwned} />
|
||||
</div>
|
||||
|
||||
{(image.releaseDate || image.runtimeMin || image.director) && (
|
||||
<div className="grid grid-cols-3 gap-chip text-xs font-mono text-[var(--color-fg-dim)] pt-section border-t border-[var(--color-glass-border)]">
|
||||
{image.releaseDate && <Field icon={Calendar} label="Released" value={image.releaseDate} />}
|
||||
{image.runtimeMin != null && <Field icon={Clock} label="Runtime" value={`${image.runtimeMin} min`} />}
|
||||
{image.director && <Field label="Director" value={image.director} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-baseline justify-center gap-x-5 gap-y-1 text-[12px] font-mono text-[var(--color-fg-muted)] pt-section border-t border-[var(--color-glass-border)]">
|
||||
<InlineMeta label="Resolution" value={`${image.width}×${image.height}`} />
|
||||
<span className="opacity-30">·</span>
|
||||
<InlineMeta label="Size" value={formatBytes(image.bytes)} />
|
||||
<span className="opacity-30">·</span>
|
||||
<InlineMeta label="Imported" value={new Date(image.importedAt).toLocaleDateString()} />
|
||||
</div>
|
||||
{heroStats && (
|
||||
<div className="grid grid-cols-4 gap-stat-gap text-center pt-section border-t border-[var(--color-glass-border)]">
|
||||
{heroStats.resolution && (
|
||||
<HeroStat label="Resolution" value={heroStats.resolution} />
|
||||
)}
|
||||
{heroStats.bitrate && (
|
||||
<HeroStat label="Bitrate" value={heroStats.bitrate} />
|
||||
)}
|
||||
{heroStats.size && (
|
||||
<HeroStat label="File Size" value={heroStats.size} accent />
|
||||
)}
|
||||
{heroStats.length && (
|
||||
<HeroStat label="Length" value={heroStats.length} cyan />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Video summary is suppressed when heroStats render — the 4-up
|
||||
strip already covers resolution/bitrate/size/length. Falls
|
||||
back to the inline summary on rows where the probe didn't
|
||||
yield enough data for the hero strip. */}
|
||||
{!heroStats && videoSummary && (
|
||||
<div className="text-[13px] font-mono text-[var(--color-fg-muted)]">
|
||||
<InlineMeta label={videoMetas.length > 1 ? `Video (${videoMetas.length} parts)` : "Video"} value={videoSummary} />
|
||||
</div>
|
||||
)}
|
||||
</PanelSection>
|
||||
</Panel>
|
||||
|
||||
{(studio || label || series) && (
|
||||
<Panel className="flex flex-col gap-chip">
|
||||
{studio && <EntityLink icon={Building2} href={`/studios/${studio.slug}`} label="Studio" name={studio.name} />}
|
||||
{label && <EntityLink icon={TagIcon} href={`/labels/${label.slug}`} label="Label" name={label.name} />}
|
||||
{series && <EntityLink icon={Film} href={`/series/${series.slug}`} label="Series" name={series.name} />}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{actresses.length > 0 && (
|
||||
<Section title="Actresses">
|
||||
<ChipCluster>
|
||||
{actresses.map((a) => (
|
||||
<Link
|
||||
key={a.id}
|
||||
href={`/actress/${a.slug}`}
|
||||
className="px-2.5 py-1 rounded-full text-xs border transition-colors"
|
||||
style={{
|
||||
background: "color-mix(in oklch, var(--color-violet) 14%, transparent)",
|
||||
color: "var(--color-violet)",
|
||||
borderColor: "color-mix(in oklch, var(--color-violet) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
{a.name}
|
||||
</Link>
|
||||
))}
|
||||
</ChipCluster>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{genres.length > 0 && (
|
||||
<Section title="Genres">
|
||||
<ChipCluster>
|
||||
{genres.map((g) => (
|
||||
<Link
|
||||
key={g.id}
|
||||
href={`/genres/${g.slug}`}
|
||||
className="px-2.5 py-1 rounded-full text-xs border transition-colors"
|
||||
style={{
|
||||
background: "color-mix(in oklch, var(--color-cyan) 14%, transparent)",
|
||||
color: "var(--color-cyan)",
|
||||
borderColor: "color-mix(in oklch, var(--color-cyan) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
{g.name}
|
||||
</Link>
|
||||
))}
|
||||
</ChipCluster>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Panel>
|
||||
<TagEditor imageId={image.id} initial={tags} />
|
||||
</Panel>
|
||||
<Panel>
|
||||
<CollectionPicker imageId={image.id} current={collections} available={allCollections} />
|
||||
</Panel>
|
||||
|
||||
<CoverEditor
|
||||
initial={{
|
||||
imageId: image.id,
|
||||
code: image.code,
|
||||
title: image.title,
|
||||
releaseDate: image.releaseDate,
|
||||
runtimeMin: image.runtimeMin,
|
||||
director: image.director,
|
||||
studio: studio?.name ?? null,
|
||||
label: label?.name ?? null,
|
||||
series: series?.name ?? null,
|
||||
rating: image.rating,
|
||||
watched: image.watched,
|
||||
notes: image.notes,
|
||||
actresses: actresses.map((a) => a.name),
|
||||
genres: genres.map((g) => g.name),
|
||||
}}
|
||||
actressSuggestions={actressSuggestions}
|
||||
genreSuggestions={genreSuggestions}
|
||||
/>
|
||||
|
||||
{image.notes && (
|
||||
<Panel>
|
||||
<PanelHeader>Notes</PanelHeader>
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{image.notes}</p>
|
||||
</Panel>
|
||||
)}
|
||||
</PanelStack>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeroStat({
|
||||
label,
|
||||
value,
|
||||
accent = false,
|
||||
cyan = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
/** Render the value in the brighter primary fg (used for headline stats). */
|
||||
accent?: boolean;
|
||||
/** Tint the value cyan — reserved for the single most-prominent stat. */
|
||||
cyan?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] mb-stat">
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
cyan
|
||||
? "font-mono text-3xl font-semibold tracking-tight leading-none text-[var(--color-cyan)]"
|
||||
: accent
|
||||
? "font-mono text-3xl font-semibold tracking-tight leading-none text-[var(--color-fg)]"
|
||||
: "font-mono text-2xl font-semibold tracking-tight leading-none text-[var(--color-fg-dim)]"
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InlineMeta({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-baseline gap-1.5">
|
||||
<span className="text-[10px] uppercase tracking-wider opacity-70">{label}</span>
|
||||
<span className="text-[var(--color-fg)]">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ icon: Icon, label, value }: { icon?: React.ComponentType<{ className?: string }>; label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[9px] uppercase tracking-wider text-[var(--color-fg-muted)] flex items-center gap-1">
|
||||
{Icon && <Icon className="w-3 h-3" />}
|
||||
{label}
|
||||
</div>
|
||||
<div className="text-[var(--color-fg)] truncate">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Panel>
|
||||
<PanelHeader>{title}</PanelHeader>
|
||||
{children}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityLink({ icon: Icon, href, label, name }: { icon: React.ComponentType<{ className?: string }>; href: string; label: string; name: string }) {
|
||||
return (
|
||||
<Link href={href} className="flex items-center gap-2 text-sm group">
|
||||
<Icon className="w-4 h-4 text-[var(--color-fg-muted)] group-hover:text-[var(--color-cyan)]" />
|
||||
<span className="text-[10px] uppercase tracking-wider font-mono text-[var(--color-fg-muted)] w-16">{label}</span>
|
||||
<span className="text-[var(--color-fg)] group-hover:text-[var(--color-cyan)]">{name}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { ChevronLeft, ChevronRight, Shuffle, Undo2 } from "lucide-react";
|
||||
import { cn, coverHref } from "@/lib/utils";
|
||||
|
||||
type Neighbor = { id: number; code: string | null } | null;
|
||||
|
||||
export function ImageNav({
|
||||
prev,
|
||||
next,
|
||||
randomEndpoint,
|
||||
}: {
|
||||
prev: Neighbor;
|
||||
next: Neighbor;
|
||||
randomEndpoint: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const prevHref = prev ? coverHref(prev) : null;
|
||||
const nextHref = next ? coverHref(next) : null;
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return;
|
||||
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
if (e.key === "ArrowLeft") {
|
||||
if (prevHref) { e.preventDefault(); router.push(prevHref); }
|
||||
} else if (e.key === "ArrowRight") {
|
||||
if (nextHref) { e.preventDefault(); router.push(nextHref); }
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
router.push(randomEndpoint);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [prevHref, nextHref, randomEndpoint, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<NavBtn href={prevHref} label="Previous (←)">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</NavBtn>
|
||||
<NavBtn href={randomEndpoint} label="Random (↑)">
|
||||
<Shuffle className="w-3.5 h-3.5" />
|
||||
</NavBtn>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
title="Last viewed (↓)"
|
||||
aria-label="Last viewed"
|
||||
className="w-8 h-8 grid place-items-center rounded-lg border border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)] transition-colors"
|
||||
>
|
||||
<Undo2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<NavBtn href={nextHref} label="Next (→)">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</NavBtn>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavBtn({ href, label, children }: { href: string | null; label: string; children: React.ReactNode }) {
|
||||
const className = cn(
|
||||
"w-8 h-8 grid place-items-center rounded-lg border transition-colors",
|
||||
href
|
||||
? "border-[var(--color-glass-border)] text-[var(--color-fg-dim)] hover:text-[var(--color-fg)] hover:bg-[var(--color-glass)]"
|
||||
: "border-[var(--color-glass-border)]/40 text-[var(--color-fg-muted)]/40 cursor-not-allowed",
|
||||
);
|
||||
if (!href) {
|
||||
return (
|
||||
<span aria-disabled title={label} className={className}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link href={href} title={label} aria-label={label} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user