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
+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>
);
}