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:
2026-09-17 07:56:47 -05:00
commit 555c25d77b
65 changed files with 5237 additions and 0 deletions
+195
View File
@@ -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 &amp; 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>
);
}