diff --git a/backend/apps/core/__init__.py b/backend/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/core/apps.py b/backend/apps/core/apps.py new file mode 100644 index 0000000..73f042c --- /dev/null +++ b/backend/apps/core/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + name = "apps.core" diff --git a/backend/apps/core/middleware.py b/backend/apps/core/middleware.py new file mode 100644 index 0000000..c76cb33 --- /dev/null +++ b/backend/apps/core/middleware.py @@ -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 diff --git a/backend/apps/core/urls.py b/backend/apps/core/urls.py new file mode 100644 index 0000000..7035eb1 --- /dev/null +++ b/backend/apps/core/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from .views import StatusView + +urlpatterns = [ + path("status/", StatusView.as_view(), name="status"), +] diff --git a/backend/apps/core/views.py b/backend/apps/core/views.py new file mode 100644 index 0000000..26aa806 --- /dev/null +++ b/backend/apps/core/views.py @@ -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) diff --git a/backend/config/settings.py b/backend/config/settings.py index 1efe311..2a4cf2b 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -52,6 +52,7 @@ INSTALLED_APPS = [ "django_filters", "apps.accounts", "apps.library", + "apps.core", ] MIDDLEWARE = [ @@ -62,6 +63,7 @@ MIDDLEWARE = [ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", + "apps.core.middleware.TimingMiddleware", ] ROOT_URLCONF = "config.urls" diff --git a/backend/config/urls.py b/backend/config/urls.py index 93e7b34..b4a0b8a 100644 --- a/backend/config/urls.py +++ b/backend/config/urls.py @@ -6,6 +6,7 @@ from django.urls import include, path urlpatterns = [ path("admin/", admin.site.urls), path("api/auth/", include("apps.accounts.urls")), + path("api/", include("apps.core.urls")), path("api/", include("apps.library.urls")), ] diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 1594a0d..0536121 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,9 +1,15 @@ -import { LogOut } from "lucide-react"; -import { Link, NavLink, Outlet } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +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 { APP_VERSION } from "@/lib/version"; +import type { SystemStatus } from "@/lib/types"; import { useAuth } from "@/store/auth"; +import { useUi } from "@/store/ui"; const navItems = [ { to: "/", label: "Library" }, @@ -13,17 +19,60 @@ const navItems = [ export function AppShell() { const user = useAuth((state) => state.user); const logout = useAuth((state) => state.logout); + const toggleSidebar = useUi((state) => state.toggleSidebar); + const location = useLocation(); + + const statusQuery = useQuery({ + queryKey: ["status"], + queryFn: () => api("/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 (
-
- +
+ J621 + {status ? ( + + {status.watched_folder} + + ) : null} + + {showSidebarToggle ? ( + + ) : null} +
-
+
+ +
); } diff --git a/frontend/src/components/StatusFooter.tsx b/frontend/src/components/StatusFooter.tsx new file mode 100644 index 0000000..ac6450a --- /dev/null +++ b/frontend/src/components/StatusFooter.tsx @@ -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 ( + + ); +} diff --git a/frontend/src/components/StatusPill.tsx b/frontend/src/components/StatusPill.tsx new file mode 100644 index 0000000..3fff423 --- /dev/null +++ b/frontend/src/components/StatusPill.tsx @@ -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 = { + 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 ( + + + ENV: {status.env.toUpperCase()} + + · + {status.git_hash} + · + + {status.os.name.toUpperCase()} + · + {formatMs(status.server_time_ms)} + {status.e621_time_ms !== null ? ( + <> + · + e621: {formatMs(status.e621_time_ms)} + + ) : null} + + ); +} diff --git a/frontend/src/features/library/FileDetailPage.tsx b/frontend/src/features/library/FileDetailPage.tsx index 47c2e5b..e4c0d87 100644 --- a/frontend/src/features/library/FileDetailPage.tsx +++ b/frontend/src/features/library/FileDetailPage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft, Download } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Link, useParams } from "react-router-dom"; 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 [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(`/api/files/${item.md5}/`, { @@ -219,7 +213,7 @@ export default function FileDetailPage() { - +

diff --git a/frontend/src/features/library/LibraryPage.tsx b/frontend/src/features/library/LibraryPage.tsx index 69b1190..c99c60a 100644 --- a/frontend/src/features/library/LibraryPage.tsx +++ b/frontend/src/features/library/LibraryPage.tsx @@ -1,11 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import { Search } from "lucide-react"; +import { Search, X } 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 { useUi } from "@/store/ui"; import type { MediaItem, Paginated } from "@/lib/types"; const sortOptions = [ @@ -40,11 +41,16 @@ const ratingFilters = [ export default function LibraryPage() { const [searchInput, setSearchInput] = useState(""); const [search, setSearch] = useState(""); - const [ratings, setRatings] = useState([]); const [ordering, setOrdering] = useState("-created_at"); - const [perPage, setPerPage] = useState(48); 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({ queryKey: ["files", { search, ratings, ordering, perPage, page }], queryFn: () => { @@ -71,10 +77,10 @@ export default function LibraryPage() { function toggleRating(value: string) { setPage(1); - setRatings((current) => - current.includes(value) - ? current.filter((rating) => rating !== value) - : [...current, value], + setRatings( + ratings.includes(value) + ? ratings.filter((rating) => rating !== value) + : [...ratings, value], ); } @@ -85,95 +91,128 @@ export default function LibraryPage() { setPage(1); } + const filterPanel = ( + <> +
+ + Search + +
+ + setSearchInput(event.target.value)} + /> +
+
+ + +
+
+ +
+ + Rating + +
+ {ratingFilters.map((option) => ( + + ))} +
+
+ + + + + + ); + return (
- +
+ {filterPanel} + + + ) : null}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e325d57..897b469 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -35,3 +35,22 @@ export interface Paginated { previous: string | null; 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; +} diff --git a/frontend/src/store/ui.ts b/frontend/src/store/ui.ts new file mode 100644 index 0000000..1b4a801 --- /dev/null +++ b/frontend/src/store/ui.ts @@ -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()( + 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" }, + ), +);