Files
vfxreview/components/shots/ShotSettingsTab.tsx
T
twotalesanimation b683976cc6
Deploy / deploy (push) Successful in 3m16s
High res uploads
2026-06-25 09:56:41 +02:00

466 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useRef } 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 } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import { Upload, X, Film, ImageIcon, Trash2, FileVideo, CheckCircle2 } from "lucide-react";
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;
highResFilename?: 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);
// High-res state
const [highResFilename, setHighResFilename] = useState<string | null>(shot.highResFilename ?? null);
const [highResUploading, setHighResUploading] = useState(false);
const [highResRemoving, setHighResRemoving] = useState(false);
const highResInputRef = useRef<HTMLInputElement>(null);
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 handleHighResChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setHighResUploading(true);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch(`/api/shots/${shot.id}/highres`, { method: "POST", body: fd });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error ?? "Upload failed");
}
setHighResFilename(file.name);
toast({ title: "High-res file uploaded" });
} catch (err) {
toast({ title: "Upload failed", description: err instanceof Error ? err.message : undefined, variant: "destructive" });
} finally {
setHighResUploading(false);
if (highResInputRef.current) highResInputRef.current.value = "";
}
};
const handleHighResRemove = async () => {
setHighResRemoving(true);
try {
const res = await fetch(`/api/shots/${shot.id}/highres`, { method: "DELETE" });
if (!res.ok) throw new Error("Remove failed");
setHighResFilename(null);
toast({ title: "High-res file removed" });
} catch (err) {
toast({ title: "Failed to remove", description: err instanceof Error ? err.message : undefined, variant: "destructive" });
} finally {
setHighResRemoving(false);
}
};
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}
/>
</div>
<Button type="submit" disabled={isSaving}>
{isSaving ? "Saving\u2026" : "Save Changes"}
</Button>
</form>
{/* High Res File */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<FileVideo className="h-4 w-4 text-amber-500" />
High Res File
</div>
<Separator />
<p className="text-xs text-zinc-500">Upload the full-resolution MOV deliverable. Stored in Hetzner Object Storage. Clients can download it from the review player.</p>
{highResFilename ? (
<div className="flex items-center gap-3 rounded-lg border border-zinc-700 bg-zinc-800/50 px-4 py-3">
<CheckCircle2 className="h-4 w-4 text-emerald-400 shrink-0" />
<span className="flex-1 truncate font-mono text-sm text-zinc-200" title={highResFilename}>
{highResFilename}
</span>
<button
type="button"
disabled={highResRemoving}
onClick={handleHighResRemove}
className="shrink-0 text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
>
{highResRemoving ? "Removing\u2026" : "Remove"}
</button>
<button
type="button"
onClick={() => highResInputRef.current?.click()}
className="shrink-0 text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
>
Replace
</button>
</div>
) : (
<div
onClick={() => !highResUploading && highResInputRef.current?.click()}
className="flex cursor-pointer items-center justify-center gap-2 rounded-lg border-2 border-dashed border-zinc-700 px-4 py-6 text-sm text-zinc-500 transition-colors hover:border-amber-500/50 hover:text-zinc-400"
>
{highResUploading ? (
<><div className="h-4 w-4 animate-spin rounded-full border-2 border-amber-500 border-t-transparent" /><span>Uploading\u2026</span></>
) : (
<><Upload className="h-4 w-4" /><span>Upload high-res MOV</span></>
)}
</div>
)}
<input
ref={highResInputRef}
type="file"
accept="video/*,.mov"
className="hidden"
onChange={handleHighResChange}
/>
</div>
{/* 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>
);
}