Files
vfxreview/components/shots/ShotSettingsTab.tsx
T
twotalesanimation 9af60a632b
Deploy / deploy (push) Failing after 2m40s
fixed build issues
2026-05-20 13:51:47 +02:00

470 lines
16 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 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 } from "@/actions/shots";
import { useToast } from "@/components/ui/use-toast";
import { Upload, X, Film, ImageIcon, Video } from "lucide-react";
const settingsSchema = z.object({
description: z.string().optional(),
status: z.enum(["WAITING", "IN_PROGRESS", "IN_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;
originalFootageUrl: string | null;
originalFootageKey: string | null;
};
artists: Artist[];
onSaved?: () => void;
}
function uploadViaXhr(
file: File,
onProgress: (fraction: number) => void
): Promise<{ url: string; key: string }> {
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", file);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/upload/local");
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
});
xhr.addEventListener("load", () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const json = JSON.parse(xhr.responseText);
if (json.url) resolve({ url: json.url, key: json.key ?? "" });
else reject(new Error(json.error ?? "Upload failed"));
} catch {
reject(new Error("Invalid server response"));
}
} else {
try {
const json = JSON.parse(xhr.responseText);
reject(new Error(json.error ?? `HTTP ${xhr.status}`));
} catch {
reject(new Error(`HTTP ${xhr.status}`));
}
}
});
xhr.addEventListener("error", () => reject(new Error("Network error")));
xhr.addEventListener("abort", () => reject(new Error("Upload aborted")));
xhr.send(formData);
});
}
export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps) {
const { toast } = useToast();
const [isSaving, setIsSaving] = 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);
// Footage upload state
const [footageFile, setFootageFile] = useState<File | null>(null);
const [footageUploading, setFootageUploading] = useState(false);
const [footageProgress, setFootageProgress] = useState(0);
const [currentFootageUrl, setCurrentFootageUrl] = useState<string | null>(shot.originalFootageUrl ?? null);
const footageInputRef = 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: {
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 handleFootageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFootageFile(file);
};
const handleFootageUpload = async () => {
if (!footageFile) return;
setFootageUploading(true);
setFootageProgress(0);
try {
const { url, key } = await uploadViaXhr(footageFile, setFootageProgress);
await updateShot({
shotId: shot.id,
originalFootageUrl: url,
originalFootageKey: key || undefined,
});
setCurrentFootageUrl(url);
setFootageFile(null);
toast({ title: "Footage uploaded" });
onSaved?.();
} catch (e) {
toast({ title: "Upload failed", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
} finally {
setFootageUploading(false);
setFootageProgress(0);
}
};
const handleFootageRemove = async () => {
try {
await updateShot({ shotId: shot.id, originalFootageUrl: null, originalFootageKey: null });
setCurrentFootageUrl(null);
setFootageFile(null);
toast({ title: "Footage removed" });
onSaved?.();
} catch (e) {
toast({ title: "Failed to remove footage", description: e instanceof Error ? e.message : undefined, variant: "destructive" });
}
};
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,
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);
}
};
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>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="IN_REVIEW">In 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…" : "Save Changes"}
</Button>
</form>
{/* Original Footage — separate section with its own XHR upload */}
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-300">
<Video className="h-4 w-4 text-amber-500" />
Original Footage
</div>
<Separator />
<p className="text-xs text-zinc-500">Upload the original (pre-VFX) footage for reference. Supported formats: MP4, MOV, AVI, MKV, WebM.</p>
{currentFootageUrl ? (
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
<Video className="h-5 w-5 text-amber-400 shrink-0" />
<span className="text-sm text-zinc-300 truncate flex-1">Footage uploaded</span>
<div className="flex items-center gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => footageInputRef.current?.click()}
>
Replace
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="text-zinc-500 hover:text-red-400"
onClick={handleFootageRemove}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<div
onClick={() => !footageUploading && footageInputRef.current?.click()}
className="w-full rounded-lg border-2 border-dashed border-border hover:border-amber-500/50 flex flex-col items-center justify-center gap-2 py-8 text-sm text-muted-foreground cursor-pointer transition-colors"
>
<Video className="h-6 w-6" />
<span>Click to select original footage</span>
</div>
)}
{footageFile && !footageUploading && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-zinc-900 border border-zinc-800">
<Video className="h-5 w-5 text-zinc-400 shrink-0" />
<span className="text-sm text-zinc-300 truncate flex-1">{footageFile.name}</span>
<span className="text-xs text-zinc-500 shrink-0">{(footageFile.size / 1024 / 1024).toFixed(1)} MB</span>
<Button type="button" size="sm" onClick={handleFootageUpload}>
Upload
</Button>
<button
type="button"
onClick={() => setFootageFile(null)}
className="text-zinc-500 hover:text-zinc-300"
>
<X className="h-4 w-4" />
</button>
</div>
)}
{footageUploading && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-zinc-400">
<span>Uploadingâ¦</span>
<span>{Math.round(footageProgress * 100)}%</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-amber-500 transition-all duration-200"
style={{ width: `${footageProgress * 100}%` }}
/>
</div>
</div>
)}
<input
ref={footageInputRef}
type="file"
accept="video/mp4,video/quicktime,video/x-msvideo,video/x-matroska,video/webm,video/*"
className="hidden"
onChange={handleFootageChange}
/>
</div>
</div>
);
}