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" />
@@ -1,8 +1,10 @@
import { useParams } from "react-router-dom";
import { BackendNeeded } from "@/components/BackendNeeded";
import { EmptyState } from "@/components/ui";
import { LibraryDetail } from "@/features/library/LibraryDetail";
import { OnlineDetail } from "@/features/online/OnlineDetail";
import { hasBackend } from "@/lib/backend";
const J_ID_RE = /^J-\d+$/i;
@@ -10,6 +12,7 @@ export default function DetailPage() {
const { itemId = "" } = useParams();
if (J_ID_RE.test(itemId)) {
if (!hasBackend()) return <BackendNeeded />;
const normalized = `J-${itemId.slice(2)}`;
return <LibraryDetail jId={normalized} />;
}
@@ -4,6 +4,7 @@ import { useState } from "react";
import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import type { FollowedPool, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts";
@@ -49,7 +50,7 @@ export function PoolFollowButton({
},
});
if (!user) return null;
if (!user || !hasBackend()) return null;
const busy = mutation.isPending || (target !== null && followed !== target);
const label = name ? `"${name}"` : `#${poolId}`;
+12 -9
View File
@@ -14,6 +14,7 @@ import { Button, EmptyState, Spinner, linkButtonClass } from "@/components/ui";
import { TagChip } from "@/components/TagChip";
import { DetailShell } from "@/features/detail/DetailShell";
import { api, apiUrl, errorMessage } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { cn } from "@/lib/cn";
import { renderDText } from "@/lib/dtext";
import { isTypingTarget } from "@/lib/dom";
@@ -101,7 +102,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
method: "POST",
json: { md5s: [post?.file.md5] },
}),
enabled: Boolean(post?.file.md5),
enabled: hasBackend() && Boolean(post?.file.md5),
staleTime: 60_000,
});
const libraryJId = post
@@ -240,13 +241,15 @@ export function OnlineDetail({ postId }: { postId: number }) {
const isVideo = post.file.ext === "mp4" || post.file.ext === "webm";
const imageSrc =
post.sample.has && post.sample.url ? post.sample.url : post.file.url;
const canUpload = Boolean(user?.can_upload);
const canUpload = Boolean(hasBackend() && user?.can_upload);
const clientDownloadUrl = post.file.url
? apiUrl(
`/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent(
`${post.file.md5}.${post.file.ext}`,
)}`,
)
? hasBackend()
? apiUrl(
`/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent(
`${post.file.md5}.${post.file.ext}`,
)}`,
)
: post.file.url
: null;
return (
@@ -294,7 +297,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
<Link to={`/detail/${libraryJId}`} className={linkButtonClass}>
<CheckCircle2 className="h-4 w-4" /> In library ({libraryJId})
</Link>
) : (
) : hasBackend() ? (
<Button
variant="secondary"
disabled={
@@ -315,7 +318,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
? "Downloading…"
: "Download to Library"}
</Button>
)}
) : null}
{clientDownloadUrl ? (
<a
+3 -1
View File
@@ -22,6 +22,7 @@ import { useE621 } from "@/store/e621";
import { useAuth } from "@/store/auth";
import { useUi } from "@/store/ui";
import { toast } from "@/store/toasts";
import { hasBackend } from "@/lib/backend";
const PAGE_SIZE = 48;
const EMPTY_POSTS: E621Post[] = [];
@@ -67,6 +68,7 @@ export default function OnlinePage() {
onSuccess: () => {
setBlacklistDraft("");
// The Followed page's cloud counted the old list; rebuild it.
if (!hasBackend()) return;
void api("/api/follows/cloud/?refresh=1")
.catch(() => undefined)
.finally(() => {
@@ -121,7 +123,7 @@ export default function OnlinePage() {
method: "POST",
json: { md5s },
}),
enabled: md5s.length > 0,
enabled: hasBackend() && md5s.length > 0,
staleTime: 60_000,
});
const inLibrary = useMemo(
@@ -8,6 +8,7 @@ import { BottomSheet } from "@/components/BottomSheet";
import { PostCard } from "@/features/online/PostCard";
import { PoolFollowButton } from "@/features/follows/PoolFollowButton";
import { api } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { cn } from "@/lib/cn";
import { renderDText } from "@/lib/dtext";
import {
@@ -109,7 +110,7 @@ export default function PoolDetailPage() {
method: "POST",
json: { md5s },
}),
enabled: md5s.length > 0,
enabled: hasBackend() && md5s.length > 0,
staleTime: 60_000,
});
const inLibrary = useMemo(
+11 -2
View File
@@ -5,6 +5,7 @@ import { Button, Spinner, inputClass } from "@/components/ui";
import { cn } from "@/lib/cn";
import {
DEFAULT_BACKEND_URL,
NO_BACKEND,
getBackendUrl,
getStoredBackendUrl,
normalizeBackendUrl,
@@ -57,7 +58,10 @@ async function testBackend(base: string): Promise<TestResult> {
*/
export function SetupPage() {
const stored = getStoredBackendUrl();
const [value, setValue] = useState(stored ?? DEFAULT_BACKEND_URL);
const localMode = stored === NO_BACKEND;
const [value, setValue] = useState(
stored === null || stored === NO_BACKEND ? DEFAULT_BACKEND_URL : stored,
);
const [testing, setTesting] = useState(false);
const [result, setResult] = useState<TestResult>(null);
@@ -101,7 +105,9 @@ export function SetupPage() {
<p className="mt-3 text-sm leading-relaxed text-ctp-subtext0">
Enter the origin this app should call for its API. Leave it blank
when the app and the API are served from the same domain.
when the app and the API are served from the same domain. Without a
backend the app runs in local mode: e621 browsing only, with
credentials kept in this browser.
</p>
<label className="mt-5 flex flex-col gap-1.5">
@@ -154,6 +160,9 @@ export function SetupPage() {
Use this server
</Button>
) : null}
<Button variant="ghost" onClick={() => save(NO_BACKEND)}>
{localMode ? "Keep running without a backend" : "Continue without a backend"}
</Button>
</div>
<p className="mt-4 text-xs leading-relaxed text-ctp-overlay0">