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
+81
View File
@@ -0,0 +1,81 @@
import { useState, type FormEvent } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Button, Field, inputClass } from "@/components/ui";
import { errorMessage } from "@/lib/api";
import { useAuth } from "@/store/auth";
export default function LoginPage() {
const login = useAuth((state) => state.login);
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const from = (location.state as { from?: string } | null)?.from ?? "/";
async function handleSubmit(event: FormEvent) {
event.preventDefault();
setBusy(true);
setError(null);
try {
await login(username, password);
navigate(from, { replace: true });
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm rounded-xl border border-ctp-surface0 bg-ctp-mantle p-6">
<div className="mb-6 flex flex-col items-center gap-3">
<span className="rounded bg-ctp-mauve px-3 py-1 font-mono text-sm font-bold text-ctp-crust">
J621
</span>
<h1 className="text-lg font-semibold">Log in</h1>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Field label="Username">
<input
className={inputClass}
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoFocus
required
/>
</Field>
<Field label="Password">
<input
className={inputClass}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="current-password"
required
/>
</Field>
{error ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{error}
</p>
) : null}
<Button type="submit" disabled={busy}>
{busy ? "Signing in…" : "Sign in"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-ctp-overlay0">
No account?{" "}
<Link to="/register" className="text-ctp-blue hover:underline">
Register
</Link>
</p>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Button, Field, inputClass } from "@/components/ui";
import { errorMessage } from "@/lib/api";
import { useAuth } from "@/store/auth";
export default function RegisterPage() {
const register = useAuth((state) => state.register);
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (password !== confirm) {
setError("Passwords do not match.");
return;
}
setBusy(true);
setError(null);
try {
await register(username, password);
navigate("/", { replace: true });
} catch (err) {
setError(errorMessage(err));
} finally {
setBusy(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<div className="w-full max-w-sm rounded-xl border border-ctp-surface0 bg-ctp-mantle p-6">
<div className="mb-6 flex flex-col items-center gap-3">
<span className="rounded bg-ctp-mauve px-3 py-1 font-mono text-sm font-bold text-ctp-crust">
J621
</span>
<h1 className="text-lg font-semibold">Create account</h1>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Field label="Username">
<input
className={inputClass}
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
autoFocus
required
/>
</Field>
<Field label="Password">
<input
className={inputClass}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="new-password"
required
/>
</Field>
<Field label="Confirm password">
<input
className={inputClass}
type="password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
autoComplete="new-password"
required
/>
</Field>
{error ? (
<p className="rounded-md border border-ctp-red/30 bg-ctp-red/10 px-3 py-2 text-sm text-ctp-red">
{error}
</p>
) : null}
<Button type="submit" disabled={busy}>
{busy ? "Creating account…" : "Register"}
</Button>
</form>
<p className="mt-4 text-center text-sm text-ctp-overlay0">
Already have an account?{" "}
<Link to="/login" className="text-ctp-blue hover:underline">
Log in
</Link>
</p>
</div>
</div>
);
}