From e1e000c8e5bd9658ca2490a864eac7fec8d1f8e9 Mon Sep 17 00:00:00 2001 From: Citali Date: Mon, 29 Dec 2025 21:46:01 +0100 Subject: [PATCH] Improve artwork edit form --- src/actions/artworks/updateArtwork.ts | 108 ++++--- src/app/(admin)/artworks/[id]/page.tsx | 4 + .../artworks/single/ArtworkDetails.tsx | 305 ++++++++++++++++++ .../artworks/single/EditArtworkForm.tsx | 175 +++++----- src/components/ui/multiselect.tsx | 60 ++-- src/schemas/artworks/imageSchema.ts | 15 +- src/utils/artworkHelpers.ts | 24 ++ 7 files changed, 529 insertions(+), 162 deletions(-) create mode 100644 src/components/artworks/single/ArtworkDetails.tsx create mode 100644 src/utils/artworkHelpers.ts diff --git a/src/actions/artworks/updateArtwork.ts b/src/actions/artworks/updateArtwork.ts index 8b23bfe..0fb3325 100644 --- a/src/actions/artworks/updateArtwork.ts +++ b/src/actions/artworks/updateArtwork.ts @@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma"; import { artworkSchema } from "@/schemas/artworks/imageSchema"; +import { normalizeNames, slugify } from "@/utils/artworkHelpers"; import { z } from "zod/v4"; export async function updateArtwork( @@ -9,7 +10,6 @@ export async function updateArtwork( id: string ) { const validated = artworkSchema.safeParse(values); - // console.log(validated) if (!validated.success) { throw new Error("Invalid image data"); } @@ -27,55 +27,71 @@ export async function updateArtwork( year, creationDate, tagIds, - categoryIds + categoryIds, + newTagNames, + newCategoryNames, } = validated.data; - if(setAsHeader) { - await prisma.artwork.updateMany({ - where: { setAsHeader: true }, - data: { setAsHeader: false }, - }) - } - + const tagsToCreate = normalizeNames(newTagNames); + const categoriesToCreate = normalizeNames(newCategoryNames); - const updatedArtwork = await prisma.artwork.update({ - where: { id: id }, - data: { - name, - needsWork, - nsfw, - published, - setAsHeader, - altText, - description, - notes, - month, - year, - creationDate + const updatedArtwork = await prisma.$transaction(async (tx) => { + + if(setAsHeader) { + await tx.artwork.updateMany({ + where: { setAsHeader: true }, + data: { setAsHeader: false }, + }) } + + const tagsRelation = + tagIds || tagsToCreate.length + ? { + tags: { + set: [], // replace entire relation + connect: (tagIds ?? []).map((tagId) => ({ id: tagId })), + connectOrCreate: tagsToCreate.map((tName) => ({ + where: { name: tName }, + create: { name: tName, slug: slugify(tName) }, + })), + }, + } + : {}; + + const categoriesRelation = + categoryIds || categoriesToCreate.length + ? { + categories: { + set: [], + connect: (categoryIds ?? []).map((catId) => ({ id: catId })), + connectOrCreate: categoriesToCreate.map((cName) => ({ + where: { name: cName }, + create: { name: cName, slug: slugify(cName) }, + })), + }, + } + : {}; + + return tx.artwork.update({ + where: { id: id }, + data: { + name, + slug: slugify(name), + needsWork, + nsfw, + published, + setAsHeader, + altText, + description, + notes, + month, + year, + creationDate, + ...tagsRelation, + ...categoriesRelation, + } + }); }); - if (tagIds) { - await prisma.artwork.update({ - where: { id: id }, - data: { - tags: { - set: tagIds.map(id => ({ id })) - } - } - }); - } - - if (categoryIds) { - await prisma.artwork.update({ - where: { id: id }, - data: { - categories: { - set: categoryIds.map(id => ({ id })) - } - } - }); - } - - return updatedArtwork + return updatedArtwork; } \ No newline at end of file diff --git a/src/app/(admin)/artworks/[id]/page.tsx b/src/app/(admin)/artworks/[id]/page.tsx index 10b685d..16a5ef0 100644 --- a/src/app/(admin)/artworks/[id]/page.tsx +++ b/src/app/(admin)/artworks/[id]/page.tsx @@ -2,6 +2,7 @@ import { getSingleArtwork } from "@/actions/artworks/getArtworks"; import { getCategoriesWithTags } from "@/actions/categories/getCategories"; import { getTags } from "@/actions/tags/getTags"; import ArtworkColors from "@/components/artworks/single/ArtworkColors"; +import ArtworkDetails from "@/components/artworks/single/ArtworkDetails"; import ArtworkVariants from "@/components/artworks/single/ArtworkVariants"; import DeleteArtworkButton from "@/components/artworks/single/DeleteArtworkButton"; import EditArtworkForm from "@/components/artworks/single/EditArtworkForm"; @@ -33,6 +34,9 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
{item && }
+
+ {item && } +
diff --git a/src/components/artworks/single/ArtworkDetails.tsx b/src/components/artworks/single/ArtworkDetails.tsx new file mode 100644 index 0000000..f6e52a5 --- /dev/null +++ b/src/components/artworks/single/ArtworkDetails.tsx @@ -0,0 +1,305 @@ +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 ( + + + {rows.map((r) => ( + + + {r.k} + + {r.v} + + ))} + +
+ ); +} + +function StatusPill({ + label, + variant, +}: { + label: string; + variant?: "default" | "secondary" | "destructive" | "outline"; +}) { + return ( + + {label} + + ); +} + +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 ? : , + artwork.nsfw ? : , + artwork.needsWork ? : , + artwork.setAsHeader ? : null, + ].filter(Boolean); + + return ( + + +
+
+ Artwork details + + Read-only technical information and metadata + +
+
{flags}
+
+
+ + + {/* Core */} +
+
+
Core
+
+ + {artwork.id} }, + { k: "Slug", v: {artwork.slug} }, + { 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: ( +
+
+ {artwork.colorStatus ?? "—"} + {artwork.colorsGeneratedAt ? ( + + generated {fmtDate(artwork.colorsGeneratedAt as any)} + + ) : null} +
+ {artwork.colorError ? ( +
{artwork.colorError}
+ ) : null} +
+ ), + }, + { + k: "OKLab", + v: + artwork.okLabL != null || artwork.okLabA != null || artwork.okLabB != null ? ( +
+ L {fmtNum(artwork.okLabL, 3)} + a {fmtNum(artwork.okLabA, 3)} + b {fmtNum(artwork.okLabB, 3)} +
+ ) : ( + "—" + ), + }, + { + k: "Relations", + v: ( +
+ {(artwork.categories?.length ?? 0)} categories + {(artwork.tags?.length ?? 0)} tags + {(artwork.colors?.length ?? 0)} colors + {(artwork.variants?.length ?? 0)} variants +
+ ), + }, + ]} + /> +
+ + + + {/* Metadata */} +
+
+
Artwork metadata
+ {!meta ? None : null} +
+ + {meta ? ( + {meta.id} }, + ]} + /> + ) : ( +
No metadata available for this artwork.
+ )} +
+ + + + {/* File data */} +
+
+
File
+ {!file ? Missing relation : null} +
+ + {file ? ( + {file.id} }, + { k: "File key", v: {file.fileKey} }, + { k: "Original name", v: {file.originalFile} }, + { 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) }, + ]} + /> + ) : ( +
+ This component expects the artwork query to include the file relation. +
+ )} +
+ + {/* Variants (optional but helpful) */} + {artwork.variants?.length ? ( + <> + +
+
Variants
+
+ {artwork.variants + .slice() + .sort((a: any, b: any) => (a.type ?? "").localeCompare(b.type ?? "")) + .map((v: any) => ( +
+
+
+ {v.type ?? "variant"} + + {v.width && v.height ? `${fmtNum(v.width)}×${fmtNum(v.height)} px` : "—"} + +
+
+ {v.mimeType ?? "—"} {v.fileExtension ? `(${v.fileExtension})` : ""} +
+
+ +
+ {v.sizeBytes ? fmtBytes(v.sizeBytes) : "—"} + {v.url ? ( + + Open + + ) : null} +
+
+ ))} +
+
+ + ) : null} +
+
+ ); +} diff --git a/src/components/artworks/single/EditArtworkForm.tsx b/src/components/artworks/single/EditArtworkForm.tsx index ac046a2..c135e11 100644 --- a/src/components/artworks/single/EditArtworkForm.tsx +++ b/src/components/artworks/single/EditArtworkForm.tsx @@ -1,7 +1,6 @@ "use client" import { updateArtwork } from "@/actions/artworks/updateArtwork"; -// import { updateImage } from "@/actions/portfolio/images/updateImage"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; @@ -11,10 +10,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { ArtTag } from "@/generated/prisma/client"; -// import { Color, ImageColor, ImageMetadata, ImageVariant, PortfolioAlbum, PortfolioCategory, PortfolioImage, PortfolioSortContext, PortfolioTag, PortfolioType } from "@/generated/prisma"; import { cn } from "@/lib/utils"; import { artworkSchema } from "@/schemas/artworks/imageSchema"; -// import { imageSchema } from "@/schemas/portfolio/imageSchema"; import { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork"; import { zodResolver } from "@hookform/resolvers/zod"; import { format } from "date-fns"; @@ -45,15 +42,10 @@ export default function EditArtworkForm({ artwork, categories, tags }: month: artwork.month || undefined, year: artwork.year || undefined, creationDate: artwork.creationDate ? new Date(artwork.creationDate) : undefined, - - // albumId: image.albumId ?? undefined, - // typeId: image.typeId ?? undefined, - metadataId: artwork.metadata?.id ?? undefined, categoryIds: artwork.categories?.map(cat => cat.id) ?? [], - colorIds: artwork.colors?.map(color => color.id) ?? [], - // sortContextIds: image.sortContexts?.map(sortContext => sortContext.id) ?? [], tagIds: artwork.tags?.map(tag => tag.id) ?? [], - variantIds: artwork.variants?.map(variant => variant.id) ?? [], + newCategoryNames: [], + newTagNames: [] } }) @@ -109,6 +101,19 @@ export default function EditArtworkForm({ artwork, categories, tags }: )} /> + ( + + Internal notes + +