"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; 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(null); const [thumbnailPreview, setThumbnailPreview] = useState(shot.thumbnailUrl ?? null); const [clearThumbnail, setClearThumbnail] = useState(false); const fileInputRef = useRef(null); // Footage upload state const [footageFile, setFootageFile] = useState(null); const [footageUploading, setFootageUploading] = useState(false); const [footageProgress, setFootageProgress] = useState(0); const [currentFootageUrl, setCurrentFootageUrl] = useState(shot.originalFootageUrl ?? null); const footageInputRef = useRef(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({ 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) => { 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) => { 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 (
{/* Details */}
Details