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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user