reference support
Deploy / deploy (push) Successful in 3m38s

This commit is contained in:
twotalesanimation
2026-07-12 10:27:46 +02:00
parent 42e614c592
commit c09d06b6c7
6 changed files with 413 additions and 3 deletions
+141 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { useForm } from "react-hook-form";
@@ -20,7 +20,143 @@ import {
} from "@/components/ui/select";
import { updateShot, deleteShot } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import { Upload, X, Film, ImageIcon, Trash2 } from "lucide-react";
import { Upload, X, Film, ImageIcon, Trash2, BookImage, Loader2 } from "lucide-react";
// ─── Shot References ──────────────────────────────────────────────────────────
interface ShotRef {
id: string;
fileUrl: string;
fileName: string;
label: string | null;
sortOrder: number;
}
function ShotReferencesSection({ shotId }: { shotId: string }) {
const { toast } = useToast();
const [refs, setRefs] = useState<ShotRef[]>([]);
const [loading, setLoading] = useState(true);
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchRefs = useCallback(async () => {
try {
const res = await fetch(`/api/shots/${shotId}/references`);
if (res.ok) {
const data = await res.json();
setRefs(data.references);
}
} finally {
setLoading(false);
}
}, [shotId]);
useEffect(() => { fetchRefs(); }, [fetchRefs]);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
if (!files.length) return;
setUploading(true);
try {
for (const file of files) {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/shots/${shotId}/references`, { method: "POST", body: fd });
if (!res.ok) throw new Error(await res.text());
const { reference } = await res.json();
setRefs((prev) => [...prev, reference]);
}
} 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 (refId: string) => {
try {
const res = await fetch(`/api/shots/${shotId}/references?refId=${refId}`, { method: "DELETE" });
if (!res.ok) throw new Error("Delete failed");
setRefs((prev) => prev.filter((r) => r.id !== refId));
} catch {
toast({ title: "Failed to delete reference", variant: "destructive" });
}
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<BookImage className="h-4 w-4 text-amber-500" />
References
</div>
<Separator />
{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>
) : (
<>
{refs.length > 0 && (
<div className="grid grid-cols-3 sm:grid-cols-4 gap-3">
{refs.map((ref) => (
<div key={ref.id} className="relative group aspect-square rounded-lg overflow-hidden border border-border bg-zinc-900">
<Image
src={ref.fileUrl}
alt={ref.label ?? ref.fileName}
fill
className="object-cover"
sizes="160px"
/>
{ref.label && (
<div className="absolute bottom-0 inset-x-0 bg-black/70 text-[10px] text-zinc-300 truncate px-1.5 py-0.5">
{ref.label}
</div>
)}
<button
type="button"
onClick={() => handleDelete(ref.id)}
className="absolute top-1 right-1 bg-black/70 hover:bg-red-900 text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
title="Remove reference"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 text-sm text-muted-foreground cursor-pointer transition-colors disabled:opacity-50"
>
{uploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{uploading ? "Uploading…" : "Add reference images"}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleUpload}
/>
</div>
</>
)}
</div>
);
}
// ─── Settings form ────────────────────────────────────────────────────────────
const settingsSchema = z.object({
shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"),
@@ -318,6 +454,9 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps
/>
</div>
{/* References */}
<ShotReferencesSection shotId={shot.id} />
<Button type="submit" disabled={isSaving}>
{isSaving ? "Saving\u2026" : "Save Changes"}
</Button>