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,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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Download } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes, formatDate } from "@/lib/format";
|
||||
import type { MediaItem, Rating } from "@/lib/types";
|
||||
|
||||
const ratingChoices: { value: Rating; label: string; active: string }[] = [
|
||||
{
|
||||
value: "",
|
||||
label: "None",
|
||||
active: "border-ctp-surface2 bg-ctp-surface0 text-ctp-text",
|
||||
},
|
||||
{
|
||||
value: "s",
|
||||
label: "Safe",
|
||||
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
|
||||
},
|
||||
{
|
||||
value: "q",
|
||||
label: "Questionable",
|
||||
active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach",
|
||||
},
|
||||
{
|
||||
value: "e",
|
||||
label: "Explicit",
|
||||
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
|
||||
},
|
||||
];
|
||||
|
||||
function CustomDataCard({ item }: { item: MediaItem }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [rating, setRating] = useState<Rating>(item.rating);
|
||||
const [tags, setTags] = useState(item.tags.join(", "));
|
||||
const [notes, setNotes] = useState(item.notes);
|
||||
|
||||
useEffect(() => {
|
||||
setRating(item.rating);
|
||||
setTags(item.tags.join(", "));
|
||||
setNotes(item.notes);
|
||||
}, [item.md5, item.rating, item.tags, item.notes]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (payload: { rating: Rating; tags: string[]; notes: string }) =>
|
||||
api<MediaItem>(`/api/files/${item.md5}/`, {
|
||||
method: "PATCH",
|
||||
json: payload,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["file", item.md5] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["files"] });
|
||||
},
|
||||
});
|
||||
|
||||
function save() {
|
||||
mutation.mutate({
|
||||
rating,
|
||||
tags: tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
notes,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">Custom data</h2>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ratingChoices.map((choice) => (
|
||||
<button
|
||||
key={choice.value || "none"}
|
||||
type="button"
|
||||
onClick={() => setRating(choice.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
|
||||
rating === choice.value
|
||||
? choice.active
|
||||
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
|
||||
)}
|
||||
>
|
||||
{choice.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
className={inputClass}
|
||||
placeholder="Tags, comma separated"
|
||||
value={tags}
|
||||
onChange={(event) => setTags(event.target.value)}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
className={cn(inputClass, "min-h-20 resize-y")}
|
||||
placeholder="Notes…"
|
||||
value={notes}
|
||||
onChange={(event) => setNotes(event.target.value)}
|
||||
/>
|
||||
|
||||
{mutation.isError ? (
|
||||
<p className="text-sm text-ctp-red">{errorMessage(mutation.error)}</p>
|
||||
) : mutation.isSuccess ? (
|
||||
<p className="text-sm text-ctp-green">Saved.</p>
|
||||
) : null}
|
||||
|
||||
<Button onClick={save} disabled={mutation.isPending}>
|
||||
{mutation.isPending ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileDetailPage() {
|
||||
const { md5 = "" } = useParams();
|
||||
const query = useQuery({
|
||||
queryKey: ["file", md5],
|
||||
queryFn: () => api<MediaItem>(`/api/files/${md5}/`),
|
||||
enabled: md5.length > 0,
|
||||
});
|
||||
|
||||
if (query.isPending) {
|
||||
return (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isError || !query.data) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="File not found"
|
||||
description={
|
||||
<Link to="/" className="text-ctp-blue hover:underline">
|
||||
Back to the library
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const item = query.data;
|
||||
const rawUrl = `/api/files/${item.md5}/raw/`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-1 text-ctp-subtext0 transition hover:text-ctp-text"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" /> Library
|
||||
</Link>
|
||||
<span className="text-ctp-surface1">/</span>
|
||||
<span className="truncate font-mono text-xs text-ctp-overlay0">
|
||||
{item.filename}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="flex min-h-[320px] items-center justify-center overflow-hidden rounded-lg border border-ctp-surface0 bg-ctp-mantle">
|
||||
{item.kind === "video" ? (
|
||||
<video
|
||||
key={item.md5}
|
||||
src={rawUrl}
|
||||
controls
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={item.md5}
|
||||
src={rawUrl}
|
||||
alt={item.filename}
|
||||
className="max-h-[75vh] w-full object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h1
|
||||
className="truncate font-mono text-sm text-ctp-subtext1"
|
||||
title={item.filename}
|
||||
>
|
||||
{item.filename}
|
||||
</h1>
|
||||
<dl className="mt-3 grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs">
|
||||
<dt className="text-ctp-overlay0">MD5</dt>
|
||||
<dd className="truncate font-mono text-ctp-subtext0" title={item.md5}>
|
||||
{item.md5}
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">Size</dt>
|
||||
<dd className="font-mono text-ctp-subtext0">
|
||||
{formatBytes(item.size)}
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">Type</dt>
|
||||
<dd className="font-mono uppercase text-ctp-subtext0">
|
||||
{item.extension} · {item.kind}
|
||||
</dd>
|
||||
<dt className="text-ctp-overlay0">Added</dt>
|
||||
<dd className="text-ctp-subtext0">{formatDate(item.created_at)}</dd>
|
||||
</dl>
|
||||
<a
|
||||
href={`${rawUrl}?download=1`}
|
||||
className="mt-4 inline-flex items-center justify-center gap-2 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1"
|
||||
>
|
||||
<Download className="h-4 w-4" /> Download
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<CustomDataCard item={item} />
|
||||
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||
Locations ({item.locations.length})
|
||||
</h2>
|
||||
<ul className="mt-2 flex flex-col gap-1.5">
|
||||
{item.locations.map((location) => (
|
||||
<li
|
||||
key={location.id}
|
||||
className="truncate font-mono text-xs text-ctp-overlay1"
|
||||
title={location.path}
|
||||
>
|
||||
{location.rel_path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Search } from "lucide-react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
|
||||
import { MediaCard } from "@/components/MediaCard";
|
||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { MediaItem, Paginated } from "@/lib/types";
|
||||
|
||||
const sortOptions = [
|
||||
{ value: "-created_at", label: "Newest first" },
|
||||
{ value: "created_at", label: "Oldest first" },
|
||||
{ value: "-size", label: "Largest first" },
|
||||
{ value: "size", label: "Smallest first" },
|
||||
{ value: "name", label: "Name A–Z" },
|
||||
{ value: "-name", label: "Name Z–A" },
|
||||
];
|
||||
|
||||
const perPageOptions = [24, 48, 96];
|
||||
|
||||
const ratingFilters = [
|
||||
{
|
||||
value: "s",
|
||||
label: "Safe",
|
||||
active: "border-ctp-green/40 bg-ctp-green/15 text-ctp-green",
|
||||
},
|
||||
{
|
||||
value: "q",
|
||||
label: "Questionable",
|
||||
active: "border-ctp-peach/40 bg-ctp-peach/15 text-ctp-peach",
|
||||
},
|
||||
{
|
||||
value: "e",
|
||||
label: "Explicit",
|
||||
active: "border-ctp-red/40 bg-ctp-red/15 text-ctp-red",
|
||||
},
|
||||
];
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [ratings, setRatings] = useState<string[]>([]);
|
||||
const [ordering, setOrdering] = useState("-created_at");
|
||||
const [perPage, setPerPage] = useState(48);
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["files", { search, ratings, ordering, perPage, page }],
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams({
|
||||
page: String(page),
|
||||
per_page: String(perPage),
|
||||
ordering,
|
||||
});
|
||||
if (search) params.set("search", search);
|
||||
if (ratings.length) params.set("rating", ratings.join(","));
|
||||
return api<Paginated<MediaItem>>(`/api/files/?${params.toString()}`);
|
||||
},
|
||||
});
|
||||
|
||||
const pageCount = query.data
|
||||
? Math.max(1, Math.ceil(query.data.count / perPage))
|
||||
: 1;
|
||||
|
||||
function applySearch(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setPage(1);
|
||||
setSearch(searchInput.trim());
|
||||
}
|
||||
|
||||
function toggleRating(value: string) {
|
||||
setPage(1);
|
||||
setRatings((current) =>
|
||||
current.includes(value)
|
||||
? current.filter((rating) => rating !== value)
|
||||
: [...current, value],
|
||||
);
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setSearchInput("");
|
||||
setSearch("");
|
||||
setRatings([]);
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
||||
<aside className="flex w-full shrink-0 flex-col gap-5 rounded-lg border border-ctp-surface0 bg-ctp-base p-4 lg:sticky lg:top-20 lg:w-64">
|
||||
<form onSubmit={applySearch} className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Search
|
||||
</span>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ctp-overlay0" />
|
||||
<input
|
||||
className={cn(inputClass, "pl-8")}
|
||||
placeholder="Filename…"
|
||||
value={searchInput}
|
||||
onChange={(event) => setSearchInput(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="flex-1">
|
||||
Search
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={clearFilters}>
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Rating
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{ratingFilters.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => toggleRating(option.value)}
|
||||
className={cn(
|
||||
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
|
||||
ratings.includes(option.value)
|
||||
? option.active
|
||||
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Sort
|
||||
</span>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={ordering}
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setOrdering(event.target.value);
|
||||
}}
|
||||
>
|
||||
{sortOptions.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex flex-col gap-2">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Per page
|
||||
</span>
|
||||
<select
|
||||
className={inputClass}
|
||||
value={perPage}
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setPerPage(Number(event.target.value));
|
||||
}}
|
||||
>
|
||||
{perPageOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</aside>
|
||||
|
||||
<section className="min-w-0 flex-1">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<p className="text-sm text-ctp-overlay0">
|
||||
{query.data
|
||||
? `${query.data.count} item${query.data.count === 1 ? "" : "s"}`
|
||||
: "…"}
|
||||
</p>
|
||||
{pageCount > 1 ? (
|
||||
<p className="font-mono text-xs text-ctp-overlay0">
|
||||
Page {page} / {pageCount}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{query.isPending ? (
|
||||
<div className="flex justify-center py-24">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
) : query.isError || !query.data ? (
|
||||
<EmptyState
|
||||
title="Could not load the library"
|
||||
description="Make sure the backend is running."
|
||||
/>
|
||||
) : query.data.results.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No files found"
|
||||
description={
|
||||
search || ratings.length
|
||||
? "Try different filters."
|
||||
: "Upload files or run the scan command."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-3">
|
||||
{query.data.results.map((item) => (
|
||||
<MediaCard key={item.md5} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pageCount > 1 ? (
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page <= 1 || query.isFetching}
|
||||
onClick={() => setPage((current) => Math.max(1, current - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="font-mono text-xs text-ctp-subtext0">
|
||||
{page} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={page >= pageCount || query.isFetching}
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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