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 (
{label} {required && * }
{
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 && (
{
e.stopPropagation();
onFileChange(null);
}}
className="mt-1.5 text-[10px] text-white/20 hover:text-red-400/60 transition-colors"
>
Remove
)}
);
}
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}
)}
);
}
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.
Logout
{error && (
)}
{success && (
Upload successful. .mtv metadata parsed automatically.
)}
);
}