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() {