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 (
{/* eslint-disable-next-line @next/next/no-img-element */} {image.title ({ id: a.id, name: a.name, slug: a.slug }))} />
{image.code ? (
{image.code} {image.hasSubtitle && ( CC )}
) : (
)} {image.rating != null && (
{Array.from({ length: 5 }).map((_, i) => ( ))}
)}

{image.title || Untitled}

{(image.releaseDate || image.runtimeMin || image.director) && (
{image.releaseDate && } {image.runtimeMin != null && } {image.director && }
)}
· ·
{heroStats && (
{heroStats.resolution && ( )} {heroStats.bitrate && ( )} {heroStats.size && ( )} {heroStats.length && ( )}
)} {/* 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 && (
1 ? `Video (${videoMetas.length} parts)` : "Video"} value={videoSummary} />
)} {(studio || label || series) && ( {studio && } {label && } {series && } )} {actresses.length > 0 && (
{actresses.map((a) => ( {a.name} ))}
)} {genres.length > 0 && (
{genres.map((g) => ( {g.name} ))}
)} a.name), genres: genres.map((g) => g.name), }} actressSuggestions={actressSuggestions} genreSuggestions={genreSuggestions} /> {image.notes && ( Notes

{image.notes}

)}
); } 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 (
{label}
{value}
); } function InlineMeta({ label, value }: { label: string; value: string }) { return ( {label} {value} ); } function Field({ icon: Icon, label, value }: { icon?: React.ComponentType<{ className?: string }>; label: string; value: string }) { return (
{Icon && } {label}
{value}
); } function Section({ title, children }: { title: string; children: React.ReactNode }) { return ( {title} {children} ); } function EntityLink({ icon: Icon, href, label, name }: { icon: React.ComponentType<{ className?: string }>; href: string; label: string; name: string }) { return ( {label} {name} ); }