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
+85 -1
View File
@@ -4,8 +4,9 @@ import { useEffect, useRef, useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import {
ChevronLeft, ChevronRight, Copy, Plus, Check, Loader2,
Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X,
Trash2, AlertCircle, ChevronDown, ChevronUp, Link2, X, BookImage,
} from "lucide-react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
@@ -201,6 +202,86 @@ function SectionHeader({ title }: { title: string }) {
);
}
// ─── Linked shot panel ────────────────────────────────────────────────────────
interface LinkedShot {
id: string;
shotCode: string;
scene: string | null;
episode: string | null;
thumbnailUrl: string | null;
references: { id: string; fileUrl: string; fileName: string; label: string | null }[];
}
function LinkedShotPanel({ shotId }: { shotId: string }) {
const { data, isLoading } = useQuery<{ shot: LinkedShot }>({
queryKey: ["linked-shot", shotId],
queryFn: async () => {
const res = await fetch(`/api/shoot-log/shots/${shotId}`);
if (!res.ok) throw new Error("Failed");
return res.json();
},
staleTime: 60_000,
enabled: !!shotId,
});
if (isLoading) {
return (
<div className="flex items-center gap-2 text-xs text-zinc-500 mt-2">
<Loader2 className="h-3 w-3 animate-spin" /> Loading shot data
</div>
);
}
const shot = data?.shot;
if (!shot) return null;
const hasContent = shot.thumbnailUrl || shot.references.length > 0;
if (!hasContent) return null;
return (
<div className="mt-3 rounded-lg border border-zinc-800 bg-zinc-900/50 p-3 space-y-3">
<div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
<BookImage className="h-3 w-3" />
Shot Reference {shot.shotCode}
</div>
{shot.thumbnailUrl && (
<div className="relative w-full max-w-xs aspect-[2.39] rounded-md overflow-hidden border border-zinc-700">
<Image
src={shot.thumbnailUrl}
alt={shot.shotCode}
fill
className="object-cover"
sizes="320px"
/>
</div>
)}
{shot.references.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{shot.references.map((ref) => (
<div key={ref.id} className="relative aspect-square rounded-md overflow-hidden border border-zinc-700 bg-zinc-950 group">
<Image
src={ref.fileUrl}
alt={ref.label ?? ref.fileName}
fill
className="object-cover"
sizes="100px"
/>
{ref.label && (
<div className="absolute bottom-0 inset-x-0 bg-black/70 text-[9px] text-zinc-300 truncate px-1 py-0.5">
{ref.label}
</div>
)}
</div>
))}
</div>
)}
</div>
);
}
// ─── Shot selector ────────────────────────────────────────────────────────────
interface ShotOption {
@@ -440,6 +521,9 @@ export function TakeEditorPanel({
value={fields.pipelineShotId}
onChange={(id) => set("pipelineShotId", id)}
/>
{fields.pipelineShotId && (
<LinkedShotPanel shotId={fields.pipelineShotId} />
)}
</Field>
{/* Camera */}
+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>