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>
<div className="space-y-6"> <div className="space-y-6">
<div> <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> </div>
</div> </div>

View File

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

View File

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

View File

@ -1,15 +1,29 @@
// import { ImageVariant } from "@/generated/prisma";
// import { formatFileSize } from "@/utils/formatFileSize";
import { FileVariant } from "@/generated/prisma/client"; import { FileVariant } from "@/generated/prisma/client";
import { formatFileSize } from "@/utils/formatFileSize"; import { formatFileSize } from "@/utils/formatFileSize";
import NextImage from "next/image"; 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[] }) { export default function ArtworkVariants({ variants }: { variants: FileVariant[] }) {
const sorted = [...variants].sort(byVariantOrder);
return ( return (
<> <>
<h2 className="font-semibold text-lg mb-2">Variants</h2> <h2 className="font-semibold text-lg mb-2">Variants</h2>
<div> <div>
{variants.map((variant) => ( {sorted.map((variant) => (
<div key={variant.id}> <div key={variant.id}>
<div className="text-sm mb-1">{variant.type} | {variant.width}x{variant.height}px | {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}</div> <div className="text-sm mb-1">{variant.type} | {variant.width}x{variant.height}px | {variant.sizeBytes ? formatFileSize(variant.sizeBytes) : "-"}</div>
{variant.s3Key && ( {variant.s3Key && (

View File

@ -1,4 +1,4 @@
"use client" "use client";
import { createImage } from "@/actions/uploads/createImage"; import { createImage } from "@/actions/uploads/createImage";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -8,46 +8,117 @@ import { fileUploadSchema } from "@/schemas/artworks/imageSchema";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import * as React from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import * as z from "zod/v4"; import * as z from "zod/v4";
type UploadStatus = "empty" | "queued" | "uploading" | "done" | "error";
export default function UploadImageForm() { export default function UploadImageForm() {
const router = useRouter(); const router = useRouter();
const [preview, setPreview] = useState<string | null>(null);
const form = useForm<z.infer<typeof fileUploadSchema>>({ const form = useForm<z.infer<typeof fileUploadSchema>>({
resolver: zodResolver(fileUploadSchema), resolver: zodResolver(fileUploadSchema),
defaultValues: { defaultValues: { file: undefined },
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 onFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files; const files = e.target.files;
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const file = files[0]; const f = files[0];
const reader = new FileReader(); setFile(f);
setError(null);
setStatus("queued");
setProgress(0);
reader.onloadend = () => { if (previewUrl) URL.revokeObjectURL(previewUrl);
if (typeof reader.result === 'string') { setPreviewUrl(URL.createObjectURL(f));
setPreview(reader.result as string);
} // keep schema shape (FileList)
}; form.setValue("file", files, { shouldValidate: true });
reader.readAsDataURL(file);
form.setValue("file", files);
}; };
async function onSubmit(values: z.infer<typeof fileUploadSchema>) { async function onSubmit(values: z.infer<typeof fileUploadSchema>) {
const image = await createImage(values) 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) { if (image) {
toast.success("Image created") setStatus("done");
router.push(`/artworks/${image.id}`) 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 ( return (
<> <>
<Form {...form}> <Form {...form}>
@ -59,34 +130,61 @@ export default function UploadImageForm() {
<FormItem> <FormItem>
<FormLabel>Choose image to upload</FormLabel> <FormLabel>Choose image to upload</FormLabel>
<FormControl> <FormControl>
<Input <Input type="file" accept="image/*" onChange={onFileChange} />
type="file"
accept="image/*"
onChange={(e) => onFileChange(e)}
/>
</FormControl> </FormControl>
</FormItem> </FormItem>
)} )}
/> />
<div className="flex gap-4"> <div className="flex gap-4">
<Button type="submit">Submit</Button> <Button type="submit" disabled={!file || isBusy}>
<Button type="reset" variant="secondary" onClick={() => router.back()}>Cancel</Button> {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> </div>
</form> </form>
</Form> </Form>
<div className="flex justify-center p-4">
{ {file ? (
preview ? <div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2 lg:grid-cols-3">
<Image <div className="rounded-lg border p-3">
src={preview} <div className="relative aspect-square w-full overflow-hidden rounded-md">
alt="Preview" {previewUrl ? (
width={200} <Image src={previewUrl} alt={file.name} fill className="object-cover" />
height={200} ) : null}
/>
:
null
}
</div> </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}
</> </>
); );
} }