M0: scaffold SPA + API monorepo
Backend (Django 6.1 + DRF): - Token auth with a custom User model (register/login/logout/me) - Library models (MediaItem, MediaLocation) and REST endpoints - File list/detail with search, rating filter, sorting, pagination - Multipart upload with optional rating/tags/notes - Range-aware media serving (video seeking) and ffmpeg thumbnails - scan_files management command for the watched folder Frontend (React 19 + Vite + TypeScript): - Catppuccin Mocha design tokens from the design docs - App shell, token persistence, protected routes - Library grid with filters, file detail with custom data editor - Upload page with per-file progress via XHR - Dev proxy to the Django API
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { UploadCloud } from "lucide-react";
|
||||
import { useRef, useState, type DragEvent } from "react";
|
||||
|
||||
import { Button, EmptyState } from "@/components/ui";
|
||||
import { errorMessage, uploadFile } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
|
||||
type UploadStatus = "queued" | "uploading" | "done" | "error";
|
||||
|
||||
interface UploadEntry {
|
||||
id: string;
|
||||
file: File;
|
||||
progress: number;
|
||||
status: UploadStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function UploadPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [entries, setEntries] = useState<UploadEntry[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const pendingCount = entries.filter(
|
||||
(entry) => entry.status === "queued" || entry.status === "error",
|
||||
).length;
|
||||
|
||||
function addFiles(files: FileList | null) {
|
||||
if (!files || files.length === 0) return;
|
||||
const next: UploadEntry[] = Array.from(files).map((file) => ({
|
||||
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`,
|
||||
file,
|
||||
progress: 0,
|
||||
status: "queued",
|
||||
}));
|
||||
setEntries((current) => [...current, ...next]);
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
setDragging(false);
|
||||
addFiles(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
function updateEntry(id: string, patch: Partial<UploadEntry>) {
|
||||
setEntries((current) =>
|
||||
current.map((entry) => (entry.id === id ? { ...entry, ...patch } : entry)),
|
||||
);
|
||||
}
|
||||
|
||||
async function startUploads() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
const queue = entries.filter(
|
||||
(entry) => entry.status === "queued" || entry.status === "error",
|
||||
);
|
||||
for (const entry of queue) {
|
||||
updateEntry(entry.id, { status: "uploading", progress: 0, error: undefined });
|
||||
try {
|
||||
await uploadFile(entry.file, {}, (percent) =>
|
||||
updateEntry(entry.id, { progress: percent }),
|
||||
);
|
||||
updateEntry(entry.id, { status: "done", progress: 100 });
|
||||
} catch (error) {
|
||||
updateEntry(entry.id, {
|
||||
status: "error",
|
||||
error: errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
function clearFinished() {
|
||||
setEntries((current) => current.filter((entry) => entry.status !== "done"));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="text-lg font-semibold">Upload</h1>
|
||||
<p className="mt-1 text-sm text-ctp-overlay0">
|
||||
Files are written to the watched folder and indexed into the library.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-6 py-14 text-center transition",
|
||||
dragging
|
||||
? "border-ctp-mauve bg-ctp-mauve/5"
|
||||
: "border-ctp-surface1 hover:border-ctp-surface2",
|
||||
)}
|
||||
>
|
||||
<UploadCloud className="h-8 w-8 text-ctp-overlay0" />
|
||||
<p className="text-sm font-medium text-ctp-subtext1">
|
||||
Drag & drop files here
|
||||
</p>
|
||||
<p className="text-xs text-ctp-overlay0">
|
||||
or click to select — PNG, JPG, GIF, APNG, MP4, WEBM
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
addFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{entries.length > 0 ? (
|
||||
<section className="flex flex-col gap-3">
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="rounded-lg border border-ctp-surface0 bg-ctp-base p-3"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="truncate font-mono text-xs text-ctp-subtext1">
|
||||
{entry.file.name}
|
||||
</span>
|
||||
<span className="shrink-0 font-mono text-xs text-ctp-overlay0">
|
||||
{formatBytes(entry.file.size)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-ctp-surface0">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full transition-[width] duration-150",
|
||||
entry.status === "error"
|
||||
? "bg-ctp-red"
|
||||
: entry.status === "done"
|
||||
? "bg-ctp-green"
|
||||
: "bg-ctp-mauve",
|
||||
)}
|
||||
style={{
|
||||
width: `${entry.status === "done" ? 100 : entry.progress}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-between text-xs">
|
||||
<span className="text-ctp-overlay0">
|
||||
{entry.status === "queued"
|
||||
? "Queued"
|
||||
: entry.status === "uploading"
|
||||
? `Uploading… ${entry.progress}%`
|
||||
: entry.status === "done"
|
||||
? "Done"
|
||||
: (entry.error ?? "Failed")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : (
|
||||
<EmptyState
|
||||
title="No files selected"
|
||||
description="Drop files above to queue them for upload."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={() => void startUploads()} disabled={busy || pendingCount === 0}>
|
||||
{busy ? "Uploading…" : `Upload ${pendingCount || ""} file${pendingCount === 1 ? "" : "s"}`.trim()}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={clearFinished} disabled={busy}>
|
||||
Clear finished
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user