diff --git a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx index 77c2738..bb5966c 100644 --- a/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx +++ b/app/(dashboard)/projects/[id]/shots/[shotId]/page.tsx @@ -27,10 +27,12 @@ import { MessageSquare, ExternalLink, XCircle, + FileVideo, } from "lucide-react"; import type { ShotWithDetails } from "@/types"; import { ShotSettingsTab } from "@/components/shots/ShotSettingsTab"; import { FootageViewer } from "@/components/shots/FootageViewer"; +import { HighResUploadDialog } from "@/components/shots/HighResUploadDialog"; import { duplicateShot, internallyApproveShot, shareWithClient, unshareFromClient, unapproveShot } from "@/actions/shots"; const STATUS_CONFIG: Record< @@ -83,6 +85,7 @@ export default function ShotDetailPage() { const [canManage, setCanManage] = useState(false); const [isDuplicating, setIsDuplicating] = useState(false); const [isActioning, setIsActioning] = useState(false); + const [highResDialogOpen, setHighResDialogOpen] = useState(false); const [activeTab, setActiveTab] = useState<"tasks" | "reviews" | "footage" | "settings">("tasks"); const fetchShot = async () => { @@ -407,8 +410,20 @@ export default function ShotDetailPage() { > {isDuplicating ? "Duplicating…" : "Duplicate Shot"} - - + )} @@ -572,6 +587,15 @@ export default function ShotDetailPage() { )} + + setHighResDialogOpen(false)} + onSuccess={fetchShot} + /> ); } diff --git a/components/shots/HighResUploadDialog.tsx b/components/shots/HighResUploadDialog.tsx new file mode 100644 index 0000000..c950cb3 --- /dev/null +++ b/components/shots/HighResUploadDialog.tsx @@ -0,0 +1,265 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { useToast } from "@/components/ui/use-toast"; +import { Upload, FileVideo, X, CheckCircle2, Trash2 } from "lucide-react"; +import { formatFileSize } from "@/lib/utils"; +import { cn } from "@/lib/utils"; + +interface HighResUploadDialogProps { + shotId: string; + shotCode: string; + currentFilename: string | null; + open: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +export function HighResUploadDialog({ + shotId, + shotCode, + currentFilename, + open, + onClose, + onSuccess, +}: HighResUploadDialogProps) { + const [file, setFile] = useState(null); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadState, setUploadState] = useState<"idle" | "uploading" | "done" | "error">("idle"); + const [isDragOver, setIsDragOver] = useState(false); + const [isRemoving, setIsRemoving] = useState(false); + const { toast } = useToast(); + + const handleFileSelect = (selected: File) => { + if (!selected.type.startsWith("video/")) { + toast({ title: "Please select a video file (.mov, .mp4, etc.)", variant: "destructive" }); + return; + } + if (selected.size > 50 * 1024 * 1024 * 1024) { + // 50 GB sanity cap + toast({ title: "File too large (max 50 GB)", variant: "destructive" }); + return; + } + setFile(selected); + }; + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); + const dropped = e.dataTransfer.files[0]; + if (dropped) handleFileSelect(dropped); + }, []); + + const handleUpload = async () => { + if (!file) return; + setUploadState("uploading"); + setUploadProgress(0); + + try { + await uploadViaXhr( + `/api/shots/${shotId}/highres`, + file, + (p) => setUploadProgress(Math.round(p * 100)) + ); + + setUploadProgress(100); + setUploadState("done"); + toast({ title: `High-res uploaded: ${file.name}` }); + setTimeout(() => { + onSuccess?.(); + handleClose(); + }, 800); + } catch (err) { + setUploadState("error"); + toast({ + title: "Upload failed", + description: (err as Error).message, + variant: "destructive", + }); + } + }; + + const handleRemove = async () => { + setIsRemoving(true); + try { + const res = await fetch(`/api/shots/${shotId}/highres`, { method: "DELETE" }); + if (!res.ok) throw new Error("Remove failed"); + toast({ title: "High-res file removed" }); + onSuccess?.(); + handleClose(); + } catch (err) { + toast({ + title: "Failed to remove", + description: (err as Error).message, + variant: "destructive", + }); + } finally { + setIsRemoving(false); + } + }; + + const resetState = () => { + setFile(null); + setUploadProgress(0); + setUploadState("idle"); + }; + + const handleClose = () => { + if (uploadState === "uploading") return; + resetState(); + onClose(); + }; + + return ( + !o && handleClose()}> + + + + + High Res File — {shotCode} + + + +
+ {/* Current file info */} + {currentFilename && !file && ( +
+ + + {currentFilename} + + +
+ )} + + {/* Drop zone */} + {!file ? ( +
{ e.preventDefault(); setIsDragOver(true); }} + onDragLeave={() => setIsDragOver(false)} + onDrop={handleDrop} + className={cn( + "relative flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-10 transition-colors cursor-pointer", + isDragOver + ? "border-amber-500 bg-amber-500/10" + : "border-zinc-700 hover:border-amber-500/50 hover:bg-zinc-800/50" + )} + onClick={() => document.getElementById("highres-file-input")?.click()} + > + e.target.files?.[0] && handleFileSelect(e.target.files[0])} + /> + +

+ {currentFilename ? "Drop replacement file here or click to browse" : "Drop high-res file here or click to browse"} +

+

MOV, MP4 or any video format

+
+ ) : ( +
+ +
+

{file.name}

+

{formatFileSize(file.size)}

+
+ {uploadState === "idle" && ( + + )} + {uploadState === "done" && ( + + )} +
+ )} + + {/* Upload progress */} + {(uploadState === "uploading" || uploadState === "done") && ( +
+
+ + {uploadState === "uploading" ? "Uploading…" : "Complete!"} + + {uploadProgress}% +
+ +
+ )} +
+ + + + + +
+
+ ); +} + +/** Upload a file via XHR so we get progress events. */ +function uploadViaXhr( + url: string, + file: File, + onProgress: (fraction: number) => void +): Promise { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append("file", file); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", url); + + xhr.upload.addEventListener("progress", (e) => { + if (e.lengthComputable) onProgress(e.loaded / e.total); + }); + + xhr.addEventListener("load", () => { + if (xhr.status >= 200 && xhr.status < 300) { + resolve(); + } 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 cancelled"))); + + xhr.send(formData); + }); +} diff --git a/components/shots/ShotSettingsTab.tsx b/components/shots/ShotSettingsTab.tsx index 26e1f40..fa1082b 100644 --- a/components/shots/ShotSettingsTab.tsx +++ b/components/shots/ShotSettingsTab.tsx @@ -20,7 +20,7 @@ import { } 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"; +import { Upload, X, Film, ImageIcon, Trash2 } from "lucide-react"; const settingsSchema = z.object({ shotCode: z.string().min(1, "Required").max(120).regex(/^[A-Z0-9_]+$/i, "Alphanumeric and underscores only"), @@ -55,7 +55,6 @@ interface ShotSettingsTabProps { dueDate: Date | string | null; artistId: string | null; thumbnailUrl: string | null; - highResFilename?: string | null; projectId: string; }; artists: Artist[]; @@ -73,12 +72,6 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps const [clearThumbnail, setClearThumbnail] = useState(false); const fileInputRef = useRef(null); - // High-res state - const [highResFilename, setHighResFilename] = useState(shot.highResFilename ?? null); - const [highResUploading, setHighResUploading] = useState(false); - const [highResRemoving, setHighResRemoving] = useState(false); - const highResInputRef = useRef(null); - const formatDate = (d: Date | string | null) => { if (!d) return ""; return new Date(d).toISOString().split("T")[0]; @@ -104,42 +97,6 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps }, }); - const handleHighResChange = async (e: React.ChangeEvent) => { - 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) => { const file = e.target.files?.[0]; if (!file) return; @@ -366,57 +323,6 @@ export function ShotSettingsTab({ shot, artists, onSaved }: ShotSettingsTabProps - {/* High Res File */} -
-
- - High Res File -
- -

Upload the full-resolution MOV deliverable. Stored in Hetzner Object Storage. Clients can download it from the review player.

- - {highResFilename ? ( -
- - - {highResFilename} - - - -
- ) : ( -
!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 ? ( - <>
Uploading\u2026 - ) : ( - <>Upload high-res MOV - )} -
- )} - -
{/* Danger Zone */}
diff --git a/types/index.ts b/types/index.ts index c905c88..88994b6 100644 --- a/types/index.ts +++ b/types/index.ts @@ -177,6 +177,8 @@ export interface ShotWithDetails { // Sequence / picture lock timecodes seqTimecodeStart: string | null; seqTimecodeEnd: string | null; + // High-res deliverable + highResFilename: string | null; shotGroup: { id: string; name: string } | null; footagePlates: { id: string;