Initial commit
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user