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<{
+
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 */}
+
+
+
+
{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
+
+
+
+
+
+ )}
+ />
{/* Number */}
{/* Select */}
- {/* (
-
- Album
-
-
-
- )}
- /> */}
- {/* (
-
- Art Type
-
-
-
- )}
- /> */}
{
+ const existingOptions = categories.map((cat) => ({
+ label: cat.name,
+ value: cat.id,
+ }));
+
+ const selectedCategoryIds = field.value ?? [];
const selectedOptions = categories
- .filter(cat => field.value?.includes(cat.id))
- .map(cat => ({ label: cat.name, value: cat.id }));
+ .filter((cat) => selectedCategoryIds.includes(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 (
Categories
({
- label: cat.name,
- value: cat.id,
- }))}
- placeholder="Select categories"
+ options={existingOptions}
+ placeholder="Select or type to create categories"
hidePlaceholderWhenSelected
selectFirstItem
- value={selectedOptions}
+ value={[...selectedOptions, ...newSelectedOptions]}
+ creatable
+ createOption={(raw) => ({
+ value: `__new__:${raw}`,
+ label: `Create: ${raw}`,
+ })}
onChange={(options) => {
- const ids = options.map(option => option.value);
- field.onChange(ids);
+ 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("newCategoryNames", Array.from(new Set(newNames)), {
+ shouldDirty: true,
+ shouldValidate: true,
+ });
}}
/>
- )
+ );
}}
/>
@@ -284,6 +262,7 @@ export default function EditArtworkForm({ artwork, categories, tags }:
render={({ field }) => {
const selectedTagIds = field.value ?? [];
const selectedCategoryIds = form.watch("categoryIds") ?? [];
+ const newTagNames = form.watch("newTagNames") ?? [];
// Tag IDs connected to selected categories
const preferredTagIds = new Set();
@@ -292,27 +271,29 @@ export default function EditArtworkForm({ artwork, categories, tags }:
for (const t of cat.tags) preferredTagIds.add(t.id);
}
- // Build grouped options: Selected -> Category -> Other
+ // Existing tag options with groups
const tagOptions = tags
.map((t) => {
let group = "Other tags";
if (selectedTagIds.includes(t.id)) group = "Selected";
else if (preferredTagIds.has(t.id)) group = "From selected categories";
- return {
- label: t.name,
- value: t.id,
- group, // IMPORTANT: groupBy will use this
- };
+ return { label: t.name, value: t.id, group };
})
- // Optional: stable ordering within each group
.sort((a, b) => a.label.localeCompare(b.label));
- // Selected value objects
- const selectedOptions = tags
+ // Selected existing tags
+ const selectedExistingOptions = tags
.filter((t) => selectedTagIds.includes(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 (
Tags
@@ -322,11 +303,31 @@ export default function EditArtworkForm({ artwork, categories, tags }:
groupBy="group"
groupOrder={["Selected", "From selected categories", "Other tags"]}
showSelectedInDropdown
- placeholder="Select tags"
+ placeholder="Select or type to create tags"
hidePlaceholderWhenSelected
selectFirstItem
- value={selectedOptions}
- onChange={(options) => field.onChange(options.map((o) => o.value))}
+ value={[...selectedExistingOptions, ...selectedNewOptions]}
+ 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,
+ });
+ }}
/>
diff --git a/src/components/ui/multiselect.tsx b/src/components/ui/multiselect.tsx
index 443cfeb..71f1157 100644
--- a/src/components/ui/multiselect.tsx
+++ b/src/components/ui/multiselect.tsx
@@ -83,6 +83,12 @@ interface MultipleSelectorProps {
/** Optional explicit group ordering (top to bottom). */
groupOrder?: string[];
+
+ /**
+ * Customize how a new (creatable) option is represented.
+ * Defaults to value = input text (current behavior).
+ */
+ createOption?: (raw: string) => Option;
}
export interface MultipleSelectorRef {
@@ -145,6 +151,10 @@ function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
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.
* So we create one and copy the `Empty` implementation from `cmdk`.
@@ -194,6 +204,7 @@ const MultipleSelector = React.forwardRef {
if (!creatable) return undefined;
- if (
- isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
- selected.find((s) => s.value === inputValue)
- ) {
- return undefined;
- }
+
+ const raw = normalizeInput(inputValue);
+ if (!raw) 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 = (
{
e.preventDefault();
e.stopPropagation();
}}
- onSelect={(value: string) => {
+ onSelect={() => {
if (selected.length >= maxSelected) {
onMaxSelected?.(selected.length);
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);
onChange?.(newOptions);
}}
>
- {`Create "${inputValue}"`}
+ {`Create "${raw}"`}
);
// For normal creatable
- if (!onSearch && inputValue.length > 0) {
- return Item;
- }
+ if (!onSearch) return Item;
- // For async search creatable. avoid showing creatable item before loading at first.
- if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
- return Item;
- }
+ // For async search creatable: show only when user typed something and search isn't loading
+ if (raw.length > 0 && !isLoading) return Item;
return undefined;
};
@@ -604,7 +626,7 @@ const MultipleSelector = React.forwardRef {
e.preventDefault();
diff --git a/src/schemas/artworks/imageSchema.ts b/src/schemas/artworks/imageSchema.ts
index 34c54a1..2a82f32 100644
--- a/src/schemas/artworks/imageSchema.ts
+++ b/src/schemas/artworks/imageSchema.ts
@@ -32,16 +32,11 @@ export const artworkSchema = z.object({
month: z.number().optional(),
year: z.number().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(),
- colorIds: z.array(z.string()).optional(),
- // sortContextIds: 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(),
})
\ No newline at end of file
diff --git a/src/utils/artworkHelpers.ts b/src/utils/artworkHelpers.ts
new file mode 100644
index 0000000..b234678
--- /dev/null
+++ b/src/utils/artworkHelpers.ts
@@ -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();
+
+ 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;
+}
\ No newline at end of file