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
+50 -20
View File
@@ -1,8 +1,10 @@
import { Eye, EyeOff } from "lucide-react";
import { useEffect, useState } from "react";
import { Link, Navigate } from "react-router-dom";
import { Button, Field, Spinner, inputClass } from "@/components/ui";
import { errorMessage } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { cn } from "@/lib/cn";
import {
e621ErrorMessage,
@@ -175,8 +177,9 @@ function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
</div>
<p className="text-xs text-ctp-overlay0">
Stored on your server and fetched by this app when needed. e621 requests
are made directly from your browser, throttled to one per second.
{hasBackend()
? "Stored on your server and fetched by this app when needed. e621 requests are made directly from your browser, throttled to one per second."
: "Stored in this browser only (local mode). e621 requests are made directly from here, throttled to one per second."}
</p>
</div>
</section>
@@ -188,35 +191,62 @@ export default function AccountPage() {
const credentials = useE621((state) => state.credentials);
const loading = useE621((state) => state.loading);
const load = useE621((state) => state.load);
const backend = hasBackend();
useEffect(() => {
void load();
}, [load]);
// Local mode: the e621 credentials live in this browser.
if (!backend) {
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">e621 account</h1>
<p className="mt-1 text-sm text-ctp-overlay0">
No backend is connected, so these credentials are stored in this
browser only and sent straight to e621 from here.
</p>
</header>
{loading && !credentials ? (
<div className="flex justify-center py-12">
<Spinner className="h-6 w-6" />
</div>
) : (
<AccountForm credentials={credentials} />
)}
<p className="text-xs text-ctp-overlay0">
<Link to="/setup" className="text-ctp-blue hover:underline">
Connect a backend
</Link>{" "}
to unlock the library, follows and account settings.
</p>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
<header>
<h1 className="text-lg font-semibold">Account</h1>
{user ? (
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-ctp-overlay0">
<span>Signed in as {user.username}.</span>
<span className="rounded-full border border-ctp-surface1 px-2 py-0.5 font-mono text-[11px] capitalize text-ctp-subtext0">
{user.role}
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-ctp-overlay0">
<span>Signed in as {user.username}.</span>
<span className="rounded-full border border-ctp-surface1 px-2 py-0.5 font-mono text-[11px] capitalize text-ctp-subtext0">
{user.role}
</span>
{user.is_superuser ? (
<span className="rounded-full border border-ctp-red/40 bg-ctp-red/10 px-2 py-0.5 font-mono text-[11px] text-ctp-red">
admin
</span>
{user.is_superuser ? (
<span className="rounded-full border border-ctp-red/40 bg-ctp-red/10 px-2 py-0.5 font-mono text-[11px] text-ctp-red">
admin
</span>
) : null}
</p>
) : null}
) : null}
</p>
</header>
{user ? (
<>
<AvatarCard />
<PreferencesCard />
</>
) : null}
<AvatarCard />
<PreferencesCard />
{loading && !credentials ? (
<div className="flex justify-center py-12">
<Spinner className="h-6 w-6" />