440 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|