Initial commit
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
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",
|
||||
runDate,
|
||||
);
|
||||
|
||||
const totalSize = videoFile.size;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.upload.addEventListener("progress", (e) => {
|
||||
if (e.lengthComputable) {
|
||||
setProgress(
|
||||
5 + Math.round((e.loaded / totalSize) * 90),
|
||||
);
|
||||
}
|
||||
});
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else
|
||||
reject(
|
||||
new Error(
|
||||
`Upload failed with status ${xhr.status}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
xhr.addEventListener("error", () =>
|
||||
reject(new Error("Upload failed")),
|
||||
);
|
||||
xhr.open("PUT", initRes.presignedUrls.video);
|
||||
xhr.setRequestHeader(
|
||||
"Content-Type",
|
||||
videoFile.type || "video/webm",
|
||||
);
|
||||
xhr.send(videoFile);
|
||||
});
|
||||
|
||||
setProgress(98);
|
||||
await uploadVideoComplete(initRes.id);
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user