Files
surfnathanrip/frontend/src/components/UploadForm.tsx
T
2026-06-03 03:30:54 +01:00

440 lines
17 KiB
TypeScript

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 (
<div>
<label className="block text-[10px] font-semibold uppercase tracking-[0.15em] text-white/35 mb-2">
{label} {required && <span className="text-red-400/50">*</span>}
</label>
<div
onDragOver={(e) => {
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"
}
`}
>
<div className="px-5 py-6 text-center">
{file ? (
<div className="flex flex-col items-center gap-1.5">
<svg
className="w-5 h-5 text-mom-accent"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="text-sm text-white/70 font-medium truncate max-w-full">
{file.name}
</span>
<span className="text-[10px] text-white/25">
{(file.size / 1024 / 1024).toFixed(1)} MB -
click or drop to replace
</span>
</div>
) : (
<div className="flex flex-col items-center gap-1.5">
<svg
className="w-6 h-6 text-white/15"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
<span className="text-sm text-white/30">
Drop file here or click to browse
</span>
{sublabel && (
<span className="text-[10px] text-white/15">
{sublabel}
</span>
)}
</div>
)}
</div>
</div>
{file && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onFileChange(null);
}}
className="mt-1.5 text-[10px] text-white/20 hover:text-red-400/60 transition-colors"
>
Remove
</button>
)}
</div>
);
}
function ProgressBar({
progress,
uploading,
}: {
progress: number;
uploading: boolean;
}) {
if (!uploading) return null;
return (
<div className="w-full rounded-full bg-white/5 h-2 overflow-hidden">
<div
className="h-full bg-mom-accent rounded-full transition-all duration-300 ease-out"
style={{ width: `${Math.min(progress, 100)}%` }}
/>
</div>
);
}
export default function UploadForm() {
const [authed, setAuthed] = useState(
() => !!localStorage.getItem("admin_token"),
);
const [tokenInput, setTokenInput] = useState("");
const [authError, setAuthError] = useState<string | null>(null);
const [videoFile, setVideoFile] = useState<File | null>(null);
const [mtvFile, setMtvFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(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 (
<div className="max-w-2xl mx-auto">
<div className="mb-10">
<h1 className="text-3xl font-bold tracking-tight text-white/90 mb-2">
Upload Surf Run
</h1>
<p className="text-sm text-white/30 leading-relaxed">
Authenticate to access the upload page.
</p>
</div>
{authError && (
<div className="mb-5 p-4 rounded-lg border border-red-500/20 bg-red-500/5 text-red-400/80 text-sm">
{authError}
</div>
)}
<form onSubmit={handleAuth} className="space-y-4">
<div>
<label className="block text-[10px] font-semibold uppercase tracking-[0.15em] text-white/35 mb-2">
Admin Token
</label>
<input
type="password"
value={tokenInput}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
className="w-full rounded-lg bg-mom-accent/15 border border-mom-accent/25 px-4 py-3 text-sm font-semibold text-mom-accent hover:bg-mom-accent/25 hover:border-mom-accent/35 transition-all duration-200"
>
Authenticate
</button>
</form>
</div>
);
}
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<string>((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 (
<div className="max-w-2xl mx-auto">
<div className="mb-10 flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-white/90 mb-2">
Upload Surf Run
</h1>
<p className="text-sm text-white/30 leading-relaxed">
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.
</p>
</div>
<button
onClick={handleLogout}
className="text-[10px] uppercase tracking-[0.15em] text-white/20 hover:text-white/50 transition-colors shrink-0 mt-2"
>
Logout
</button>
</div>
{error && (
<div className="mb-5 p-4 rounded-lg border border-red-500/20 bg-red-500/5 text-red-400/80 text-sm flex items-start gap-3">
<svg
className="w-4 h-4 mt-0.5 shrink-0 text-red-400/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
)}
{success && (
<div className="mb-5 p-4 rounded-lg border border-green-500/20 bg-green-500/5 text-green-400/80 text-sm flex items-start gap-3">
<svg
className="w-4 h-4 mt-0.5 shrink-0 text-green-400/60"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span>
Upload successful. .mtv metadata parsed automatically.
</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-8">
<div className="rounded-xl border border-white/7 bg-white/2 p-6 space-y-5">
<h2 className="text-xs font-semibold uppercase tracking-[0.15em] text-white/35 mb-1">
Files
</h2>
<UploadDropzone
accept="video/*"
label="Video"
sublabel="MP4, WebM, etc."
required
file={videoFile}
onFileChange={setVideoFile}
/>
<UploadDropzone
accept=".mtv"
label=".mtv Replay"
sublabel="Momentum Mod replay file"
required
file={mtvFile}
onFileChange={setMtvFile}
/>
</div>
{uploading && (
<div className="space-y-2">
<div className="flex justify-between text-xs text-white/40">
<span>
{success ? "Complete!" : "Uploading..."}
</span>
<span>{progress}%</span>
</div>
<ProgressBar
progress={progress}
uploading={uploading}
/>
</div>
)}
<button
type="submit"
disabled={uploading || !videoFile || !mtvFile}
className="w-full rounded-lg bg-mom-accent/15 border border-mom-accent/25 px-4 py-3 text-sm font-semibold text-mom-accent hover:bg-mom-accent/25 hover:border-mom-accent/35 disabled:opacity-30 disabled:cursor-not-allowed transition-all duration-200"
>
{uploading ? "Uploading..." : "Upload"}
</button>
</form>
</div>
);
}