Files
pinkudex/components/image/ImageDetailView.tsx
T
2026-05-26 22:46:00 +02:00

327 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}