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
243 lines
7.6 KiB
TypeScript
243 lines
7.6 KiB
TypeScript
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>
|
||
);
|
||
}
|