Shell polish: storage line, status pill, footer strip, sidebar
Backend: - apps.core with GET /api/status/ (env, git hash, OS, watched folder, worker counts) and a TimingMiddleware adding X-Server-Time-Ms - status endpoint reports its own server-side assembly time Frontend: - top bar shows the storage line (watched folder) and a status pill with ENV, git hash, host OS, server time (e621 time once it exists) - fixed 32px footer strip: storage path, active workers, build version - collapsible filter sidebar: closed by default, Ctrl/Cmd+B toggle, overlay drawer with backdrop blur under 1024px - rating filters, per-page and sidebar state persist in localStorage
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
name = "apps.core"
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class TimingMiddleware:
|
||||||
|
"""Record server-side request time, mirroring the original app.
|
||||||
|
|
||||||
|
Sets ``request.start_time`` before the view runs so views can report
|
||||||
|
elapsed time, and exposes the total duration as a response header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response):
|
||||||
|
self.get_response = get_response
|
||||||
|
|
||||||
|
def __call__(self, request):
|
||||||
|
started = time.perf_counter()
|
||||||
|
request.start_time = started
|
||||||
|
response = self.get_response(request)
|
||||||
|
response["X-Server-Time-Ms"] = f"{(time.perf_counter() - started) * 1000:.1f}"
|
||||||
|
return response
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from .views import StatusView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("status/", StatusView.as_view(), name="status"),
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import platform
|
||||||
|
import time
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
|
|
||||||
|
def _os_info():
|
||||||
|
system = platform.system()
|
||||||
|
icon = {"Linux": "linux", "Darwin": "apple", "Windows": "windows"}.get(
|
||||||
|
system, "unknown"
|
||||||
|
)
|
||||||
|
return {"name": system, "release": platform.release(), "icon": icon}
|
||||||
|
|
||||||
|
|
||||||
|
def _worker_counts():
|
||||||
|
"""Active/queued background jobs.
|
||||||
|
|
||||||
|
No task pipeline exists yet, so this reports honest zeros for now.
|
||||||
|
"""
|
||||||
|
return {"active": 0, "queued": 0}
|
||||||
|
|
||||||
|
|
||||||
|
class StatusView(APIView):
|
||||||
|
"""Runtime status used by the SPA shell (storage line, status pill, footer)."""
|
||||||
|
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
started = getattr(request, "start_time", time.perf_counter())
|
||||||
|
payload = {
|
||||||
|
"app": "J621",
|
||||||
|
"env": settings.APP_ENV,
|
||||||
|
"git_hash": settings.GIT_COMMIT_HASH,
|
||||||
|
"version": settings.APP_VERSION,
|
||||||
|
"os": _os_info(),
|
||||||
|
"watched_folder": settings.WATCHED_FOLDER,
|
||||||
|
"e621_time_ms": None, # no e621 calls in the backend yet
|
||||||
|
"workers": _worker_counts(),
|
||||||
|
}
|
||||||
|
payload["server_time_ms"] = round((time.perf_counter() - started) * 1000, 1)
|
||||||
|
return Response(payload)
|
||||||
@@ -52,6 +52,7 @@ INSTALLED_APPS = [
|
|||||||
"django_filters",
|
"django_filters",
|
||||||
"apps.accounts",
|
"apps.accounts",
|
||||||
"apps.library",
|
"apps.library",
|
||||||
|
"apps.core",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
@@ -62,6 +63,7 @@ MIDDLEWARE = [
|
|||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
|
"apps.core.middleware.TimingMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = "config.urls"
|
ROOT_URLCONF = "config.urls"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.urls import include, path
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("api/auth/", include("apps.accounts.urls")),
|
path("api/auth/", include("apps.accounts.urls")),
|
||||||
|
path("api/", include("apps.core.urls")),
|
||||||
path("api/", include("apps.library.urls")),
|
path("api/", include("apps.library.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { LogOut } from "lucide-react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
import { LogOut, Menu } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { Link, NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
import { StatusFooter } from "@/components/StatusFooter";
|
||||||
|
import { StatusPill } from "@/components/StatusPill";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { APP_VERSION } from "@/lib/version";
|
import type { SystemStatus } from "@/lib/types";
|
||||||
import { useAuth } from "@/store/auth";
|
import { useAuth } from "@/store/auth";
|
||||||
|
import { useUi } from "@/store/ui";
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/", label: "Library" },
|
{ to: "/", label: "Library" },
|
||||||
@@ -13,17 +19,60 @@ const navItems = [
|
|||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const user = useAuth((state) => state.user);
|
const user = useAuth((state) => state.user);
|
||||||
const logout = useAuth((state) => state.logout);
|
const logout = useAuth((state) => state.logout);
|
||||||
|
const toggleSidebar = useUi((state) => state.toggleSidebar);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const statusQuery = useQuery({
|
||||||
|
queryKey: ["status"],
|
||||||
|
queryFn: () => api<SystemStatus>("/api/status/"),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
const status = statusQuery.data;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "b") {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleSidebar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [toggleSidebar]);
|
||||||
|
|
||||||
|
const showSidebarToggle = location.pathname === "/";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col">
|
||||||
<header className="sticky top-0 z-40 border-b border-ctp-surface0 bg-ctp-crust/95 backdrop-blur">
|
<header className="sticky top-0 z-40 border-b border-ctp-surface0 bg-ctp-crust/95 backdrop-blur">
|
||||||
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-6 px-4">
|
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-4 px-4">
|
||||||
<Link to="/" className="flex items-center gap-2.5">
|
<Link to="/" className="flex shrink-0 items-center">
|
||||||
<span className="rounded bg-ctp-mauve px-2 py-1 font-mono text-xs font-bold tracking-wide text-ctp-crust">
|
<span className="rounded bg-ctp-mauve px-2 py-1 font-mono text-xs font-bold tracking-wide text-ctp-crust">
|
||||||
J621
|
J621
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{status ? (
|
||||||
|
<span
|
||||||
|
className="hidden max-w-[240px] truncate font-mono text-[11px] text-ctp-overlay0 md:block"
|
||||||
|
title={status.watched_folder}
|
||||||
|
>
|
||||||
|
{status.watched_folder}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showSidebarToggle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
title="Toggle filters (Ctrl+B)"
|
||||||
|
className="rounded-md p-1.5 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
|
||||||
|
>
|
||||||
|
<Menu className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<nav className="flex items-center gap-1">
|
<nav className="flex items-center gap-1">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -45,12 +94,7 @@ export function AppShell() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<span
|
<StatusPill status={status} />
|
||||||
className="hidden font-mono text-[11px] text-ctp-overlay0 lg:block"
|
|
||||||
title="Running build"
|
|
||||||
>
|
|
||||||
{APP_VERSION}
|
|
||||||
</span>
|
|
||||||
{user ? (
|
{user ? (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -82,9 +126,11 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-6">
|
<main className="mx-auto w-full max-w-[1600px] flex-1 px-4 pb-14 pt-6">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<StatusFooter status={status} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Folder, Zap } from "lucide-react";
|
||||||
|
|
||||||
|
import type { SystemStatus } from "@/lib/types";
|
||||||
|
import { APP_VERSION } from "@/lib/version";
|
||||||
|
|
||||||
|
export function StatusFooter({ status }: { status?: SystemStatus }) {
|
||||||
|
return (
|
||||||
|
<footer className="fixed inset-x-0 bottom-0 z-30 border-t border-ctp-surface0 bg-ctp-crust">
|
||||||
|
<div className="mx-auto flex h-8 w-full max-w-[1600px] items-center gap-4 px-4 font-mono text-[11px] text-ctp-overlay0">
|
||||||
|
<span
|
||||||
|
className="flex min-w-0 items-center gap-1.5"
|
||||||
|
title={status?.watched_folder ?? ""}
|
||||||
|
>
|
||||||
|
<Folder className="h-3 w-3 shrink-0" />
|
||||||
|
<span className="hidden shrink-0 sm:inline">Local Storage:</span>
|
||||||
|
<span className="truncate">{status?.watched_folder ?? "…"}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
className="hidden shrink-0 items-center gap-1.5 md:flex"
|
||||||
|
title="Active background jobs"
|
||||||
|
>
|
||||||
|
<Zap className="h-3 w-3" />
|
||||||
|
<span>
|
||||||
|
Active Workers:{" "}
|
||||||
|
{status
|
||||||
|
? `${status.workers.active} running | ${status.workers.queued} pending`
|
||||||
|
: "…"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="ml-auto shrink-0" title="Running build">
|
||||||
|
{status?.version ?? APP_VERSION}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { Laptop, Monitor, Terminal, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
import type { SystemStatus } from "@/lib/types";
|
||||||
|
|
||||||
|
const osIcons: Record<string, LucideIcon> = {
|
||||||
|
linux: Terminal,
|
||||||
|
apple: Laptop,
|
||||||
|
windows: Monitor,
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatMs(value: number): string {
|
||||||
|
return value >= 100 ? `${Math.round(value)}ms` : `${value.toFixed(1)}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusPill({ status }: { status?: SystemStatus }) {
|
||||||
|
if (!status) return null;
|
||||||
|
const OsIcon = osIcons[status.os.icon] ?? Monitor;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="hidden select-none items-center gap-1.5 rounded-full border border-ctp-surface0 bg-ctp-mantle px-2.5 py-1 font-mono text-[11px] text-ctp-overlay1 lg:flex"
|
||||||
|
title={`${status.os.name} ${status.os.release} · built from ${status.git_hash}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-medium",
|
||||||
|
status.env === "prod" ? "text-ctp-teal" : "text-ctp-yellow",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
ENV: {status.env.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-ctp-surface2">·</span>
|
||||||
|
<span>{status.git_hash}</span>
|
||||||
|
<span className="text-ctp-surface2">·</span>
|
||||||
|
<OsIcon className="h-3 w-3" />
|
||||||
|
<span>{status.os.name.toUpperCase()}</span>
|
||||||
|
<span className="text-ctp-surface2">·</span>
|
||||||
|
<span>{formatMs(status.server_time_ms)}</span>
|
||||||
|
{status.e621_time_ms !== null ? (
|
||||||
|
<>
|
||||||
|
<span className="text-ctp-surface2">·</span>
|
||||||
|
<span>e621: {formatMs(status.e621_time_ms)}</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ArrowLeft, Download } from "lucide-react";
|
import { ArrowLeft, Download } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||||
@@ -38,12 +38,6 @@ function CustomDataCard({ item }: { item: MediaItem }) {
|
|||||||
const [tags, setTags] = useState(item.tags.join(", "));
|
const [tags, setTags] = useState(item.tags.join(", "));
|
||||||
const [notes, setNotes] = useState(item.notes);
|
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({
|
const mutation = useMutation({
|
||||||
mutationFn: (payload: { rating: Rating; tags: string[]; notes: string }) =>
|
mutationFn: (payload: { rating: Rating; tags: string[]; notes: string }) =>
|
||||||
api<MediaItem>(`/api/files/${item.md5}/`, {
|
api<MediaItem>(`/api/files/${item.md5}/`, {
|
||||||
@@ -219,7 +213,7 @@ export default function FileDetailPage() {
|
|||||||
</a>
|
</a>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<CustomDataCard item={item} />
|
<CustomDataCard key={item.updated_at} item={item} />
|
||||||
|
|
||||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Search } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
|
|
||||||
import { MediaCard } from "@/components/MediaCard";
|
import { MediaCard } from "@/components/MediaCard";
|
||||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
import { useUi } from "@/store/ui";
|
||||||
import type { MediaItem, Paginated } from "@/lib/types";
|
import type { MediaItem, Paginated } from "@/lib/types";
|
||||||
|
|
||||||
const sortOptions = [
|
const sortOptions = [
|
||||||
@@ -40,11 +41,16 @@ const ratingFilters = [
|
|||||||
export default function LibraryPage() {
|
export default function LibraryPage() {
|
||||||
const [searchInput, setSearchInput] = useState("");
|
const [searchInput, setSearchInput] = useState("");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [ratings, setRatings] = useState<string[]>([]);
|
|
||||||
const [ordering, setOrdering] = useState("-created_at");
|
const [ordering, setOrdering] = useState("-created_at");
|
||||||
const [perPage, setPerPage] = useState(48);
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const sidebarOpen = useUi((state) => state.sidebarOpen);
|
||||||
|
const setSidebarOpen = useUi((state) => state.setSidebarOpen);
|
||||||
|
const ratings = useUi((state) => state.ratings);
|
||||||
|
const setRatings = useUi((state) => state.setRatings);
|
||||||
|
const perPage = useUi((state) => state.perPage);
|
||||||
|
const setPerPage = useUi((state) => state.setPerPage);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["files", { search, ratings, ordering, perPage, page }],
|
queryKey: ["files", { search, ratings, ordering, perPage, page }],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
@@ -71,10 +77,10 @@ export default function LibraryPage() {
|
|||||||
|
|
||||||
function toggleRating(value: string) {
|
function toggleRating(value: string) {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
setRatings((current) =>
|
setRatings(
|
||||||
current.includes(value)
|
ratings.includes(value)
|
||||||
? current.filter((rating) => rating !== value)
|
? ratings.filter((rating) => rating !== value)
|
||||||
: [...current, value],
|
: [...ratings, value],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,95 +91,128 @@ export default function LibraryPage() {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filterPanel = (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start">
|
<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">
|
{sidebarOpen ? (
|
||||||
<form onSubmit={applySearch} className="flex flex-col gap-2">
|
<>
|
||||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
<div
|
||||||
Search
|
className="fixed inset-0 z-40 bg-ctp-crust/60 backdrop-blur-sm lg:hidden"
|
||||||
</span>
|
onClick={() => setSidebarOpen(false)}
|
||||||
<div className="relative">
|
aria-hidden="true"
|
||||||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-ctp-overlay0" />
|
/>
|
||||||
<input
|
<aside
|
||||||
className={cn(inputClass, "pl-8")}
|
className={cn(
|
||||||
placeholder="Filename…"
|
"fixed inset-y-0 left-0 z-50 flex w-72 flex-col gap-5 overflow-y-auto border-r border-ctp-surface0 bg-ctp-base p-4",
|
||||||
value={searchInput}
|
"lg:sticky lg:inset-y-auto lg:top-20 lg:z-auto lg:w-64 lg:shrink-0 lg:overflow-visible lg:rounded-lg lg:border",
|
||||||
onChange={(event) => setSearchInput(event.target.value)}
|
)}
|
||||||
/>
|
>
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2">
|
<span className="text-sm font-semibold text-ctp-subtext1">
|
||||||
<Button type="submit" className="flex-1">
|
Filters
|
||||||
Search
|
</span>
|
||||||
</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
|
<button
|
||||||
key={option.value}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleRating(option.value)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
className={cn(
|
title="Close filters"
|
||||||
"rounded-full border px-2.5 py-1 text-xs font-medium transition",
|
className="rounded-md p-1 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text lg:hidden"
|
||||||
ratings.includes(option.value)
|
|
||||||
? option.active
|
|
||||||
: "border-ctp-surface1 text-ctp-subtext0 hover:border-ctp-surface2 hover:text-ctp-text",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{option.label}
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
</div>
|
||||||
</div>
|
{filterPanel}
|
||||||
</div>
|
</aside>
|
||||||
|
</>
|
||||||
<label className="flex flex-col gap-2">
|
) : null}
|
||||||
<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">
|
<section className="min-w-0 flex-1">
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
<div className="mb-4 flex items-center justify-between gap-3">
|
||||||
|
|||||||
@@ -35,3 +35,22 @@ export interface Paginated<T> {
|
|||||||
previous: string | null;
|
previous: string | null;
|
||||||
results: T[];
|
results: T[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SystemStatus {
|
||||||
|
app: string;
|
||||||
|
env: "dev" | "prod";
|
||||||
|
git_hash: string;
|
||||||
|
version: string;
|
||||||
|
os: {
|
||||||
|
name: string;
|
||||||
|
release: string;
|
||||||
|
icon: "linux" | "apple" | "windows" | "unknown";
|
||||||
|
};
|
||||||
|
watched_folder: string;
|
||||||
|
e621_time_ms: number | null;
|
||||||
|
workers: {
|
||||||
|
active: number;
|
||||||
|
queued: number;
|
||||||
|
};
|
||||||
|
server_time_ms: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
|
||||||
|
interface UiState {
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
setSidebarOpen: (open: boolean) => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
ratings: string[];
|
||||||
|
setRatings: (ratings: string[]) => void;
|
||||||
|
perPage: number;
|
||||||
|
setPerPage: (perPage: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useUi = create<UiState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
sidebarOpen: false,
|
||||||
|
setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
|
||||||
|
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
|
|
||||||
|
ratings: [],
|
||||||
|
setRatings: (ratings) => set({ ratings }),
|
||||||
|
|
||||||
|
perPage: 48,
|
||||||
|
setPerPage: (perPage) => set({ perPage }),
|
||||||
|
}),
|
||||||
|
{ name: "j621.ui" },
|
||||||
|
),
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user