Improve artwork edit form
This commit is contained in:
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
||||||
|
import { normalizeNames, slugify } from "@/utils/artworkHelpers";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export async function updateArtwork(
|
export async function updateArtwork(
|
||||||
@ -9,7 +10,6 @@ export async function updateArtwork(
|
|||||||
id: string
|
id: string
|
||||||
) {
|
) {
|
||||||
const validated = artworkSchema.safeParse(values);
|
const validated = artworkSchema.safeParse(values);
|
||||||
// console.log(validated)
|
|
||||||
if (!validated.success) {
|
if (!validated.success) {
|
||||||
throw new Error("Invalid image data");
|
throw new Error("Invalid image data");
|
||||||
}
|
}
|
||||||
@ -27,55 +27,71 @@ export async function updateArtwork(
|
|||||||
year,
|
year,
|
||||||
creationDate,
|
creationDate,
|
||||||
tagIds,
|
tagIds,
|
||||||
categoryIds
|
categoryIds,
|
||||||
|
newTagNames,
|
||||||
|
newCategoryNames,
|
||||||
} = validated.data;
|
} = validated.data;
|
||||||
|
|
||||||
if(setAsHeader) {
|
const tagsToCreate = normalizeNames(newTagNames);
|
||||||
await prisma.artwork.updateMany({
|
const categoriesToCreate = normalizeNames(newCategoryNames);
|
||||||
where: { setAsHeader: true },
|
|
||||||
data: { setAsHeader: false },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const updatedArtwork = await prisma.$transaction(async (tx) => {
|
||||||
|
|
||||||
const updatedArtwork = await prisma.artwork.update({
|
if(setAsHeader) {
|
||||||
where: { id: id },
|
await tx.artwork.updateMany({
|
||||||
data: {
|
where: { setAsHeader: true },
|
||||||
name,
|
data: { setAsHeader: false },
|
||||||
needsWork,
|
})
|
||||||
nsfw,
|
|
||||||
published,
|
|
||||||
setAsHeader,
|
|
||||||
altText,
|
|
||||||
description,
|
|
||||||
notes,
|
|
||||||
month,
|
|
||||||
year,
|
|
||||||
creationDate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
return updatedArtwork;
|
||||||
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
|
|
||||||
}
|
}
|
||||||
@ -2,6 +2,7 @@ import { getSingleArtwork } from "@/actions/artworks/getArtworks";
|
|||||||
import { getCategoriesWithTags } from "@/actions/categories/getCategories";
|
import { getCategoriesWithTags } from "@/actions/categories/getCategories";
|
||||||
import { getTags } from "@/actions/tags/getTags";
|
import { getTags } from "@/actions/tags/getTags";
|
||||||
import ArtworkColors from "@/components/artworks/single/ArtworkColors";
|
import ArtworkColors from "@/components/artworks/single/ArtworkColors";
|
||||||
|
import ArtworkDetails from "@/components/artworks/single/ArtworkDetails";
|
||||||
import ArtworkVariants from "@/components/artworks/single/ArtworkVariants";
|
import ArtworkVariants from "@/components/artworks/single/ArtworkVariants";
|
||||||
import DeleteArtworkButton from "@/components/artworks/single/DeleteArtworkButton";
|
import DeleteArtworkButton from "@/components/artworks/single/DeleteArtworkButton";
|
||||||
import EditArtworkForm from "@/components/artworks/single/EditArtworkForm";
|
import EditArtworkForm from "@/components/artworks/single/EditArtworkForm";
|
||||||
@ -33,6 +34,9 @@ export default async function ArtworkSinglePage({ params }: { params: Promise<{
|
|||||||
<div>
|
<div>
|
||||||
{item && <ArtworkColors colors={item.colors} artworkId={item.id} />}
|
{item && <ArtworkColors colors={item.colors} artworkId={item.id} />}
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
{item && <ArtworkDetails artwork={item} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
305
src/components/artworks/single/ArtworkDetails.tsx
Normal file
305
src/components/artworks/single/ArtworkDetails.tsx
Normal file
@ -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 (
|
||||||
|
<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 break-words">{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { updateArtwork } from "@/actions/artworks/updateArtwork";
|
import { updateArtwork } from "@/actions/artworks/updateArtwork";
|
||||||
// import { updateImage } from "@/actions/portfolio/images/updateImage";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Calendar } from "@/components/ui/calendar";
|
import { Calendar } from "@/components/ui/calendar";
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
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 { Switch } from "@/components/ui/switch";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { ArtTag } from "@/generated/prisma/client";
|
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 { cn } from "@/lib/utils";
|
||||||
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
import { artworkSchema } from "@/schemas/artworks/imageSchema";
|
||||||
// import { imageSchema } from "@/schemas/portfolio/imageSchema";
|
|
||||||
import { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork";
|
import { ArtworkWithRelations, CategoryWithTags } from "@/types/Artwork";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
@ -45,15 +42,10 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
month: artwork.month || undefined,
|
month: artwork.month || undefined,
|
||||||
year: artwork.year || undefined,
|
year: artwork.year || undefined,
|
||||||
creationDate: artwork.creationDate ? new Date(artwork.creationDate) : 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) ?? [],
|
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) ?? [],
|
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 }:
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="notes"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Internal notes</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea {...field} placeholder="Any note to the image" />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
{/* Number */}
|
{/* Number */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@ -192,89 +197,62 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/* Select */}
|
{/* Select */}
|
||||||
{/* <FormField
|
|
||||||
control={form.control}
|
|
||||||
name="albumId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Album</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={(value) => field.onChange(value === "" ? undefined : value)}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select an album" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{albums.map((album) => (
|
|
||||||
<SelectItem key={album.id} value={album.id}>
|
|
||||||
{album.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/> */}
|
|
||||||
{/* <FormField
|
|
||||||
control={form.control}
|
|
||||||
name="typeId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Art Type</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={(value) => field.onChange(value === "" ? undefined : value)}
|
|
||||||
value={field.value ?? ""}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select an art type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{types.map((type) => (
|
|
||||||
<SelectItem key={type.id} value={type.id}>
|
|
||||||
{type.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/> */}
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="categoryIds"
|
name="categoryIds"
|
||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
|
const existingOptions = categories.map((cat) => ({
|
||||||
|
label: cat.name,
|
||||||
|
value: cat.id,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectedCategoryIds = field.value ?? [];
|
||||||
const selectedOptions = categories
|
const selectedOptions = categories
|
||||||
.filter(cat => field.value?.includes(cat.id))
|
.filter((cat) => selectedCategoryIds.includes(cat.id))
|
||||||
.map(cat => ({ label: cat.name, value: cat.id }));
|
.map((cat) => ({ label: cat.name, value: cat.id }));
|
||||||
|
|
||||||
|
// Also include any "new" selections so they stay visible after selection
|
||||||
|
const newCategoryNames = form.watch("newCategoryNames") ?? [];
|
||||||
|
const newSelectedOptions = newCategoryNames.map((name) => ({
|
||||||
|
label: `Create: ${name}`,
|
||||||
|
value: `__new__:${name}`,
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Categories</FormLabel>
|
<FormLabel>Categories</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<MultipleSelector
|
<MultipleSelector
|
||||||
defaultOptions={categories.map(cat => ({
|
options={existingOptions}
|
||||||
label: cat.name,
|
placeholder="Select or type to create categories"
|
||||||
value: cat.id,
|
|
||||||
}))}
|
|
||||||
placeholder="Select categories"
|
|
||||||
hidePlaceholderWhenSelected
|
hidePlaceholderWhenSelected
|
||||||
selectFirstItem
|
selectFirstItem
|
||||||
value={selectedOptions}
|
value={[...selectedOptions, ...newSelectedOptions]}
|
||||||
|
creatable
|
||||||
|
createOption={(raw) => ({
|
||||||
|
value: `__new__:${raw}`,
|
||||||
|
label: `Create: ${raw}`,
|
||||||
|
})}
|
||||||
onChange={(options) => {
|
onChange={(options) => {
|
||||||
const ids = options.map(option => option.value);
|
const values = options.map((o) => o.value);
|
||||||
field.onChange(ids);
|
|
||||||
|
const existingIds = values.filter((v) => !v.startsWith("__new__:"));
|
||||||
|
const newNames = values
|
||||||
|
.filter((v) => v.startsWith("__new__:"))
|
||||||
|
.map((v) => v.replace("__new__:", "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
field.onChange(existingIds);
|
||||||
|
form.setValue("newCategoryNames", Array.from(new Set(newNames)), {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -284,6 +262,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
render={({ field }) => {
|
render={({ field }) => {
|
||||||
const selectedTagIds = field.value ?? [];
|
const selectedTagIds = field.value ?? [];
|
||||||
const selectedCategoryIds = form.watch("categoryIds") ?? [];
|
const selectedCategoryIds = form.watch("categoryIds") ?? [];
|
||||||
|
const newTagNames = form.watch("newTagNames") ?? [];
|
||||||
|
|
||||||
// Tag IDs connected to selected categories
|
// Tag IDs connected to selected categories
|
||||||
const preferredTagIds = new Set<string>();
|
const preferredTagIds = new Set<string>();
|
||||||
@ -292,27 +271,29 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
for (const t of cat.tags) preferredTagIds.add(t.id);
|
for (const t of cat.tags) preferredTagIds.add(t.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build grouped options: Selected -> Category -> Other
|
// Existing tag options with groups
|
||||||
const tagOptions = tags
|
const tagOptions = tags
|
||||||
.map((t) => {
|
.map((t) => {
|
||||||
let group = "Other tags";
|
let group = "Other tags";
|
||||||
if (selectedTagIds.includes(t.id)) group = "Selected";
|
if (selectedTagIds.includes(t.id)) group = "Selected";
|
||||||
else if (preferredTagIds.has(t.id)) group = "From selected categories";
|
else if (preferredTagIds.has(t.id)) group = "From selected categories";
|
||||||
|
|
||||||
return {
|
return { label: t.name, value: t.id, group };
|
||||||
label: t.name,
|
|
||||||
value: t.id,
|
|
||||||
group, // IMPORTANT: groupBy will use this
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
// Optional: stable ordering within each group
|
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
|
|
||||||
// Selected value objects
|
// Selected existing tags
|
||||||
const selectedOptions = tags
|
const selectedExistingOptions = tags
|
||||||
.filter((t) => selectedTagIds.includes(t.id))
|
.filter((t) => selectedTagIds.includes(t.id))
|
||||||
.map((t) => ({ label: t.name, value: t.id }));
|
.map((t) => ({ label: t.name, value: t.id }));
|
||||||
|
|
||||||
|
// Selected "new" tags (so they remain visible)
|
||||||
|
const selectedNewOptions = newTagNames.map((name) => ({
|
||||||
|
label: `Create: ${name}`,
|
||||||
|
value: `__new__:${name}`,
|
||||||
|
group: "Selected",
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Tags</FormLabel>
|
<FormLabel>Tags</FormLabel>
|
||||||
@ -322,11 +303,31 @@ export default function EditArtworkForm({ artwork, categories, tags }:
|
|||||||
groupBy="group"
|
groupBy="group"
|
||||||
groupOrder={["Selected", "From selected categories", "Other tags"]}
|
groupOrder={["Selected", "From selected categories", "Other tags"]}
|
||||||
showSelectedInDropdown
|
showSelectedInDropdown
|
||||||
placeholder="Select tags"
|
placeholder="Select or type to create tags"
|
||||||
hidePlaceholderWhenSelected
|
hidePlaceholderWhenSelected
|
||||||
selectFirstItem
|
selectFirstItem
|
||||||
value={selectedOptions}
|
value={[...selectedExistingOptions, ...selectedNewOptions]}
|
||||||
onChange={(options) => field.onChange(options.map((o) => o.value))}
|
creatable
|
||||||
|
createOption={(raw) => ({
|
||||||
|
value: `__new__:${raw}`,
|
||||||
|
label: `Create: ${raw}`,
|
||||||
|
group: "Selected",
|
||||||
|
})}
|
||||||
|
onChange={(options) => {
|
||||||
|
const values = options.map((o) => o.value);
|
||||||
|
|
||||||
|
const existingIds = values.filter((v) => !v.startsWith("__new__:"));
|
||||||
|
const newNames = values
|
||||||
|
.filter((v) => v.startsWith("__new__:"))
|
||||||
|
.map((v) => v.replace("__new__:", "").trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
field.onChange(existingIds);
|
||||||
|
form.setValue("newTagNames", Array.from(new Set(newNames)), {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
|
|||||||
@ -83,6 +83,12 @@ interface MultipleSelectorProps {
|
|||||||
|
|
||||||
/** Optional explicit group ordering (top to bottom). */
|
/** Optional explicit group ordering (top to bottom). */
|
||||||
groupOrder?: string[];
|
groupOrder?: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Customize how a new (creatable) option is represented.
|
||||||
|
* Defaults to value = input text (current behavior).
|
||||||
|
*/
|
||||||
|
createOption?: (raw: string) => Option;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MultipleSelectorRef {
|
export interface MultipleSelectorRef {
|
||||||
@ -145,6 +151,10 @@ function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeInput(s: string) {
|
||||||
|
return s.trim().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
|
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
|
||||||
* So we create one and copy the `Empty` implementation from `cmdk`.
|
* So we create one and copy the `Empty` implementation from `cmdk`.
|
||||||
@ -194,6 +204,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
|
|||||||
badgeClassName,
|
badgeClassName,
|
||||||
selectFirstItem = true,
|
selectFirstItem = true,
|
||||||
creatable = false,
|
creatable = false,
|
||||||
|
createOption,
|
||||||
triggerSearchOnFocus = false,
|
triggerSearchOnFocus = false,
|
||||||
commandProps,
|
commandProps,
|
||||||
inputProps,
|
inputProps,
|
||||||
@ -354,45 +365,56 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
|
|||||||
|
|
||||||
const CreatableItem = () => {
|
const CreatableItem = () => {
|
||||||
if (!creatable) return undefined;
|
if (!creatable) return undefined;
|
||||||
if (
|
|
||||||
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
|
const raw = normalizeInput(inputValue);
|
||||||
selected.find((s) => s.value === inputValue)
|
if (!raw) return undefined;
|
||||||
) {
|
|
||||||
return undefined;
|
// Check if an option with same label already exists (case-insensitive)
|
||||||
}
|
const labelExistsInOptions = Object.values(options).some((group) =>
|
||||||
|
group.some((o) => o.label.toLowerCase() === raw.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const labelExistsInSelected = selected.some(
|
||||||
|
(s) => s.label.toLowerCase() === raw.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (labelExistsInOptions || labelExistsInSelected) return undefined;
|
||||||
|
|
||||||
|
const created = createOption ? createOption(raw) : { value: raw, label: raw };
|
||||||
|
|
||||||
const Item = (
|
const Item = (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
value={inputValue}
|
value={raw}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
onSelect={(value: string) => {
|
onSelect={() => {
|
||||||
if (selected.length >= maxSelected) {
|
if (selected.length >= maxSelected) {
|
||||||
onMaxSelected?.(selected.length);
|
onMaxSelected?.(selected.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setInputValue('');
|
|
||||||
const newOptions = [...selected, { value, label: value }];
|
setInputValue("");
|
||||||
|
|
||||||
|
// Guard against duplicates (by value)
|
||||||
|
if (selected.some((s) => s.value === created.value)) return;
|
||||||
|
|
||||||
|
const newOptions = [...selected, created];
|
||||||
setSelected(newOptions);
|
setSelected(newOptions);
|
||||||
onChange?.(newOptions);
|
onChange?.(newOptions);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{`Create "${inputValue}"`}
|
{`Create "${raw}"`}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
);
|
);
|
||||||
|
|
||||||
// For normal creatable
|
// For normal creatable
|
||||||
if (!onSearch && inputValue.length > 0) {
|
if (!onSearch) return Item;
|
||||||
return Item;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For async search creatable. avoid showing creatable item before loading at first.
|
// For async search creatable: show only when user typed something and search isn't loading
|
||||||
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
|
if (raw.length > 0 && !isLoading) return Item;
|
||||||
return Item;
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
@ -604,7 +626,7 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
|
|||||||
return (
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.label}
|
value={option.value}
|
||||||
disabled={disabledItem}
|
disabled={disabledItem}
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@ -32,16 +32,11 @@ export const artworkSchema = z.object({
|
|||||||
month: z.number().optional(),
|
month: z.number().optional(),
|
||||||
year: z.number().optional(),
|
year: z.number().optional(),
|
||||||
creationDate: z.date().optional(),
|
creationDate: z.date().optional(),
|
||||||
|
|
||||||
// fileId: z.string(),
|
|
||||||
// albumId: z.string().optional(),
|
|
||||||
// typeId: z.string().optional(),
|
|
||||||
|
|
||||||
metadataId: z.string().optional(),
|
|
||||||
|
|
||||||
categoryIds: z.array(z.string()).optional(),
|
categoryIds: z.array(z.string()).optional(),
|
||||||
colorIds: z.array(z.string()).optional(),
|
|
||||||
// sortContextIds: z.array(z.string()).optional(),
|
|
||||||
tagIds: z.array(z.string()).optional(),
|
tagIds: z.array(z.string()).optional(),
|
||||||
variantIds: z.array(z.string()).optional(),
|
newTagNames: z.array(z.string().min(1)).optional(),
|
||||||
|
newCategoryNames: z.array(z.string().min(1)).optional(),
|
||||||
|
// colorIds: z.array(z.string()).optional(),
|
||||||
|
// sortContextIds: z.array(z.string()).optional(),
|
||||||
|
// variantIds: z.array(z.string()).optional(),
|
||||||
})
|
})
|
||||||
24
src/utils/artworkHelpers.ts
Normal file
24
src/utils/artworkHelpers.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
export function slugify(input: string) {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/['"]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeNames(items?: string[]) {
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const raw of items ?? []) {
|
||||||
|
const name = raw.trim().replace(/\s+/g, " ");
|
||||||
|
if (!name) continue;
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user