Run the SPA without a backend (local mode)

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/<e621 id> 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.
This commit is contained in:
2026-09-18 00:00:48 -05:00
parent 3bd5bc73dd
commit a904abdf20
19 changed files with 412 additions and 155 deletions
+6
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { api, getToken, setToken } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { applyPreferences } from "@/lib/preferences";
import { queryClient } from "@/lib/queryClient";
import type { User } from "@/lib/types";
@@ -58,6 +59,11 @@ export const useAuth = create<AuthState>((set) => ({
},
loadUser: async () => {
if (!hasBackend()) {
// Local mode: no account, and the e621 features work on their own.
set({ user: null, ready: true });
return;
}
if (!getToken()) {
set({ user: null, ready: true });
return;
+49
View File
@@ -1,6 +1,7 @@
import { create } from "zustand";
import { api } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import {
effectiveCredentials,
updateBlacklist,
@@ -9,6 +10,43 @@ import {
type E621User,
} from "@/lib/e621";
/** Standalone mode keeps credentials in this browser only. */
const LOCAL_CREDENTIALS_KEY = "j621.e621";
function readLocalCredentials(): E621Credentials | null {
try {
const raw = window.localStorage.getItem(LOCAL_CREDENTIALS_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<E621Credentials>;
const username = String(parsed.username ?? "");
const api_key = String(parsed.api_key ?? "");
return {
username,
api_key,
base_url: String(parsed.base_url ?? "https://e621.net"),
configured: Boolean(username && api_key),
};
} catch {
return null;
}
}
function writeLocalCredentials(values: {
username: string;
api_key: string;
base_url: string;
}): E621Credentials {
const credentials: E621Credentials = {
...values,
configured: Boolean(values.username && values.api_key),
};
window.localStorage.setItem(
LOCAL_CREDENTIALS_KEY,
JSON.stringify(credentials),
);
return credentials;
}
interface E621State {
credentials: E621Credentials | null;
profile: E621User | null;
@@ -35,6 +73,11 @@ export const useE621 = create<E621State>((set, get) => ({
load: async () => {
set({ loading: true });
if (!hasBackend()) {
set({ credentials: readLocalCredentials(), ready: true, loading: false });
void get().loadBlacklist();
return;
}
try {
const credentials = await api<E621Credentials>("/api/auth/e621/");
set({ credentials, ready: true, loading: false });
@@ -71,6 +114,12 @@ export const useE621 = create<E621State>((set, get) => ({
},
save: async (values) => {
if (!hasBackend()) {
const credentials = writeLocalCredentials(values);
set({ credentials });
void get().loadBlacklist();
return credentials;
}
const credentials = await api<E621Credentials>("/api/auth/e621/", {
method: "PUT",
json: values,