Refactor color generaton on artwork single page
This commit is contained in:
11
src/actions/artworks/getArtworkColors.ts
Normal file
11
src/actions/artworks/getArtworkColors.ts
Normal 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" }],
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -38,7 +38,7 @@ export function ArtworkColorProcessor() {
|
||||
};
|
||||
|
||||
const done =
|
||||
stats &&
|
||||
!!stats &&
|
||||
stats.pending === 0 &&
|
||||
stats.processing === 0 &&
|
||||
stats.failed === 0 &&
|
||||
|
||||
@ -1,48 +1,60 @@
|
||||
"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 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");
|
||||
// } catch (err) {
|
||||
// toast.error("Failed to extract colors");
|
||||
// console.error(err);
|
||||
// }
|
||||
// });
|
||||
// };
|
||||
} 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>
|
||||
</>
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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)
|
||||
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) {
|
||||
toast.success("Image created")
|
||||
router.push(`/artworks/${image.id}`)
|
||||
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
|
||||
}
|
||||
|
||||
{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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user