@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user