Initial commit

This commit is contained in:
admin
2026-05-26 22:46:00 +02:00
commit 7e2c2ff89c
256 changed files with 51523 additions and 0 deletions
+120
View File
@@ -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>
);
}
+129
View File
@@ -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>
);
}
+36
View File
@@ -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>
);
}
+326
View File
@@ -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>
);
}
+86
View File
@@ -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>
);
}