Refactor color generaton on artwork single page

This commit is contained in:
2025-12-25 22:51:17 +01:00
parent 25ba47f7be
commit e9176ff73e
6 changed files with 211 additions and 76 deletions

View File

@ -0,0 +1,11 @@
"use server";
import { prisma } from "@/lib/prisma";
export async function getArtworkColors(artworkId: string) {
return prisma.artworkColor.findMany({
where: { artworkId },
include: { color: true },
orderBy: [{ type: "asc" }],
});
}

View File

@ -44,7 +44,7 @@ export default async function ArtworkSinglePage({ params }: { params: { id: stri
</div>
<div className="space-y-6">
<div>
{item && <ArtworkColors colors={item.colors} artworkId={item.id} fileKey={item.file.fileKey} fileType={item.file.fileType || ""} />}
{item && <ArtworkColors colors={item.colors} artworkId={item.id} />}
</div>
</div>
</div>

View File

@ -38,7 +38,7 @@ export function ArtworkColorProcessor() {
};
const done =
stats &&
!!stats &&
stats.pending === 0 &&
stats.processing === 0 &&
stats.failed === 0 &&

View File

@ -1,50 +1,62 @@
"use client"
"use client";
// import { generateArtworkColors } from "@/actions/artworks/generateArtworkColors";
// import { generateImageColors } from "@/actions/portfolio/images/generateImageColors";
import { ArtworkColor, Color } from "@/generated/prisma/client";
// import { Color, ImageColor } from "@/generated/prisma";
import { useState, useTransition } from "react";
import { generateArtworkColorsForArtwork } from "@/actions/artworks/generateArtworkColors";
import { getArtworkColors } from "@/actions/artworks/getArtworkColors";
// import { generateArtworkColorsForArtwork } from "@/actions/colors/generateArtworkColorsForArtwork";
// import { getArtworkColors } from "@/actions/colors/getArtworkColors";
import { Button } from "@/components/ui/button";
import type { ArtworkColor, Color } from "@/generated/prisma/client";
import * as React from "react";
// import { toast } from "sonner"; // if you use it
type ColorWithItems = ArtworkColor & {
color: Color
};
type ColorWithItems = ArtworkColor & { color: Color };
export default function ArtworkColors({ colors: initialColors, artworkId, fileKey, fileType }: { colors: ColorWithItems[], artworkId: string, fileKey: string, fileType?: string }) {
const [colors, setColors] = useState(initialColors);
const [isPending, startTransition] = useTransition();
export default function ArtworkColors({
colors: initialColors,
artworkId,
}: {
colors: ColorWithItems[];
artworkId: string;
}) {
const [colors, setColors] = React.useState(initialColors);
const [isPending, startTransition] = React.useTransition();
// const handleGenerate = () => {
// startTransition(async () => {
// try {
// const newColors = await generateArtworkColorsForArtwork(artworkId, fileKey, fileType);
// setColors(newColors);
// toast.success("Colors extracted successfully");
// } catch (err) {
// toast.error("Failed to extract colors");
// console.error(err);
// }
// });
// };
const handleGenerate = () => {
startTransition(async () => {
try {
const res = await generateArtworkColorsForArtwork(artworkId);
if (!res.ok) throw new Error(res.error ?? "Color generation failed");
const fresh = await getArtworkColors(artworkId);
setColors(fresh);
// toast.success("Colors extracted successfully");
} catch (err) {
// toast.error(err instanceof Error ? err.message : "Failed to extract colors");
console.error(err);
}
});
};
return (
<>
<div className="flex items-center justify-between mb-2">
<h2 className="font-semibold text-lg">Artwork Colors</h2>
{/* <Button size="sm" onClick={handleGenerate} disabled={isPending}>
<Button size="sm" onClick={handleGenerate} disabled={isPending}>
{isPending ? "Extracting..." : "Generate Palette"}
</Button> */}
</div >
</Button>
</div>
<div className="flex flex-wrap gap-2">
{colors.map((item) => (
<div
key={`${item.artworkId}-${item.type}`}
className="w-10 h-10 rounded"
style={{ backgroundColor: item.color?.hex ?? "#000000" }}
title={`${item.type} ${item.color?.hex}`}
></div>
title={`${item.type} ${item.color?.hex ?? ""}`}
/>
))}
</div>
</>
);
}
}

View File

@ -1,15 +1,29 @@
// import { ImageVariant } from "@/generated/prisma";
// import { formatFileSize } from "@/utils/formatFileSize";
import { FileVariant } from "@/generated/prisma/client";
import { formatFileSize } from "@/utils/formatFileSize";
import NextImage from "next/image";
const ORDER: Record<string, number> = {
thumbnail: 0,
resized: 1,
modified: 2,
original: 3,
};
function byVariantOrder(a: FileVariant, b: FileVariant) {
const ra = ORDER[a.type] ?? 999;
const rb = ORDER[b.type] ?? 999;
if (ra !== rb) return ra - rb;
return a.type.localeCompare(b.type);
}
export default function ArtworkVariants({ variants }: { variants: FileVariant[] }) {
const sorted = [...variants].sort(byVariantOrder);
return (
<>
<h2 className="font-semibold text-lg mb-2">Variants</h2>
<div>
{variants.map((variant) => (
{sorted.map((variant) => (
<div key={variant.id}>
<div className="text-sm mb-1">{variant.type} | {variant.width}x{variant.height}px | {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}</div>
{variant.s3Key && (

View File

@ -1,4 +1,4 @@
"use client"
"use client";
import { createImage } from "@/actions/uploads/createImage";
import { Button } from "@/components/ui/button";
@ -8,46 +8,117 @@ import { fileUploadSchema } from "@/schemas/artworks/imageSchema";
import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { useState } from "react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import * as z from "zod/v4";
type UploadStatus = "empty" | "queued" | "uploading" | "done" | "error";
export default function UploadImageForm() {
const router = useRouter();
const [preview, setPreview] = useState<string | null>(null);
const form = useForm<z.infer<typeof fileUploadSchema>>({
resolver: zodResolver(fileUploadSchema),
defaultValues: {
file: undefined
},
})
defaultValues: { file: undefined },
});
const [status, setStatus] = React.useState<UploadStatus>("empty");
const [progress, setProgress] = React.useState(0);
const [error, setError] = React.useState<string | null>(null);
const [file, setFile] = React.useState<File | null>(null);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const intervalRef = React.useRef<number | null>(null);
React.useEffect(() => {
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
if (intervalRef.current) window.clearInterval(intervalRef.current);
};
}, [previewUrl]);
const startSimProgress = () => {
if (intervalRef.current) window.clearInterval(intervalRef.current);
intervalRef.current = window.setInterval(() => {
setProgress((p) => Math.min(90, p + Math.random() * 7 + 1));
}, 250);
};
const stopSimProgress = () => {
if (intervalRef.current) window.clearInterval(intervalRef.current);
intervalRef.current = null;
};
const resetAll = () => {
stopSimProgress();
setStatus("empty");
setProgress(0);
setError(null);
setFile(null);
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(null);
form.reset();
};
const onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
const reader = new FileReader();
const f = files[0];
setFile(f);
setError(null);
setStatus("queued");
setProgress(0);
reader.onloadend = () => {
if (typeof reader.result === 'string') {
setPreview(reader.result as string);
}
};
reader.readAsDataURL(file);
form.setValue("file", files);
if (previewUrl) URL.revokeObjectURL(previewUrl);
setPreviewUrl(URL.createObjectURL(f));
// keep schema shape (FileList)
form.setValue("file", files, { shouldValidate: true });
};
async function onSubmit(values: z.infer<typeof fileUploadSchema>) {
const image = await createImage(values)
if (image) {
toast.success("Image created")
router.push(`/artworks/${image.id}`)
setError(null);
const hasFile = (values.file?.length ?? 0) > 0;
if (!hasFile) return;
setStatus("uploading");
setProgress((p) => Math.max(p, 5));
startSimProgress();
try {
const image = await createImage(values);
stopSimProgress();
setProgress(100);
if (image) {
setStatus("done");
toast.success("Image created");
router.push(`/artworks/${image.id}`);
return;
}
setStatus("error");
setError("Upload failed");
toast.error("Upload failed");
} catch (err) {
stopSimProgress();
setProgress(100);
setStatus("error");
const msg = err instanceof Error ? err.message : "Upload failed";
setError(msg);
toast.error(msg);
}
}
const isBusy = status === "uploading";
return (
<>
<Form {...form}>
@ -59,34 +130,61 @@ export default function UploadImageForm() {
<FormItem>
<FormLabel>Choose image to upload</FormLabel>
<FormControl>
<Input
type="file"
accept="image/*"
onChange={(e) => onFileChange(e)}
/>
<Input type="file" accept="image/*" onChange={onFileChange} />
</FormControl>
</FormItem>
)}
/>
<div className="flex gap-4">
<Button type="submit">Submit</Button>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button>
<Button type="submit" disabled={!file || isBusy}>
{isBusy ? "Uploading..." : "Submit"}
</Button>
<Button type="button" variant="secondary" disabled={isBusy} onClick={() => router.back()}>
Cancel
</Button>
<Button type="button" variant="outline" disabled={isBusy && !file} onClick={resetAll}>
Reset
</Button>
</div>
</form>
</Form>
<div className="flex justify-center p-4">
{
preview ?
<Image
src={preview}
alt="Preview"
width={200}
height={200}
/>
:
null
}
</div>
{file ? (
<div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="rounded-lg border p-3">
<div className="relative aspect-square w-full overflow-hidden rounded-md">
{previewUrl ? (
<Image src={previewUrl} alt={file.name} fill className="object-cover" />
) : null}
</div>
<div className="mt-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{file.name}</div>
<div className="text-xs text-muted-foreground">
{status === "queued" && "Queued"}
{status === "uploading" && "Uploading / Processing"}
{status === "done" && "Done"}
{status === "error" && "Error"}
</div>
</div>
</div>
<div className="mt-2">
<div className="h-2 w-full rounded bg-muted">
<div className="h-2 rounded bg-primary transition-all" style={{ width: `${progress}%` }} />
</div>
{status === "error" && error ? (
<div className="mt-2 text-xs text-destructive">{error}</div>
) : null}
</div>
</div>
</div>
) : null}
</>
);
}
}