import { useState, useCallback, type FormEvent } from "react"; import { uploadVideoInit, uploadVideoComplete } from "../api/client"; function UploadDropzone({ accept, label, sublabel, required, file, onFileChange, }: { accept: string; label: string; sublabel?: string; required?: boolean; file: File | null; onFileChange: (file: File | null) => void; }) { const [isDragOver, setIsDragOver] = useState(false); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); const dropped = e.dataTransfer.files[0]; if (dropped) onFileChange(dropped); }, [onFileChange], ); return (
{ e.preventDefault(); setIsDragOver(true); }} onDragLeave={() => setIsDragOver(false)} onDrop={handleDrop} onClick={() => { const input = document.createElement("input"); input.type = "file"; input.accept = accept; input.onchange = () => onFileChange(input.files?.[0] ?? null); input.click(); }} className={`relative cursor-pointer rounded-lg border-2 border-dashed transition-all duration-200 ${ file ? "border-mom-accent/40 bg-mom-accent/5" : isDragOver ? "border-mom-accent/40 bg-mom-accent/5" : "border-white/7 bg-white/2 hover:border-white/15 hover:bg-white/3" } `} >
{file ? (
{file.name} {(file.size / 1024 / 1024).toFixed(1)} MB - click or drop to replace
) : (
Drop file here or click to browse {sublabel && ( {sublabel} )}
)}
{file && ( )}
); } function ProgressBar({ progress, uploading, }: { progress: number; uploading: boolean; }) { if (!uploading) return null; return (
); } export default function UploadForm() { const [authed, setAuthed] = useState( () => !!localStorage.getItem("admin_token"), ); const [tokenInput, setTokenInput] = useState(""); const [authError, setAuthError] = useState(null); const [videoFile, setVideoFile] = useState(null); const [mtvFile, setMtvFile] = useState(null); const [uploading, setUploading] = useState(false); const [progress, setProgress] = useState(0); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); const handleAuth = (e: FormEvent) => { e.preventDefault(); if (!tokenInput.trim()) return; localStorage.setItem("admin_token", tokenInput.trim()); setAuthError(null); fetch("/api/videos/upload-url", { method: "POST", headers: { Authorization: `Bearer ${tokenInput.trim()}` }, }) .then((res) => { if (res.status === 401) { localStorage.removeItem("admin_token"); setAuthError("Invalid token"); setAuthed(false); } else { setAuthed(true); } }) .catch(() => { setAuthed(true); }); }; const handleLogout = () => { localStorage.removeItem("admin_token"); setAuthed(false); setTokenInput(""); }; if (!authed) { return (

Upload Surf Run

Authenticate to access the upload page.

{authError && (
{authError}
)}
setTokenInput(e.target.value)} placeholder="Enter admin token" className="w-full rounded-lg border border-white/7 bg-white/3 px-4 py-2.5 text-sm text-white/80 placeholder:text-white/15 focus:border-mom-accent/40 focus:outline-none transition-colors" />
); } const handleSubmit = async (e: FormEvent) => { e.preventDefault(); if (!videoFile || !mtvFile) { setError("Video and .mtv files are required"); return; } setError(null); setUploading(true); setSuccess(false); setProgress(0); try { setProgress(5); const runDate = mtvFile.lastModified ? new Date(mtvFile.lastModified).toISOString() : undefined; const initRes = await uploadVideoInit( mtvFile, videoFile.name, videoFile.type || "video/webm", videoFile.size.toString(), runDate, ); const { parts, uploadId } = initRes.presignedUrls; const totalSize = videoFile.size; const CHUNK_SIZE = 80 * 1024 * 1024; const uploadedParts: { partNumber: number; eTag: string }[] = []; for (let i = 0; i < parts.length; i++) { const part = parts[i]!; const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, totalSize); const chunk = videoFile.slice(start, end); const eTag = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.upload.addEventListener("progress", (e) => { if (e.lengthComputable) { const chunkProgress = e.loaded / e.total; const overallUploaded = start + e.loaded; setProgress( 5 + Math.round( (overallUploaded / totalSize) * 90, ), ); } }); xhr.addEventListener("load", () => { if (xhr.status >= 200 && xhr.status < 300) { const etag = xhr.getResponseHeader("ETag"); if (etag) resolve(etag); else reject( new Error("No ETag returned from upload"), ); } else { reject( new Error( `Upload failed with status ${xhr.status}`, ), ); } }); xhr.addEventListener("error", () => reject(new Error("Upload failed")), ); xhr.open("PUT", part.url); xhr.setRequestHeader( "Content-Type", videoFile.type || "video/webm", ); xhr.send(chunk); }); uploadedParts.push({ partNumber: part.partNumber, eTag }); } setProgress(98); await uploadVideoComplete(initRes.id, uploadedParts, uploadId); setProgress(100); setSuccess(true); setVideoFile(null); setMtvFile(null); } catch (err: any) { setError(err.message || "Upload failed"); } finally { setUploading(false); } }; return (

Upload Surf Run

Drop your replay video and .mtv file. Map name, player, run time, and stage splits are parsed from the .mtv automatically. The run date is taken from the .mtv file.

{error && (
{error}
)} {success && (
Upload successful. .mtv metadata parsed automatically.
)}

Files

{uploading && (
{success ? "Complete!" : "Uploading..."} {progress}%
)}
); }