@@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import Image from "next/image";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import { Upload, X, Loader2, Pencil, Check, LayoutTemplate } from "lucide-react";
|
||||
|
||||
interface SketchTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export function SketchTemplatesSection() {
|
||||
const { toast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [templates, setTemplates] = useState<SketchTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [pendingName, setPendingName] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
|
||||
const fetchTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/sketch-templates");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setTemplates(data.templates);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchTemplates(); }, [fetchTemplates]);
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (pendingName.trim()) fd.append("name", pendingName.trim());
|
||||
const res = await fetch("/api/sketch-templates", { method: "POST", body: fd });
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error ?? "Upload failed");
|
||||
}
|
||||
const { template } = await res.json();
|
||||
setTemplates((prev) => [...prev, template]);
|
||||
setPendingName("");
|
||||
} catch (err) {
|
||||
toast({ title: "Upload failed", description: err instanceof Error ? err.message : undefined, variant: "destructive" });
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/sketch-templates/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("Delete failed");
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||
} catch {
|
||||
toast({ title: "Failed to delete template", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = (t: SketchTemplate) => {
|
||||
setEditingId(t.id);
|
||||
setEditName(t.name);
|
||||
};
|
||||
|
||||
const saveEdit = async (id: string) => {
|
||||
if (!editName.trim()) return;
|
||||
try {
|
||||
const res = await fetch(`/api/sketch-templates/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editName }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Rename failed");
|
||||
setTemplates((prev) => prev.map((t) => t.id === id ? { ...t, name: editName.trim() } : t));
|
||||
} catch {
|
||||
toast({ title: "Failed to rename template", variant: "destructive" });
|
||||
} finally {
|
||||
setEditingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||||
<LayoutTemplate className="h-4 w-4 text-amber-500" />
|
||||
Sketch Templates
|
||||
</div>
|
||||
<Separator />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Upload background images (body diagrams, scene layouts, storyboard frames, etc.) that artists can load as a canvas background when sketching on a take.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" /> Loading…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Template grid */}
|
||||
{templates.length > 0 && (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
|
||||
{templates.map((t) => (
|
||||
<div key={t.id} className="group relative rounded-lg border border-border overflow-hidden bg-zinc-900">
|
||||
<div className="relative aspect-[4/3]">
|
||||
<Image
|
||||
src={t.fileUrl}
|
||||
alt={t.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="180px"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-1.5 flex items-center gap-1">
|
||||
{editingId === t.id ? (
|
||||
<>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveEdit(t.id);
|
||||
if (e.key === "Escape") setEditingId(null);
|
||||
}}
|
||||
className="h-6 text-xs px-1 bg-zinc-800 border-zinc-600"
|
||||
autoFocus
|
||||
/>
|
||||
<button onClick={() => saveEdit(t.id)} className="shrink-0 text-green-400 hover:text-green-300">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-[11px] text-zinc-300 truncate">{t.name}</span>
|
||||
<button onClick={() => startEdit(t)} className="shrink-0 text-zinc-600 hover:text-zinc-300 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button onClick={() => handleDelete(t.id)} className="shrink-0 text-zinc-600 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Input
|
||||
placeholder="Template name (optional)"
|
||||
value={pendingName}
|
||||
onChange={(e) => setPendingName(e.target.value)}
|
||||
className="h-8 text-sm max-w-[200px]"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={uploading}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{uploading ? <Loader2 className="h-3.5 w-3.5 mr-1.5 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1.5" />}
|
||||
{uploading ? "Uploading…" : "Upload Template"}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 && (
|
||||
<p className="text-xs text-zinc-600">No templates yet.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user