caaa333f02
Deploy / deploy (push) Failing after 19m11s
Two independent features landing together: Generated shot thumbnails — a shot can now carry a thumbnail promoted from a render export (Shot.generatedThumbnailUrl/generatedThumbnailFrom) alongside the existing brief-supplied thumbnailUrl, which always takes display precedence (lib/shot-thumbnail.ts) so a promoted frame can only ever fill a gap, never overwrite the brief. Shows with pipelineConfig.autoShotThumbnail promote automatically on every finished export (lib/render-pipeline/preview.ts); promoteExportThumbnail/ clearGeneratedThumbnail (actions/shots.ts) are the manual equivalent for everyone else. Worker-side seek logic for picking the promoted frame is covered by RenderWorker/VFXReviewWorker.Tests/ThumbnailSeekTests.cs. Bulk shot delete — the shot-status page's selection bar gets a "Bulk Actions" dropdown (alongside the existing Change Due Date) with Delete Shots. bulkDeleteShots() (actions/shots.ts) deletes one at a time rather than in a single statement: exports_shotId_fkey is ON DELETE RESTRICT by design, so a shot with renders or deliveries on record is protected and gets skipped and reported by name rather than aborting the whole selection or being silently destroyed along with its history. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
611 lines
22 KiB
TypeScript
611 lines
22 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect, useCallback } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import Image from "next/image";
|
||
import { useForm } from "react-hook-form";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { z } from "zod";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { Separator } from "@/components/ui/separator";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
updateShot,
|
||
deleteShot,
|
||
promoteExportThumbnail,
|
||
clearGeneratedThumbnail,
|
||
} from "@/actions/shots";
|
||
import { useToast } from "@/components/ui/use-toast";
|
||
import { Upload, X, Film, ImageIcon, Trash2, BookImage, Loader2, Sparkles } 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"),
|
||
description: z.string().optional(),
|
||
status: z.enum(["WAITING", "IN_PROGRESS", "INTERNAL_REVIEW", "READY_FOR_CLIENT", "CLIENT_REVIEW", "REVISIONS", "COMPLETE"]),
|
||
priority: z.enum(["LOW", "NORMAL", "HIGH", "URGENT"]),
|
||
fps: z.coerce.number().min(1).max(240),
|
||
frameStart: z.coerce.number().int().optional().or(z.literal("")),
|
||
frameEnd: z.coerce.number().int().optional().or(z.literal("")),
|
||
dueDate: z.string().optional(),
|
||
artistId: z.string().optional(),
|
||
});
|
||
|
||
type SettingsFormValues = z.infer<typeof settingsSchema>;
|
||
|
||
interface Artist {
|
||
id: string;
|
||
name: string | null;
|
||
email: string;
|
||
}
|
||
|
||
interface ShotSettingsTabProps {
|
||
shot: {
|
||
id: string;
|
||
shotCode: string;
|
||
description: string | null;
|
||
status: string;
|
||
priority: string;
|
||
fps: number;
|
||
frameStart?: number | null;
|
||
frameEnd?: number | null;
|
||
dueDate: Date | string | null;
|
||
artistId: string | null;
|
||
thumbnailUrl: string | null;
|
||
generatedThumbnailUrl?: string | null;
|
||
generatedThumbnailFrom?: string | null;
|
||
projectId: string;
|
||
};
|
||
artists: Artist[];
|
||
onSaved?: () => void;
|
||
}
|
||
|
||
export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps) {
|
||
const router = useRouter();
|
||
const { toast } = useToast();
|
||
const [isSaving, setIsSaving] = useState(false);
|
||
const [isDeleting, setIsDeleting] = useState(false);
|
||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||
const [thumbnailFile, setThumbnailFile] = useState<File | null>(null);
|
||
const [thumbnailPreview, setThumbnailPreview] = useState<string | null>(shot.thumbnailUrl ?? null);
|
||
const [clearThumbnail, setClearThumbnail] = useState(false);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
// Render-derived still, managed separately from the reference frame above.
|
||
const [generatedUrl, setGeneratedUrl] = useState(shot.generatedThumbnailUrl ?? null);
|
||
const [generatedFrom, setGeneratedFrom] = useState(shot.generatedThumbnailFrom ?? null);
|
||
const [isPromoting, setIsPromoting] = useState(false);
|
||
|
||
const handlePromote = async () => {
|
||
setIsPromoting(true);
|
||
try {
|
||
const res = await promoteExportThumbnail(shot.id);
|
||
setGeneratedUrl(res.generatedThumbnailUrl);
|
||
setGeneratedFrom(res.generatedThumbnailFrom);
|
||
toast({ title: `Thumbnail taken from ${res.generatedThumbnailFrom}` });
|
||
router.refresh();
|
||
} catch (err) {
|
||
toast({
|
||
title: "Could not use a render frame",
|
||
description: (err as Error).message,
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setIsPromoting(false);
|
||
}
|
||
};
|
||
|
||
const handleClearGenerated = async () => {
|
||
setIsPromoting(true);
|
||
try {
|
||
await clearGeneratedThumbnail(shot.id);
|
||
setGeneratedUrl(null);
|
||
setGeneratedFrom(null);
|
||
router.refresh();
|
||
} catch (err) {
|
||
toast({
|
||
title: "Could not clear the render frame",
|
||
description: (err as Error).message,
|
||
variant: "destructive",
|
||
});
|
||
} finally {
|
||
setIsPromoting(false);
|
||
}
|
||
};
|
||
|
||
const formatDate = (d: Date | string | null) => {
|
||
if (!d) return "";
|
||
return new Date(d).toISOString().split("T")[0];
|
||
};
|
||
|
||
const {
|
||
register,
|
||
handleSubmit,
|
||
setValue,
|
||
formState: { errors },
|
||
} = useForm<SettingsFormValues>({
|
||
resolver: zodResolver(settingsSchema),
|
||
defaultValues: {
|
||
shotCode: shot.shotCode,
|
||
description: shot.description ?? "",
|
||
status: shot.status as SettingsFormValues["status"],
|
||
priority: shot.priority as SettingsFormValues["priority"],
|
||
fps: shot.fps,
|
||
frameStart: shot.frameStart ?? "",
|
||
frameEnd: shot.frameEnd ?? "",
|
||
dueDate: formatDate(shot.dueDate),
|
||
artistId: shot.artistId ?? "__none__",
|
||
},
|
||
});
|
||
|
||
const handleThumbnailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setThumbnailFile(file);
|
||
setClearThumbnail(false);
|
||
const reader = new FileReader();
|
||
reader.onload = (ev) => setThumbnailPreview(ev.target?.result as string);
|
||
reader.readAsDataURL(file);
|
||
};
|
||
|
||
const onSubmit = async (values: SettingsFormValues) => {
|
||
setIsSaving(true);
|
||
try {
|
||
let thumbnailUrl: string | null | undefined = undefined;
|
||
|
||
if (clearThumbnail) {
|
||
thumbnailUrl = null;
|
||
} else if (thumbnailFile) {
|
||
const fd = new FormData();
|
||
fd.append("file", thumbnailFile);
|
||
fd.append("type", "image");
|
||
const res = await fetch("/api/upload", { method: "POST", body: fd });
|
||
if (!res.ok) throw new Error("Thumbnail upload failed");
|
||
const data = await res.json();
|
||
thumbnailUrl = data.url;
|
||
}
|
||
|
||
await updateShot({
|
||
shotId: shot.id,
|
||
shotCode: values.shotCode,
|
||
description: values.description || undefined,
|
||
status: values.status,
|
||
priority: values.priority,
|
||
fps: values.fps,
|
||
frameStart: values.frameStart !== "" && values.frameStart != null ? Number(values.frameStart) : null,
|
||
frameEnd: values.frameEnd !== "" && values.frameEnd != null ? Number(values.frameEnd) : null,
|
||
dueDate: values.dueDate || null,
|
||
artistId: values.artistId === "__none__" ? null : values.artistId,
|
||
thumbnailUrl,
|
||
});
|
||
|
||
toast({ title: "Shot updated" });
|
||
onSaved?.();
|
||
} catch (e) {
|
||
toast({ title: "Failed to save", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||
} finally {
|
||
setIsSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
setIsDeleting(true);
|
||
try {
|
||
await deleteShot(shot.id);
|
||
toast({ title: "Shot deleted" });
|
||
router.push(`/projects/${shot.projectId}`);
|
||
} catch (e) {
|
||
toast({ title: "Failed to delete shot", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
|
||
setIsDeleting(false);
|
||
setConfirmDelete(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-10 max-w-2xl">
|
||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
|
||
{/* Details */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||
<Film className="h-4 w-4 text-amber-500" />
|
||
Details
|
||
</div>
|
||
<Separator />
|
||
|
||
<div className="space-y-1.5">
|
||
<Label>Shot Code</Label>
|
||
<Input
|
||
{...register("shotCode")}
|
||
className="font-mono uppercase"
|
||
placeholder="SHOW_SC010_0010"
|
||
/>
|
||
{errors.shotCode && <p className="text-xs text-destructive">{errors.shotCode.message}</p>}
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label>Description</Label>
|
||
<Textarea {...register("description")} rows={3} placeholder="Shot description…" />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<Label>Status</Label>
|
||
<Select
|
||
defaultValue={shot.status}
|
||
onValueChange={(v) => setValue("status", v as SettingsFormValues["status"])}
|
||
>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="WAITING">Waiting</SelectItem>
|
||
<SelectItem value="IN_PROGRESS">In Progress</SelectItem>
|
||
<SelectItem value="INTERNAL_REVIEW">Internal Review</SelectItem>
|
||
<SelectItem value="READY_FOR_CLIENT">Ready for Client</SelectItem>
|
||
<SelectItem value="CLIENT_REVIEW">Client Review</SelectItem>
|
||
<SelectItem value="REVISIONS">Revisions</SelectItem>
|
||
<SelectItem value="COMPLETE">Complete</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<Label>Priority</Label>
|
||
<Select
|
||
defaultValue={shot.priority}
|
||
onValueChange={(v) => setValue("priority", v as SettingsFormValues["priority"])}
|
||
>
|
||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="LOW">Low</SelectItem>
|
||
<SelectItem value="NORMAL">Normal</SelectItem>
|
||
<SelectItem value="HIGH">High</SelectItem>
|
||
<SelectItem value="CRITICAL">Critical</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Timing */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||
<span className="text-amber-500 font-mono text-xs">FPS</span>
|
||
Timing
|
||
</div>
|
||
<Separator />
|
||
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div className="space-y-1.5">
|
||
<Label>FPS</Label>
|
||
<Input type="number" step="any" {...register("fps")} />
|
||
{errors.fps && <p className="text-xs text-destructive">{errors.fps.message}</p>}
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label>Frame Start</Label>
|
||
<Input type="number" {...register("frameStart")} placeholder="1001" />
|
||
</div>
|
||
<div className="space-y-1.5">
|
||
<Label>Frame End</Label>
|
||
<Input type="number" {...register("frameEnd")} placeholder="1100" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5 max-w-xs">
|
||
<Label>Due Date</Label>
|
||
<Input type="date" {...register("dueDate")} />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Assignment */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||
<span className="text-amber-500">👤</span>
|
||
Assignment
|
||
</div>
|
||
<Separator />
|
||
|
||
<div className="space-y-1.5 max-w-xs">
|
||
<Label>Artist</Label>
|
||
<Select
|
||
defaultValue={shot.artistId ?? "__none__"}
|
||
onValueChange={(v) => setValue("artistId", v)}
|
||
>
|
||
<SelectTrigger><SelectValue placeholder="Unassigned" /></SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">Unassigned</SelectItem>
|
||
{artists.map((a) => (
|
||
<SelectItem key={a.id} value={a.id}>
|
||
{a.name ?? a.email}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Thumbnail */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
|
||
<ImageIcon className="h-4 w-4 text-amber-500" />
|
||
Thumbnail
|
||
</div>
|
||
<Separator />
|
||
|
||
{thumbnailPreview && !clearThumbnail ? (
|
||
<div className="relative w-72 aspect-[2.39] rounded-lg overflow-hidden border border-border group">
|
||
<Image src={thumbnailPreview} alt={shot.shotCode} fill className="object-cover" />
|
||
<button
|
||
type="button"
|
||
onClick={() => { setClearThumbnail(true); setThumbnailPreview(null); setThumbnailFile(null); }}
|
||
className="absolute top-1.5 right-1.5 bg-black/70 hover:bg-black text-white rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
>
|
||
<X className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div
|
||
onClick={() => fileInputRef.current?.click()}
|
||
className="w-72 aspect-[2.39] rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 flex items-center justify-center gap-2 text-sm text-muted-foreground cursor-pointer transition-colors"
|
||
>
|
||
<Upload className="h-4 w-4" />
|
||
Upload thumbnail
|
||
</div>
|
||
)}
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="hidden"
|
||
onChange={handleThumbnailChange}
|
||
/>
|
||
|
||
{/* ── From renders ───────────────────────────────────────────────────
|
||
A second, separate still taken from a finished export. It never
|
||
replaces the one above — it only shows where there is none — so a
|
||
show that ships reference frames keeps them, and a show that ships
|
||
none stops rendering empty grids. */}
|
||
<div className="pt-4 mt-2 border-t border-border/60 space-y-3">
|
||
<div className="flex items-center gap-2 text-xs font-medium text-zinc-400">
|
||
<Sparkles className="h-3.5 w-3.5 text-amber-500/70" />
|
||
From renders
|
||
</div>
|
||
|
||
{generatedUrl ? (
|
||
<div className="flex items-start gap-3">
|
||
<div className="relative w-40 aspect-[2.39] rounded-md overflow-hidden border border-border shrink-0">
|
||
<Image src={generatedUrl} alt={shot.shotCode} fill className="object-cover" />
|
||
</div>
|
||
<div className="space-y-2 min-w-0">
|
||
<p className="text-xs text-muted-foreground">
|
||
{generatedFrom ? <>Taken from <span className="font-mono">{generatedFrom}</span>.</> : "Taken from a render."}{" "}
|
||
{thumbnailPreview && !clearThumbnail
|
||
? "Not shown anywhere while this shot has a thumbnail of its own."
|
||
: "Shown wherever this shot appears, until a thumbnail is uploaded above."}
|
||
</p>
|
||
<div className="flex gap-2">
|
||
<Button type="button" variant="outline" size="sm" onClick={handlePromote} disabled={isPromoting}>
|
||
{isPromoting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Refresh from latest"}
|
||
</Button>
|
||
<Button type="button" variant="ghost" size="sm" onClick={handleClearGenerated} disabled={isPromoting}>
|
||
Remove
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
<p className="text-xs text-muted-foreground">
|
||
Take a frame from this shot's most recent finished export. Never
|
||
replaces the thumbnail above — it only fills in where there
|
||
isn't one.
|
||
</p>
|
||
<Button type="button" variant="outline" size="sm" onClick={handlePromote} disabled={isPromoting}>
|
||
{isPromoting ? (
|
||
<><Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> Fetching…</>
|
||
) : (
|
||
"Use latest render frame"
|
||
)}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* References */}
|
||
<ShotReferencesSection shotId={shot.id} />
|
||
|
||
<Button type="submit" disabled={isSaving}>
|
||
{isSaving ? "Saving\u2026" : "Save Changes"}
|
||
</Button>
|
||
</form>
|
||
|
||
{/* Danger Zone */}
|
||
<div className="space-y-3 pt-2">
|
||
<div className="flex items-center gap-2 text-sm font-semibold text-red-500">
|
||
<Trash2 className="h-4 w-4" />
|
||
Danger Zone
|
||
</div>
|
||
<Separator className="bg-red-900/30" />
|
||
{confirmDelete ? (
|
||
<div className="rounded-lg border border-red-900/50 bg-red-950/20 p-4 space-y-3">
|
||
<p className="text-sm text-zinc-300">
|
||
This will permanently delete <span className="font-mono font-semibold text-white">{shot.shotCode}</span> including all tasks, versions, footage, and annotations. This cannot be undone.
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="destructive"
|
||
size="sm"
|
||
disabled={isDeleting}
|
||
onClick={handleDelete}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||
{isDeleting ? "Deleting\u2026" : "Yes, delete shot"}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
disabled={isDeleting}
|
||
onClick={() => setConfirmDelete(false)}
|
||
>
|
||
Cancel
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="border-red-900/50 text-red-400 hover:bg-red-950/30 hover:text-red-300 hover:border-red-800"
|
||
onClick={() => setConfirmDelete(true)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
||
Delete Shot
|
||
</Button>
|
||
)}
|
||
</div> </div>
|
||
);
|
||
}
|