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
+11 -7
View File
@@ -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/.
+3
View File
@@ -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)
+45 -13
View File
@@ -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 <BackendNeeded />;
return <>{children}</>;
}
function PostRedirect() {
const { postId = "" } = useParams();
return <Navigate to={`/detail/${postId}`} replace />;
@@ -77,9 +84,21 @@ export default function App() {
return (
<Routes>
<Route element={<AppShell />}>
<Route path="/" element={<LibraryPage />} />
<Route
path="/"
element={
hasBackend() ? <LibraryPage /> : <Navigate to="/online" replace />
}
/>
<Route path="/detail/:itemId" element={<DetailPage />} />
<Route path="/view/:md5" element={<Md5Redirect />} />
<Route
path="/view/:md5"
element={
<RequireBackend>
<Md5Redirect />
</RequireBackend>
}
/>
<Route path="/online" element={<OnlinePage />} />
<Route path="/online/view/:postId" element={<PostRedirect />} />
<Route path="/pools" element={<PoolsPage />} />
@@ -87,71 +106,84 @@ export default function App() {
<Route
path="/followed"
element={
<RequireBackend>
<RequireAuth>
<FollowedPage />
</RequireAuth>
</RequireBackend>
}
/>
<Route
path="/duplicates"
element={
<RequireBackend>
<RequireAuth>
<DuplicatesPage />
</RequireAuth>
</RequireBackend>
}
/>
<Route
path="/delete"
element={
<RequireBackend>
<RequireAuth>
<DeletePage />
</RequireAuth>
</RequireBackend>
}
/>
<Route
path="/stats"
element={
<RequireBackend>
<RequireAuth>
<StatsPage />
</RequireAuth>
</RequireBackend>
}
/>
<Route
path="/users"
element={
<RequireBackend>
<RequireAuth>
<UsersPage />
</RequireAuth>
</RequireBackend>
}
/>
<Route
path="/similar"
element={
<RequireBackend>
<RequireAuth>
<SimilarPage />
</RequireAuth>
</RequireBackend>
}
/>
<Route
path="/upload"
element={
<RequireBackend>
<RequireAuth>
<UploadPage />
</RequireAuth>
</RequireBackend>
}
/>
<Route
path="/account"
element={
<RequireAuth>
<AccountPage />
</RequireAuth>
}
/>
<Route path="/account" element={<AccountPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/login"
element={hasBackend() ? <LoginPage /> : <BackendNeeded />}
/>
<Route
path="/register"
element={hasBackend() ? <RegisterPage /> : <BackendNeeded />}
/>
<Route path="/setup" element={<SetupPage />} />
</Routes>
);
+34 -7
View File
@@ -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<SystemStatus>("/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,6 +150,8 @@ export function AppShell() {
</nav>
<div className="ml-auto flex items-center gap-3">
{backend ? (
<>
<StatusPill status={status} />
{user ? (
<>
@@ -187,6 +192,25 @@ export function AppShell() {
Log in
</Link>
)}
</>
) : (
<>
<Link
to="/account"
title="e621 credentials (stored in this browser)"
className="rounded-md p-1.5 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
<KeyRound className="h-4 w-4" />
</Link>
<Link
to="/setup"
className="inline-flex items-center gap-1.5 rounded-md border border-ctp-surface1 bg-ctp-surface0 px-3 py-1.5 text-sm font-medium text-ctp-text transition hover:bg-ctp-surface1"
>
<Cable className="h-3.5 w-3.5" />
Setup Backend
</Link>
</>
)}
</div>
</div>
</header>
@@ -196,9 +220,12 @@ export function AppShell() {
</main>
<StatusFooter
backend={backend}
status={status}
staff={Boolean(
user && (user.is_staff || user.is_superuser || user.role === "staff"),
backend &&
user &&
(user.is_staff || user.is_superuser || user.role === "staff"),
)}
/>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
+21
View File
@@ -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 (
<EmptyState
title="A backend is needed here"
description={
<>
This part of the app stores its data on a J621 backend.{" "}
<Link to="/setup" className="text-ctp-blue hover:underline">
Connect one
</Link>{" "}
or keep browsing e621 in local mode.
</>
}
/>
);
}
+29 -5
View File
@@ -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<Command[]>(() => {
const backend = hasBackend();
const list: Command[] = [
...(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,6 +183,8 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: Menu,
run: toggleSidebar,
},
...(backend
? [
{
id: "search",
label: "Focus library search",
@@ -188,8 +197,16 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
});
},
},
]
: []),
{
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 }) {
<span className="ml-auto font-mono text-[11px] text-ctp-overlay0">
{row.tag.post_count.toLocaleString()} posts
</span>
<TagFollowToggle tag={row.tag.name} />
{hasBackend() ? <TagFollowToggle tag={row.tag.name} /> : null}
<a
href={`https://e621.net/posts?tags=${encodeURIComponent(row.tag.name)}`}
target="_blank"
+27 -5
View File
@@ -1,4 +1,4 @@
import { HardDrive, Zap } from "lucide-react";
import { HardDrive, Unplug, Zap } from "lucide-react";
import { Link } from "react-router-dom";
import { cn } from "@/lib/cn";
@@ -14,13 +14,15 @@ function storageColor(percent: number): string {
}
export function StatusFooter({
backend,
status,
staff,
}: {
backend: boolean;
status?: SystemStatus;
staff?: boolean;
}) {
const backend = getBackendUrl() || window.location.origin;
const backendOrigin = getBackendUrl() || window.location.origin;
const storage = status?.storage;
const workers = (
@@ -35,6 +37,26 @@ export function StatusFooter({
</>
);
if (!backend) {
return (
<footer className="fixed inset-x-0 bottom-0 z-30 border-t border-ctp-surface0 bg-ctp-crust">
<div className="mx-auto flex h-8 w-full max-w-[1600px] items-center gap-4 px-4 font-mono text-[11px] text-ctp-overlay0">
<span className="flex min-w-0 shrink-0 items-center gap-1.5">
<Unplug className="h-3 w-3 shrink-0" />
Local mode — e621 features only
</span>
<Link
to="/setup"
className="mx-auto transition hover:text-ctp-text"
title="Connect a backend to unlock the library, uploads and account features"
>
Setup Backend
</Link>
</div>
</footer>
);
}
return (
<footer className="fixed inset-x-0 bottom-0 z-30 border-t border-ctp-surface0 bg-ctp-crust">
<div className="mx-auto flex h-8 w-full max-w-[1600px] items-center gap-4 px-4 font-mono text-[11px] text-ctp-overlay0">
@@ -66,7 +88,7 @@ export function StatusFooter({
<span
className="mx-auto hidden min-w-0 items-center gap-1.5 md:flex"
title={`Backend API: ${backend}`}
title={`Backend API: ${backendOrigin}`}
>
{staff ? (
<Link
@@ -74,10 +96,10 @@ export function StatusFooter({
className="truncate transition hover:text-ctp-text"
title="Change backend"
>
{backend}
{backendOrigin}
</Link>
) : (
<span className="truncate">{backend}</span>
<span className="truncate">{backendOrigin}</span>
)}
</span>
+2 -1
View File
@@ -1,4 +1,5 @@
import { cn } from "@/lib/cn";
import { hasBackend } from "@/lib/backend";
import { TagFollowToggle } from "@/features/follows/TagFollowToggle";
const TAG_STYLES: Record<string, string> = {
@@ -52,7 +53,7 @@ export function TagChip({
) : (
<span>{label}</span>
)}
<TagFollowToggle tag={tag} />
{hasBackend() ? <TagFollowToggle tag={tag} /> : null}
</span>
);
}
+38 -8
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,16 +191,48 @@ 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">
@@ -209,14 +244,9 @@ export default function AccountPage() {
</span>
) : null}
</p>
) : null}
</header>
{user ? (
<>
<AvatarCard />
<PreferencesCard />
</>
) : null}
{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}`;
@@ -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
? 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">
+6 -1
View File
@@ -1,5 +1,5 @@
import type { TempUpload } from "@/lib/types";
import { getBackendUrl } from "@/lib/backend";
import { getBackendUrl, hasBackend } from "@/lib/backend";
const TOKEN_KEY = "j621.token";
@@ -66,6 +66,11 @@ interface ApiOptions extends Omit<RequestInit, "body"> {
}
export async function api<T>(path: string, options: ApiOptions = {}): Promise<T> {
if (!hasBackend()) {
throw new ApiError(0, {
detail: "No backend is connected — set one up to use this feature.",
});
}
const { json, headers, ...rest } = options;
const finalHeaders = new Headers(headers);
let body = options.body;
+26 -12
View File
@@ -1,9 +1,12 @@
/**
* Where the J621 API lives.
* How this SPA reaches its backend, and whether it has one at all.
*
* A production build asks for this on first start (`/setup`) and remembers the
* answer per browser, so one build works same-origin AND against a backend on
* another domain. Same-origin is the empty string.
* Stored per browser in `localStorage["j621.backend"]`:
* - a URL → that origin serves the API
* - "" → same origin as this page
* - "none" → no backend: local/standalone mode (e621 features only)
* - absent → dev defaults to same-origin; a production build asks
* via /setup on first start.
*
* Self-hosters: set DEFAULT_BACKEND_URL to the origin where your API is
* reachable to prefill the setup screen — the equivalent of the matrix.org
@@ -11,26 +14,38 @@
*/
export const DEFAULT_BACKEND_URL = "";
/** Sentinel for "run without a backend" (local mode). */
export const NO_BACKEND = "none";
const STORAGE_KEY = "j621.backend";
/** Trim slashes, add a scheme when missing (http for localhost, else https). */
export function normalizeBackendUrl(value: string): string {
const trimmed = value.trim().replace(/\/+$/, "");
if (!trimmed) return "";
if (trimmed === NO_BACKEND) return NO_BACKEND;
if (/^https?:\/\//i.test(trimmed)) return trimmed;
const local = /^(localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(trimmed);
return `${local ? "http" : "https"}://${trimmed}`;
}
/** null = the user never chose; "" = same-origin. */
/** null = the user never chose; "" = same-origin; NO_BACKEND = local mode. */
export function getStoredBackendUrl(): string | null {
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw === null ? null : normalizeBackendUrl(raw);
}
/** Current API origin; empty string means same-origin. */
/** True when an API is available (dev falls back to the same-origin proxy). */
export function hasBackend(): boolean {
const stored = getStoredBackendUrl();
if (stored === null) return !import.meta.env?.PROD;
return stored !== NO_BACKEND;
}
/** Current API origin; empty string means same-origin (or no backend). */
export function getBackendUrl(): string {
return getStoredBackendUrl() ?? DEFAULT_BACKEND_URL;
const stored = getStoredBackendUrl();
return stored === null || stored === NO_BACKEND ? "" : stored;
}
export function setBackendUrl(value: string): void {
@@ -38,11 +53,10 @@ export function setBackendUrl(value: string): void {
}
/**
* Production builds must be told where the API is on first start; the dev
* server keeps defaulting to the same-origin Vite proxy (visit /setup to
* change it manually).
* Production builds must be told where the API is on first start (they may
* pick "no backend" there). The dev server keeps defaulting to the
* same-origin Vite proxy; visit /setup to change it manually.
*/
export function needsBackendSetup(): boolean {
if (getStoredBackendUrl() !== null) return false;
return import.meta.env.PROD;
return getStoredBackendUrl() === null && Boolean(import.meta.env?.PROD);
}
+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,