306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
import Link from "next/link";
|
||
import * as React from "react";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
|
||
import { cn } from "@/lib/utils";
|
||
import type { ArtworkWithRelations } from "@/types/Artwork";
|
||
|
||
function fmtDate(value?: Date | string | null) {
|
||
if (!value) return "—";
|
||
const d = typeof value === "string" ? new Date(value) : value;
|
||
if (Number.isNaN(d.getTime())) return "—";
|
||
return new Intl.DateTimeFormat("de-DE", {
|
||
year: "numeric",
|
||
month: "short",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(d);
|
||
}
|
||
|
||
function fmtBool(value?: boolean | null) {
|
||
if (value === true) return "Yes";
|
||
if (value === false) return "No";
|
||
return "—";
|
||
}
|
||
|
||
function fmtNum(value?: number | null, digits = 0) {
|
||
if (value === null || value === undefined) return "—";
|
||
return new Intl.NumberFormat("de-DE", {
|
||
maximumFractionDigits: digits,
|
||
minimumFractionDigits: digits,
|
||
}).format(value);
|
||
}
|
||
|
||
function fmtBytes(bytes?: number | null) {
|
||
if (!bytes && bytes !== 0) return "—";
|
||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||
let v = bytes;
|
||
let i = 0;
|
||
while (v >= 1024 && i < units.length - 1) {
|
||
v /= 1024;
|
||
i++;
|
||
}
|
||
return `${fmtNum(v, i === 0 ? 0 : 2)} ${units[i]}`;
|
||
}
|
||
|
||
function KVTable({ rows }: { rows: Array<{ k: string; v: React.ReactNode }> }) {
|
||
return (
|
||
<Table>
|
||
<TableBody>
|
||
{rows.map((r) => (
|
||
<TableRow key={r.k} className="hover:bg-transparent">
|
||
<TableCell className="w-[38%] align-top py-2 text-muted-foreground">
|
||
{r.k}
|
||
</TableCell>
|
||
<TableCell className="py-2">{r.v}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
);
|
||
}
|
||
|
||
function StatusPill({
|
||
label,
|
||
variant,
|
||
}: {
|
||
label: string;
|
||
variant?: "default" | "secondary" | "destructive" | "outline";
|
||
}) {
|
||
return (
|
||
<Badge variant={variant ?? "secondary"} className="whitespace-nowrap">
|
||
{label}
|
||
</Badge>
|
||
);
|
||
}
|
||
|
||
export default function ArtworkDetails({
|
||
artwork,
|
||
className,
|
||
}: {
|
||
artwork: ArtworkWithRelations;
|
||
className?: string;
|
||
}) {
|
||
const meta = artwork.metadata ?? null;
|
||
|
||
// Your schema: Artwork has `fileId` + relation `file: FileData`
|
||
// but depending on your `ArtworkWithRelations` type, `file` may be optional.
|
||
const file = (artwork as any).file ?? null;
|
||
|
||
const flags = [
|
||
artwork.published ? <StatusPill key="published" label="Published" /> : <StatusPill key="unpublished" label="Unpublished" variant="outline" />,
|
||
artwork.nsfw ? <StatusPill key="nsfw" label="NSFW" variant="destructive" /> : <StatusPill key="sfw" label="SFW" variant="secondary" />,
|
||
artwork.needsWork ? <StatusPill key="needs-work" label="Needs work" variant="outline" /> : <StatusPill key="ok" label="OK" variant="secondary" />,
|
||
artwork.setAsHeader ? <StatusPill key="header" label="Header" /> : null,
|
||
].filter(Boolean);
|
||
|
||
return (
|
||
<Card className={cn("overflow-hidden", className)}>
|
||
<CardHeader className="space-y-1">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div>
|
||
<CardTitle className="text-base">Artwork details</CardTitle>
|
||
<CardDescription className="text-sm">
|
||
Read-only technical information and metadata
|
||
</CardDescription>
|
||
</div>
|
||
<div className="flex flex-wrap items-center justify-end gap-2">{flags}</div>
|
||
</div>
|
||
</CardHeader>
|
||
|
||
<CardContent className="space-y-6">
|
||
{/* Core */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm font-medium">Core</div>
|
||
</div>
|
||
|
||
<KVTable
|
||
rows={[
|
||
{ k: "ID", v: <span className="font-mono text-xs break-all">{artwork.id}</span> },
|
||
{ k: "Slug", v: <span className="font-mono text-xs break-all">{artwork.slug}</span> },
|
||
{ k: "Sort index", v: fmtNum(artwork.sortIndex ?? 0) },
|
||
{ k: "Sort key", v: artwork.sortKey != null ? fmtNum(artwork.sortKey) : "—" },
|
||
{ k: "Created", v: fmtDate(artwork.createdAt as any) },
|
||
{ k: "Updated", v: fmtDate(artwork.updatedAt as any) },
|
||
{ k: "Creation date", v: fmtDate(artwork.creationDate as any) },
|
||
{
|
||
k: "Creation (month/year)",
|
||
v:
|
||
artwork.month || artwork.year
|
||
? `${artwork.month ? fmtNum(artwork.month) : "—"} / ${artwork.year ? fmtNum(artwork.year) : "—"}`
|
||
: "—",
|
||
},
|
||
{
|
||
k: "Color status",
|
||
v: (
|
||
<div className="flex flex-col gap-1">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="outline">{artwork.colorStatus ?? "—"}</Badge>
|
||
{artwork.colorsGeneratedAt ? (
|
||
<span className="text-xs text-muted-foreground">
|
||
generated {fmtDate(artwork.colorsGeneratedAt as any)}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{artwork.colorError ? (
|
||
<div className="text-xs text-destructive wrap-break-word">{artwork.colorError}</div>
|
||
) : null}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
k: "OKLab",
|
||
v:
|
||
artwork.okLabL != null || artwork.okLabA != null || artwork.okLabB != null ? (
|
||
<div className="flex flex-wrap gap-2">
|
||
<Badge variant="secondary">L {fmtNum(artwork.okLabL, 3)}</Badge>
|
||
<Badge variant="secondary">a {fmtNum(artwork.okLabA, 3)}</Badge>
|
||
<Badge variant="secondary">b {fmtNum(artwork.okLabB, 3)}</Badge>
|
||
</div>
|
||
) : (
|
||
"—"
|
||
),
|
||
},
|
||
{
|
||
k: "Relations",
|
||
v: (
|
||
<div className="flex flex-wrap gap-2 text-xs">
|
||
<Badge variant="secondary">{(artwork.categories?.length ?? 0)} categories</Badge>
|
||
<Badge variant="secondary">{(artwork.tags?.length ?? 0)} tags</Badge>
|
||
<Badge variant="secondary">{(artwork.colors?.length ?? 0)} colors</Badge>
|
||
<Badge variant="secondary">{(artwork.variants?.length ?? 0)} variants</Badge>
|
||
</div>
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* Metadata */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm font-medium">Artwork metadata</div>
|
||
{!meta ? <Badge variant="outline">None</Badge> : null}
|
||
</div>
|
||
|
||
{meta ? (
|
||
<KVTable
|
||
rows={[
|
||
{ k: "Format", v: meta.format ?? "—" },
|
||
{ k: "Space", v: meta.space ?? "—" },
|
||
{ k: "Depth", v: meta.depth ?? "—" },
|
||
{ k: "Channels", v: meta.channels != null ? fmtNum(meta.channels) : "—" },
|
||
{
|
||
k: "Dimensions",
|
||
v:
|
||
meta.width && meta.height
|
||
? `${fmtNum(meta.width)} × ${fmtNum(meta.height)} px`
|
||
: "—",
|
||
},
|
||
{
|
||
k: "Auto-orient",
|
||
v:
|
||
meta.autoOrientW || meta.autoOrientH
|
||
? `${meta.autoOrientW ?? "—"} × ${meta.autoOrientH ?? "—"}`
|
||
: "—",
|
||
},
|
||
{ k: "Bits per sample", v: meta.bitsPerSample != null ? fmtNum(meta.bitsPerSample) : "—" },
|
||
{ k: "Density", v: meta.density != null ? fmtNum(meta.density, 2) : "—" },
|
||
{ k: "Has alpha", v: fmtBool(meta.hasAlpha) },
|
||
{ k: "Has profile", v: fmtBool(meta.hasProfile) },
|
||
{ k: "Is palette", v: fmtBool(meta.isPalette) },
|
||
{ k: "Is progressive", v: fmtBool(meta.isProgressive) },
|
||
{ k: "Metadata ID", v: <span className="font-mono text-xs break-all">{meta.id}</span> },
|
||
]}
|
||
/>
|
||
) : (
|
||
<div className="text-sm text-muted-foreground">No metadata available for this artwork.</div>
|
||
)}
|
||
</div>
|
||
|
||
<Separator />
|
||
|
||
{/* File data */}
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm font-medium">File</div>
|
||
{!file ? <Badge variant="outline">Missing relation</Badge> : null}
|
||
</div>
|
||
|
||
{file ? (
|
||
<KVTable
|
||
rows={[
|
||
{ k: "File ID", v: <span className="font-mono text-xs break-all">{file.id}</span> },
|
||
{ k: "File key", v: <span className="font-mono text-xs break-all">{file.fileKey}</span> },
|
||
{ k: "Original name", v: <span className="break-all">{file.originalFile}</span> },
|
||
{ k: "Stored name", v: file.name ?? "—" },
|
||
{ k: "MIME type", v: file.fileType ?? "—" },
|
||
{ k: "Size", v: fmtBytes(file.fileSize) },
|
||
{ k: "Uploaded", v: fmtDate(file.uploadDate as any) },
|
||
]}
|
||
/>
|
||
) : (
|
||
<div className="text-sm text-muted-foreground">
|
||
This component expects the artwork query to include the <span className="font-mono">file</span> relation.
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Variants (optional but helpful) */}
|
||
{artwork.variants?.length ? (
|
||
<>
|
||
<Separator />
|
||
<div className="space-y-3">
|
||
<div className="text-sm font-medium">Variants</div>
|
||
<div className="space-y-2">
|
||
{artwork.variants
|
||
.slice()
|
||
.sort((a: any, b: any) => (a.type ?? "").localeCompare(b.type ?? ""))
|
||
.map((v: any) => (
|
||
<div
|
||
key={v.id}
|
||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2"
|
||
>
|
||
<div className="flex flex-col">
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="secondary">{v.type ?? "variant"}</Badge>
|
||
<span className="text-xs text-muted-foreground">
|
||
{v.width && v.height ? `${fmtNum(v.width)}×${fmtNum(v.height)} px` : "—"}
|
||
</span>
|
||
</div>
|
||
<div className="text-xs text-muted-foreground break-all">
|
||
{v.mimeType ?? "—"} {v.fileExtension ? `(${v.fileExtension})` : ""}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 text-xs">
|
||
<Badge variant="outline">{v.sizeBytes ? fmtBytes(v.sizeBytes) : "—"}</Badge>
|
||
{v.url ? (
|
||
<Link
|
||
href={v.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
||
>
|
||
Open
|
||
</Link>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
) : null}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|