@@ -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 */}
|
||||
|
||||
@@ -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