From a904abdf20920853ed371bc480113460a3a933aa Mon Sep 17 00:00:00 2001 From: JakeBreath Date: Fri, 18 Sep 2026 00:00:48 -0500 Subject: [PATCH] Run the SPA without a backend (local mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app can now operate backend-agnostically: a production build still asks on first start, but /setup also offers 'Continue without a backend' (stored as the sentinel 'none'), and the shell adapts: - Local mode shows only the e621-facing pages: Online (search, post view, favorites, blacklist editor, direct downloads) and Pools. Library, uploads, duplicates, stats, users, follows and similarity are hidden from the nav and palette and render a 'backend needed' state when reached directly; /detail/ still works while /detail/J-x asks for a backend. - e621 credentials are stored in this browser (j621.e621) and the Account page becomes a credentials-only screen; the store reads/writes locally instead of /api/auth/e621/. - The header replaces the status pill and login/user area with an e621 credentials button and a 'Setup Backend' button; the footer shows 'Local mode — e621 features only' with the same entry point. - In-library lookups (badges/browse markers) are skipped without a backend; 'Download to client' links straight to the e621 file instead of the backend proxy; follow buttons and palette follow toggles are hidden; api() fails fast with a clear message if something slips through. Mode logic lives in lib/backend.ts (URL / '' same-origin / 'none') with its matrix verified in Node; tsc, oxlint and the build are clean. --- AGENTS.md | 18 +-- ROADMAP.md | 3 + frontend/src/App.tsx | 100 ++++++++++------ frontend/src/components/AppShell.tsx | 107 +++++++++++------- frontend/src/components/BackendNeeded.tsx | 21 ++++ frontend/src/components/CommandPalette.tsx | 66 +++++++---- frontend/src/components/StatusFooter.tsx | 32 +++++- frontend/src/components/TagChip.tsx | 3 +- frontend/src/features/account/AccountPage.tsx | 70 ++++++++---- frontend/src/features/detail/DetailPage.tsx | 3 + .../src/features/follows/PoolFollowButton.tsx | 3 +- frontend/src/features/online/OnlineDetail.tsx | 21 ++-- frontend/src/features/online/OnlinePage.tsx | 4 +- .../src/features/pools/PoolDetailPage.tsx | 3 +- frontend/src/features/setup/SetupPage.tsx | 13 ++- frontend/src/lib/api.ts | 7 +- frontend/src/lib/backend.ts | 38 +++++-- frontend/src/store/auth.ts | 6 + frontend/src/store/e621.ts | 49 ++++++++ 19 files changed, 412 insertions(+), 155 deletions(-) create mode 100644 frontend/src/components/BackendNeeded.tsx diff --git a/AGENTS.md b/AGENTS.md index 083247a..dbb603e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,13 +31,17 @@ Project constraints (do not regress): MariaDB/Redis come from docker-compose.yml. - Deployment will be Docker-based (compose); do not add systemd/cron unit files for scheduling — use the container setup for timers/workers. -- Same-origin and cross-origin frontends both work: a production build asks - for the backend origin on first start (/setup, stored in localStorage; - DEFAULT_BACKEND_URL in frontend/src/lib/backend.ts is the prefilled - default), the backend allows extra origins via CORS_ALLOWED_ORIGINS (plus - CSRF_TRUSTED_ORIGINS for the admin), and API media URLs are absolute - (built from the request host), so signed files load cross-origin too. - TRUST_PROXY_HEADERS=true is required behind a TLS-terminating proxy. +- Same-origin and cross-origin frontends both work, and the SPA can also run + with no backend at all: a production build asks on first start (/setup) and + stores the answer in localStorage — a URL, "" for same origin, or "none" + for local mode. Local mode shows only the e621-facing pages (Online, Pools) + with credentials kept in this browser (j621.e621) and a "Setup Backend" + button in the shell. DEFAULT_BACKEND_URL in frontend/src/lib/backend.ts is + the prefilled default; the backend allows extra origins via + CORS_ALLOWED_ORIGINS (plus CSRF_TRUSTED_ORIGINS for the admin), and API + media URLs are absolute (built from the request host), so signed files load + cross-origin too. TRUST_PROXY_HEADERS=true is required behind a + TLS-terminating proxy. - No server-side media processing: the home server cannot handle it. Compression/optimization runs client-side (WebCodecs + WASM in a worker) and the server only applies the result via POST /api/files/J-x/optimize/. diff --git a/ROADMAP.md b/ROADMAP.md index 984a610..83b721b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -137,6 +137,9 @@ Files now stage first and are resolved before entering the library. (searchable library grid, remove supported) set avatars from library J-IDs - [x] Profile extras: per-user landing page, default rating filter/sort, items per page and thumbnail size (synced to the account, applied on load) +- [x] Backend-less local mode: with no backend connected the SPA runs on the + e621-facing pages only (Online, Pools) using credentials stored in the + browser, and the shell offers a "Setup Backend" button instead - [x] Command palette: tag finder, recent searches, navigation commands (Ctrl+K / ⌘K, reachable from anywhere in the shell) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d14869e..31d40fd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { } from "react-router-dom"; import { AppShell } from "@/components/AppShell"; +import { BackendNeeded } from "@/components/BackendNeeded"; import { FullPageSpinner } from "@/components/ui"; import AccountPage from "@/features/account/AccountPage"; import { AgeGate } from "@/features/age/AgeGate"; @@ -28,7 +29,7 @@ import StatsPage from "@/features/stats/StatsPage"; import UploadPage from "@/features/upload/UploadPage"; import UsersPage from "@/features/users/UsersPage"; import { isAgeVerified, markAgeVerified } from "@/lib/age"; -import { needsBackendSetup } from "@/lib/backend"; +import { hasBackend, needsBackendSetup } from "@/lib/backend"; import { useAuth } from "@/store/auth"; function RequireAuth({ children }: { children: ReactNode }) { @@ -40,6 +41,12 @@ function RequireAuth({ children }: { children: ReactNode }) { return <>{children}; } +/** Libraries, follows, uploads and staff pages need an API behind them. */ +function RequireBackend({ children }: { children: ReactNode }) { + if (!hasBackend()) return ; + return <>{children}; +} + function PostRedirect() { const { postId = "" } = useParams(); return ; @@ -77,9 +84,21 @@ export default function App() { return ( }> - } /> + : + } + /> } /> - } /> + + + + } + /> } /> } /> } /> @@ -87,71 +106,84 @@ export default function App() { - - + + + + + } /> - - + + + + + } /> - - + + + + + } /> - - + + + + + } /> - - + + + + + } /> - - + + + + + } /> - - - } - /> - - - + + + + + } /> + } /> } /> - } /> - } /> + : } + /> + : } + /> } /> ); diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index a7b863e..b46a3be 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { LogOut, Menu } from "lucide-react"; +import { Cable, KeyRound, LogOut, Menu } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Link, @@ -16,6 +16,7 @@ import { StatusFooter } from "@/components/StatusFooter"; import { StatusPill } from "@/components/StatusPill"; import { Toasts } from "@/components/Toasts"; import { api } from "@/lib/api"; +import { hasBackend } from "@/lib/backend"; import { cn } from "@/lib/cn"; import type { SystemStatus } from "@/lib/types"; import { useAuth } from "@/store/auth"; @@ -31,6 +32,7 @@ export function AppShell() { const navigate = useNavigate(); const [paletteOpen, setPaletteOpen] = useState(false); const landingApplied = useRef(false); + const backend = hasBackend(); useEffect(() => { void loadE621(); @@ -50,6 +52,7 @@ export function AppShell() { queryFn: () => api("/api/status/"), refetchInterval: 5_000, staleTime: 4_000, + enabled: backend, }); const status = statusQuery.data; @@ -78,19 +81,19 @@ export function AppShell() { location.pathname === "/" || location.pathname === "/online"; const navItems = [ - { to: "/", label: "Library" }, + ...(backend ? [{ to: "/", label: "Library" }] : []), { to: "/online", label: "Online" }, { to: "/pools", label: "Pools" }, - ...(user ? [{ to: "/followed", label: "Followed" }] : []), - ...(user ? [{ to: "/similar", label: "Similar" }] : []), - ...(user?.can_upload + ...(backend && user ? [{ to: "/followed", label: "Followed" }] : []), + ...(backend && user ? [{ to: "/similar", label: "Similar" }] : []), + ...(backend && user?.can_upload ? [ { to: "/upload", label: "Upload" }, { to: "/duplicates", label: "Duplicates" }, { to: "/delete", label: "Delete" }, ] : []), - ...(user && (user.is_staff || user.is_superuser || user.role === "staff") + ...(backend && user && (user.is_staff || user.is_superuser || user.role === "staff") ? [ { to: "/stats", label: "Stats" }, { to: "/users", label: "Users" }, @@ -147,45 +150,66 @@ export function AppShell() {
- - {user ? ( + {backend ? ( + <> + + {user ? ( + <> + + {user.avatar_url ? ( + {user.username} + ) : ( + + {user.username.slice(0, 1).toUpperCase()} + + )} + + {user.username} + + + + + ) : ( + + Log in + + )} + + ) : ( <> - {user.avatar_url ? ( - {user.username} - ) : ( - - {user.username.slice(0, 1).toUpperCase()} - - )} - - {user.username} - - - + + + + + Setup Backend + - ) : ( - - Log in - )}
@@ -196,9 +220,12 @@ export function AppShell() { setPaletteOpen(false)} /> diff --git a/frontend/src/components/BackendNeeded.tsx b/frontend/src/components/BackendNeeded.tsx new file mode 100644 index 0000000..2b7fc8d --- /dev/null +++ b/frontend/src/components/BackendNeeded.tsx @@ -0,0 +1,21 @@ +import { Link } from "react-router-dom"; + +import { EmptyState } from "@/components/ui"; + +/** Shown where a page needs the J621 API but the app runs without one. */ +export function BackendNeeded() { + return ( + + This part of the app stores its data on a J621 backend.{" "} + + Connect one + {" "} + or keep browsing e621 in local mode. + + } + /> + ); +} diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 4c38a26..e1c4c51 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { + Cable, Copy, ExternalLink, FolderOpen, @@ -25,6 +26,7 @@ import { useNavigate } from "react-router-dom"; import { TagChip } from "@/components/TagChip"; import { Spinner } from "@/components/ui"; import { TagFollowToggle } from "@/features/follows/TagFollowToggle"; +import { hasBackend } from "@/lib/backend"; import { cn } from "@/lib/cn"; import { effectiveCredentials, @@ -112,13 +114,18 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) { }); const commands = useMemo(() => { + const backend = hasBackend(); const list: Command[] = [ - { - id: "library", - label: "Go to Library", - icon: FolderOpen, - run: () => navigate("/"), - }, + ...(backend + ? [ + { + id: "library", + label: "Go to Library", + icon: FolderOpen, + run: () => navigate("/"), + }, + ] + : []), { id: "online", label: "Go to Online browser", @@ -131,7 +138,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) { icon: Layers, run: () => navigate("/pools"), }, - ...(user + ...(backend && user ? [ { id: "followed", @@ -147,7 +154,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) { }, ] : []), - ...(user?.can_upload + ...(backend && user?.can_upload ? [ { id: "upload", @@ -176,20 +183,30 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) { icon: Menu, run: toggleSidebar, }, + ...(backend + ? [ + { + id: "search", + label: "Focus library search", + hint: "/", + icon: Search, + run: () => { + navigate("/"); + window.requestAnimationFrame(() => { + document.getElementById("library-search")?.focus(); + }); + }, + }, + ] + : []), { - id: "search", - label: "Focus library search", - hint: "/", - icon: Search, - run: () => { - navigate("/"); - window.requestAnimationFrame(() => { - document.getElementById("library-search")?.focus(); - }); - }, + id: "setup", + label: backend ? "Change backend" : "Set up a backend", + icon: Cable, + run: () => navigate("/setup"), }, ]; - if (user) { + if (backend && user) { list.push({ id: "account", label: "Account settings", @@ -210,13 +227,20 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) { icon: LogOut, run: () => void logout(), }); - } else { + } else if (backend) { list.push({ id: "login", label: "Log in", icon: LogIn, run: () => navigate("/login"), }); + } else { + list.push({ + id: "credentials", + label: "e621 credentials", + icon: Settings, + run: () => navigate("/account"), + }); } return list; }, [navigate, toggleSidebar, user, logout]); @@ -326,7 +350,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) { {row.tag.post_count.toLocaleString()} posts - + {hasBackend() ? : null} ); + if (!backend) { + return ( +
+
+ + + Local mode — e621 features only + + + Setup Backend + +
+
+ ); + } + return (