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. MariaDB/Redis come from docker-compose.yml.
- Deployment will be Docker-based (compose); do not add systemd/cron unit - Deployment will be Docker-based (compose); do not add systemd/cron unit
files for scheduling — use the container setup for timers/workers. files for scheduling — use the container setup for timers/workers.
- Same-origin and cross-origin frontends both work: a production build asks - Same-origin and cross-origin frontends both work, and the SPA can also run
for the backend origin on first start (/setup, stored in localStorage; with no backend at all: a production build asks on first start (/setup) and
DEFAULT_BACKEND_URL in frontend/src/lib/backend.ts is the prefilled stores the answer in localStorage — a URL, "" for same origin, or "none"
default), the backend allows extra origins via CORS_ALLOWED_ORIGINS (plus for local mode. Local mode shows only the e621-facing pages (Online, Pools)
CSRF_TRUSTED_ORIGINS for the admin), and API media URLs are absolute with credentials kept in this browser (j621.e621) and a "Setup Backend"
(built from the request host), so signed files load cross-origin too. button in the shell. DEFAULT_BACKEND_URL in frontend/src/lib/backend.ts is
TRUST_PROXY_HEADERS=true is required behind a TLS-terminating proxy. 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. - No server-side media processing: the home server cannot handle it.
Compression/optimization runs client-side (WebCodecs + WASM in a worker) Compression/optimization runs client-side (WebCodecs + WASM in a worker)
and the server only applies the result via POST /api/files/J-x/optimize/. 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 (searchable library grid, remove supported) set avatars from library J-IDs
- [x] Profile extras: per-user landing page, default rating filter/sort, items - [x] Profile extras: per-user landing page, default rating filter/sort, items
per page and thumbnail size (synced to the account, applied on load) 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 - [x] Command palette: tag finder, recent searches, navigation commands
(Ctrl+K / ⌘K, reachable from anywhere in the shell) (Ctrl+K / ⌘K, reachable from anywhere in the shell)
+66 -34
View File
@@ -8,6 +8,7 @@ import {
} from "react-router-dom"; } from "react-router-dom";
import { AppShell } from "@/components/AppShell"; import { AppShell } from "@/components/AppShell";
import { BackendNeeded } from "@/components/BackendNeeded";
import { FullPageSpinner } from "@/components/ui"; import { FullPageSpinner } from "@/components/ui";
import AccountPage from "@/features/account/AccountPage"; import AccountPage from "@/features/account/AccountPage";
import { AgeGate } from "@/features/age/AgeGate"; import { AgeGate } from "@/features/age/AgeGate";
@@ -28,7 +29,7 @@ import StatsPage from "@/features/stats/StatsPage";
import UploadPage from "@/features/upload/UploadPage"; import UploadPage from "@/features/upload/UploadPage";
import UsersPage from "@/features/users/UsersPage"; import UsersPage from "@/features/users/UsersPage";
import { isAgeVerified, markAgeVerified } from "@/lib/age"; import { isAgeVerified, markAgeVerified } from "@/lib/age";
import { needsBackendSetup } from "@/lib/backend"; import { hasBackend, needsBackendSetup } from "@/lib/backend";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
function RequireAuth({ children }: { children: ReactNode }) { function RequireAuth({ children }: { children: ReactNode }) {
@@ -40,6 +41,12 @@ function RequireAuth({ children }: { children: ReactNode }) {
return <>{children}</>; 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() { function PostRedirect() {
const { postId = "" } = useParams(); const { postId = "" } = useParams();
return <Navigate to={`/detail/${postId}`} replace />; return <Navigate to={`/detail/${postId}`} replace />;
@@ -77,9 +84,21 @@ export default function App() {
return ( return (
<Routes> <Routes>
<Route element={<AppShell />}> <Route element={<AppShell />}>
<Route path="/" element={<LibraryPage />} /> <Route
path="/"
element={
hasBackend() ? <LibraryPage /> : <Navigate to="/online" replace />
}
/>
<Route path="/detail/:itemId" element={<DetailPage />} /> <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" element={<OnlinePage />} />
<Route path="/online/view/:postId" element={<PostRedirect />} /> <Route path="/online/view/:postId" element={<PostRedirect />} />
<Route path="/pools" element={<PoolsPage />} /> <Route path="/pools" element={<PoolsPage />} />
@@ -87,71 +106,84 @@ export default function App() {
<Route <Route
path="/followed" path="/followed"
element={ element={
<RequireAuth> <RequireBackend>
<FollowedPage /> <RequireAuth>
</RequireAuth> <FollowedPage />
</RequireAuth>
</RequireBackend>
} }
/> />
<Route <Route
path="/duplicates" path="/duplicates"
element={ element={
<RequireAuth> <RequireBackend>
<DuplicatesPage /> <RequireAuth>
</RequireAuth> <DuplicatesPage />
</RequireAuth>
</RequireBackend>
} }
/> />
<Route <Route
path="/delete" path="/delete"
element={ element={
<RequireAuth> <RequireBackend>
<DeletePage /> <RequireAuth>
</RequireAuth> <DeletePage />
</RequireAuth>
</RequireBackend>
} }
/> />
<Route <Route
path="/stats" path="/stats"
element={ element={
<RequireAuth> <RequireBackend>
<StatsPage /> <RequireAuth>
</RequireAuth> <StatsPage />
</RequireAuth>
</RequireBackend>
} }
/> />
<Route <Route
path="/users" path="/users"
element={ element={
<RequireAuth> <RequireBackend>
<UsersPage /> <RequireAuth>
</RequireAuth> <UsersPage />
</RequireAuth>
</RequireBackend>
} }
/> />
<Route <Route
path="/similar" path="/similar"
element={ element={
<RequireAuth> <RequireBackend>
<SimilarPage /> <RequireAuth>
</RequireAuth> <SimilarPage />
</RequireAuth>
</RequireBackend>
} }
/> />
<Route <Route
path="/upload" path="/upload"
element={ element={
<RequireAuth> <RequireBackend>
<UploadPage /> <RequireAuth>
</RequireAuth> <UploadPage />
} </RequireAuth>
/> </RequireBackend>
<Route
path="/account"
element={
<RequireAuth>
<AccountPage />
</RequireAuth>
} }
/> />
<Route path="/account" element={<AccountPage />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Route> </Route>
<Route path="/login" element={<LoginPage />} /> <Route
<Route path="/register" element={<RegisterPage />} /> path="/login"
element={hasBackend() ? <LoginPage /> : <BackendNeeded />}
/>
<Route
path="/register"
element={hasBackend() ? <RegisterPage /> : <BackendNeeded />}
/>
<Route path="/setup" element={<SetupPage />} /> <Route path="/setup" element={<SetupPage />} />
</Routes> </Routes>
); );
+67 -40
View File
@@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query"; 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 { useEffect, useRef, useState } from "react";
import { import {
Link, Link,
@@ -16,6 +16,7 @@ import { StatusFooter } from "@/components/StatusFooter";
import { StatusPill } from "@/components/StatusPill"; import { StatusPill } from "@/components/StatusPill";
import { Toasts } from "@/components/Toasts"; import { Toasts } from "@/components/Toasts";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import type { SystemStatus } from "@/lib/types"; import type { SystemStatus } from "@/lib/types";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
@@ -31,6 +32,7 @@ export function AppShell() {
const navigate = useNavigate(); const navigate = useNavigate();
const [paletteOpen, setPaletteOpen] = useState(false); const [paletteOpen, setPaletteOpen] = useState(false);
const landingApplied = useRef(false); const landingApplied = useRef(false);
const backend = hasBackend();
useEffect(() => { useEffect(() => {
void loadE621(); void loadE621();
@@ -50,6 +52,7 @@ export function AppShell() {
queryFn: () => api<SystemStatus>("/api/status/"), queryFn: () => api<SystemStatus>("/api/status/"),
refetchInterval: 5_000, refetchInterval: 5_000,
staleTime: 4_000, staleTime: 4_000,
enabled: backend,
}); });
const status = statusQuery.data; const status = statusQuery.data;
@@ -78,19 +81,19 @@ export function AppShell() {
location.pathname === "/" || location.pathname === "/online"; location.pathname === "/" || location.pathname === "/online";
const navItems = [ const navItems = [
{ to: "/", label: "Library" }, ...(backend ? [{ to: "/", label: "Library" }] : []),
{ to: "/online", label: "Online" }, { to: "/online", label: "Online" },
{ to: "/pools", label: "Pools" }, { to: "/pools", label: "Pools" },
...(user ? [{ to: "/followed", label: "Followed" }] : []), ...(backend && user ? [{ to: "/followed", label: "Followed" }] : []),
...(user ? [{ to: "/similar", label: "Similar" }] : []), ...(backend && user ? [{ to: "/similar", label: "Similar" }] : []),
...(user?.can_upload ...(backend && user?.can_upload
? [ ? [
{ to: "/upload", label: "Upload" }, { to: "/upload", label: "Upload" },
{ to: "/duplicates", label: "Duplicates" }, { to: "/duplicates", label: "Duplicates" },
{ to: "/delete", label: "Delete" }, { 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: "/stats", label: "Stats" },
{ to: "/users", label: "Users" }, { to: "/users", label: "Users" },
@@ -147,45 +150,66 @@ export function AppShell() {
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
<StatusPill status={status} /> {backend ? (
{user ? ( <>
<StatusPill status={status} />
{user ? (
<>
<Link
to="/account"
title="Account settings"
className="flex items-center gap-2 transition hover:opacity-80"
>
{user.avatar_url ? (
<img
src={user.avatar_url}
alt={user.username}
className="h-7 w-7 rounded-full border border-ctp-surface1 object-cover"
/>
) : (
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-ctp-surface1 font-mono text-xs font-semibold text-ctp-lavender">
{user.username.slice(0, 1).toUpperCase()}
</span>
)}
<span className="hidden text-sm text-ctp-subtext1 sm:block">
{user.username}
</span>
</Link>
<button
type="button"
onClick={() => void logout()}
title="Log out"
className="rounded-md p-1.5 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
<LogOut className="h-4 w-4" />
</button>
</>
) : (
<Link
to="/login"
className="rounded-md px-3 py-1.5 text-sm font-medium text-ctp-subtext0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
Log in
</Link>
)}
</>
) : (
<> <>
<Link <Link
to="/account" to="/account"
title="Account settings" title="e621 credentials (stored in this browser)"
className="flex items-center gap-2 transition hover:opacity-80"
>
{user.avatar_url ? (
<img
src={user.avatar_url}
alt={user.username}
className="h-7 w-7 rounded-full border border-ctp-surface1 object-cover"
/>
) : (
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-ctp-surface1 font-mono text-xs font-semibold text-ctp-lavender">
{user.username.slice(0, 1).toUpperCase()}
</span>
)}
<span className="hidden text-sm text-ctp-subtext1 sm:block">
{user.username}
</span>
</Link>
<button
type="button"
onClick={() => void logout()}
title="Log out"
className="rounded-md p-1.5 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text" className="rounded-md p-1.5 text-ctp-overlay0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
> >
<LogOut className="h-4 w-4" /> <KeyRound className="h-4 w-4" />
</button> </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>
</> </>
) : (
<Link
to="/login"
className="rounded-md px-3 py-1.5 text-sm font-medium text-ctp-subtext0 transition hover:bg-ctp-surface0 hover:text-ctp-text"
>
Log in
</Link>
)} )}
</div> </div>
</div> </div>
@@ -196,9 +220,12 @@ export function AppShell() {
</main> </main>
<StatusFooter <StatusFooter
backend={backend}
status={status} status={status}
staff={Boolean( 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)} /> <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.
</>
}
/>
);
}
+45 -21
View File
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Cable,
Copy, Copy,
ExternalLink, ExternalLink,
FolderOpen, FolderOpen,
@@ -25,6 +26,7 @@ import { useNavigate } from "react-router-dom";
import { TagChip } from "@/components/TagChip"; import { TagChip } from "@/components/TagChip";
import { Spinner } from "@/components/ui"; import { Spinner } from "@/components/ui";
import { TagFollowToggle } from "@/features/follows/TagFollowToggle"; import { TagFollowToggle } from "@/features/follows/TagFollowToggle";
import { hasBackend } from "@/lib/backend";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { import {
effectiveCredentials, effectiveCredentials,
@@ -112,13 +114,18 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
}); });
const commands = useMemo<Command[]>(() => { const commands = useMemo<Command[]>(() => {
const backend = hasBackend();
const list: Command[] = [ const list: Command[] = [
{ ...(backend
id: "library", ? [
label: "Go to Library", {
icon: FolderOpen, id: "library",
run: () => navigate("/"), label: "Go to Library",
}, icon: FolderOpen,
run: () => navigate("/"),
},
]
: []),
{ {
id: "online", id: "online",
label: "Go to Online browser", label: "Go to Online browser",
@@ -131,7 +138,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: Layers, icon: Layers,
run: () => navigate("/pools"), run: () => navigate("/pools"),
}, },
...(user ...(backend && user
? [ ? [
{ {
id: "followed", id: "followed",
@@ -147,7 +154,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
}, },
] ]
: []), : []),
...(user?.can_upload ...(backend && user?.can_upload
? [ ? [
{ {
id: "upload", id: "upload",
@@ -176,20 +183,30 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: Menu, icon: Menu,
run: toggleSidebar, run: toggleSidebar,
}, },
...(backend
? [
{
id: "search",
label: "Focus library search",
hint: "/",
icon: Search,
run: () => {
navigate("/");
window.requestAnimationFrame(() => {
document.getElementById("library-search")?.focus();
});
},
},
]
: []),
{ {
id: "search", id: "setup",
label: "Focus library search", label: backend ? "Change backend" : "Set up a backend",
hint: "/", icon: Cable,
icon: Search, run: () => navigate("/setup"),
run: () => {
navigate("/");
window.requestAnimationFrame(() => {
document.getElementById("library-search")?.focus();
});
},
}, },
]; ];
if (user) { if (backend && user) {
list.push({ list.push({
id: "account", id: "account",
label: "Account settings", label: "Account settings",
@@ -210,13 +227,20 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
icon: LogOut, icon: LogOut,
run: () => void logout(), run: () => void logout(),
}); });
} else { } else if (backend) {
list.push({ list.push({
id: "login", id: "login",
label: "Log in", label: "Log in",
icon: LogIn, icon: LogIn,
run: () => navigate("/login"), run: () => navigate("/login"),
}); });
} else {
list.push({
id: "credentials",
label: "e621 credentials",
icon: Settings,
run: () => navigate("/account"),
});
} }
return list; return list;
}, [navigate, toggleSidebar, user, logout]); }, [navigate, toggleSidebar, user, logout]);
@@ -326,7 +350,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
<span className="ml-auto font-mono text-[11px] text-ctp-overlay0"> <span className="ml-auto font-mono text-[11px] text-ctp-overlay0">
{row.tag.post_count.toLocaleString()} posts {row.tag.post_count.toLocaleString()} posts
</span> </span>
<TagFollowToggle tag={row.tag.name} /> {hasBackend() ? <TagFollowToggle tag={row.tag.name} /> : null}
<a <a
href={`https://e621.net/posts?tags=${encodeURIComponent(row.tag.name)}`} href={`https://e621.net/posts?tags=${encodeURIComponent(row.tag.name)}`}
target="_blank" 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 { Link } from "react-router-dom";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@@ -14,13 +14,15 @@ function storageColor(percent: number): string {
} }
export function StatusFooter({ export function StatusFooter({
backend,
status, status,
staff, staff,
}: { }: {
backend: boolean;
status?: SystemStatus; status?: SystemStatus;
staff?: boolean; staff?: boolean;
}) { }) {
const backend = getBackendUrl() || window.location.origin; const backendOrigin = getBackendUrl() || window.location.origin;
const storage = status?.storage; const storage = status?.storage;
const workers = ( 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 ( return (
<footer className="fixed inset-x-0 bottom-0 z-30 border-t border-ctp-surface0 bg-ctp-crust"> <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"> <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 <span
className="mx-auto hidden min-w-0 items-center gap-1.5 md:flex" className="mx-auto hidden min-w-0 items-center gap-1.5 md:flex"
title={`Backend API: ${backend}`} title={`Backend API: ${backendOrigin}`}
> >
{staff ? ( {staff ? (
<Link <Link
@@ -74,10 +96,10 @@ export function StatusFooter({
className="truncate transition hover:text-ctp-text" className="truncate transition hover:text-ctp-text"
title="Change backend" title="Change backend"
> >
{backend} {backendOrigin}
</Link> </Link>
) : ( ) : (
<span className="truncate">{backend}</span> <span className="truncate">{backendOrigin}</span>
)} )}
</span> </span>
+2 -1
View File
@@ -1,4 +1,5 @@
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { hasBackend } from "@/lib/backend";
import { TagFollowToggle } from "@/features/follows/TagFollowToggle"; import { TagFollowToggle } from "@/features/follows/TagFollowToggle";
const TAG_STYLES: Record<string, string> = { const TAG_STYLES: Record<string, string> = {
@@ -52,7 +53,7 @@ export function TagChip({
) : ( ) : (
<span>{label}</span> <span>{label}</span>
)} )}
<TagFollowToggle tag={tag} /> {hasBackend() ? <TagFollowToggle tag={tag} /> : null}
</span> </span>
); );
} }
+50 -20
View File
@@ -1,8 +1,10 @@
import { Eye, EyeOff } from "lucide-react"; import { Eye, EyeOff } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link, Navigate } from "react-router-dom";
import { Button, Field, Spinner, inputClass } from "@/components/ui"; import { Button, Field, Spinner, inputClass } from "@/components/ui";
import { errorMessage } from "@/lib/api"; import { errorMessage } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { import {
e621ErrorMessage, e621ErrorMessage,
@@ -175,8 +177,9 @@ function AccountForm({ credentials }: { credentials: E621Credentials | null }) {
</div> </div>
<p className="text-xs text-ctp-overlay0"> <p className="text-xs text-ctp-overlay0">
Stored on your server and fetched by this app when needed. e621 requests {hasBackend()
are made directly from your browser, throttled to one per second. ? "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> </p>
</div> </div>
</section> </section>
@@ -188,35 +191,62 @@ export default function AccountPage() {
const credentials = useE621((state) => state.credentials); const credentials = useE621((state) => state.credentials);
const loading = useE621((state) => state.loading); const loading = useE621((state) => state.loading);
const load = useE621((state) => state.load); const load = useE621((state) => state.load);
const backend = hasBackend();
useEffect(() => { useEffect(() => {
void load(); void load();
}, [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 ( return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6"> <div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
<header> <header>
<h1 className="text-lg font-semibold">Account</h1> <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">
<p className="mt-1 flex flex-wrap items-center gap-2 text-sm text-ctp-overlay0"> <span>Signed in as {user.username}.</span>
<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">
<span className="rounded-full border border-ctp-surface1 px-2 py-0.5 font-mono text-[11px] capitalize text-ctp-subtext0"> {user.role}
{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> </span>
{user.is_superuser ? ( ) : null}
<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"> </p>
admin
</span>
) : null}
</p>
) : null}
</header> </header>
{user ? ( <AvatarCard />
<> <PreferencesCard />
<AvatarCard />
<PreferencesCard />
</>
) : null}
{loading && !credentials ? ( {loading && !credentials ? (
<div className="flex justify-center py-12"> <div className="flex justify-center py-12">
<Spinner className="h-6 w-6" /> <Spinner className="h-6 w-6" />
@@ -1,8 +1,10 @@
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { BackendNeeded } from "@/components/BackendNeeded";
import { EmptyState } from "@/components/ui"; import { EmptyState } from "@/components/ui";
import { LibraryDetail } from "@/features/library/LibraryDetail"; import { LibraryDetail } from "@/features/library/LibraryDetail";
import { OnlineDetail } from "@/features/online/OnlineDetail"; import { OnlineDetail } from "@/features/online/OnlineDetail";
import { hasBackend } from "@/lib/backend";
const J_ID_RE = /^J-\d+$/i; const J_ID_RE = /^J-\d+$/i;
@@ -10,6 +12,7 @@ export default function DetailPage() {
const { itemId = "" } = useParams(); const { itemId = "" } = useParams();
if (J_ID_RE.test(itemId)) { if (J_ID_RE.test(itemId)) {
if (!hasBackend()) return <BackendNeeded />;
const normalized = `J-${itemId.slice(2)}`; const normalized = `J-${itemId.slice(2)}`;
return <LibraryDetail jId={normalized} />; return <LibraryDetail jId={normalized} />;
} }
@@ -4,6 +4,7 @@ import { useState } from "react";
import { Button, Spinner } from "@/components/ui"; import { Button, Spinner } from "@/components/ui";
import { api, errorMessage } from "@/lib/api"; import { api, errorMessage } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import type { FollowedPool, Paginated } from "@/lib/types"; import type { FollowedPool, Paginated } from "@/lib/types";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { toast } from "@/store/toasts"; 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 busy = mutation.isPending || (target !== null && followed !== target);
const label = name ? `"${name}"` : `#${poolId}`; 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 { TagChip } from "@/components/TagChip";
import { DetailShell } from "@/features/detail/DetailShell"; import { DetailShell } from "@/features/detail/DetailShell";
import { api, apiUrl, errorMessage } from "@/lib/api"; import { api, apiUrl, errorMessage } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { renderDText } from "@/lib/dtext"; import { renderDText } from "@/lib/dtext";
import { isTypingTarget } from "@/lib/dom"; import { isTypingTarget } from "@/lib/dom";
@@ -101,7 +102,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
method: "POST", method: "POST",
json: { md5s: [post?.file.md5] }, json: { md5s: [post?.file.md5] },
}), }),
enabled: Boolean(post?.file.md5), enabled: hasBackend() && Boolean(post?.file.md5),
staleTime: 60_000, staleTime: 60_000,
}); });
const libraryJId = post const libraryJId = post
@@ -240,13 +241,15 @@ export function OnlineDetail({ postId }: { postId: number }) {
const isVideo = post.file.ext === "mp4" || post.file.ext === "webm"; const isVideo = post.file.ext === "mp4" || post.file.ext === "webm";
const imageSrc = const imageSrc =
post.sample.has && post.sample.url ? post.sample.url : post.file.url; 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 const clientDownloadUrl = post.file.url
? apiUrl( ? hasBackend()
`/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent( ? apiUrl(
`${post.file.md5}.${post.file.ext}`, `/api/online/file/?url=${encodeURIComponent(post.file.url)}&filename=${encodeURIComponent(
)}`, `${post.file.md5}.${post.file.ext}`,
) )}`,
)
: post.file.url
: null; : null;
return ( return (
@@ -294,7 +297,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
<Link to={`/detail/${libraryJId}`} className={linkButtonClass}> <Link to={`/detail/${libraryJId}`} className={linkButtonClass}>
<CheckCircle2 className="h-4 w-4" /> In library ({libraryJId}) <CheckCircle2 className="h-4 w-4" /> In library ({libraryJId})
</Link> </Link>
) : ( ) : hasBackend() ? (
<Button <Button
variant="secondary" variant="secondary"
disabled={ disabled={
@@ -315,7 +318,7 @@ export function OnlineDetail({ postId }: { postId: number }) {
? "Downloading…" ? "Downloading…"
: "Download to Library"} : "Download to Library"}
</Button> </Button>
)} ) : null}
{clientDownloadUrl ? ( {clientDownloadUrl ? (
<a <a
+3 -1
View File
@@ -22,6 +22,7 @@ import { useE621 } from "@/store/e621";
import { useAuth } from "@/store/auth"; import { useAuth } from "@/store/auth";
import { useUi } from "@/store/ui"; import { useUi } from "@/store/ui";
import { toast } from "@/store/toasts"; import { toast } from "@/store/toasts";
import { hasBackend } from "@/lib/backend";
const PAGE_SIZE = 48; const PAGE_SIZE = 48;
const EMPTY_POSTS: E621Post[] = []; const EMPTY_POSTS: E621Post[] = [];
@@ -67,6 +68,7 @@ export default function OnlinePage() {
onSuccess: () => { onSuccess: () => {
setBlacklistDraft(""); setBlacklistDraft("");
// The Followed page's cloud counted the old list; rebuild it. // The Followed page's cloud counted the old list; rebuild it.
if (!hasBackend()) return;
void api("/api/follows/cloud/?refresh=1") void api("/api/follows/cloud/?refresh=1")
.catch(() => undefined) .catch(() => undefined)
.finally(() => { .finally(() => {
@@ -121,7 +123,7 @@ export default function OnlinePage() {
method: "POST", method: "POST",
json: { md5s }, json: { md5s },
}), }),
enabled: md5s.length > 0, enabled: hasBackend() && md5s.length > 0,
staleTime: 60_000, staleTime: 60_000,
}); });
const inLibrary = useMemo( const inLibrary = useMemo(
@@ -8,6 +8,7 @@ import { BottomSheet } from "@/components/BottomSheet";
import { PostCard } from "@/features/online/PostCard"; import { PostCard } from "@/features/online/PostCard";
import { PoolFollowButton } from "@/features/follows/PoolFollowButton"; import { PoolFollowButton } from "@/features/follows/PoolFollowButton";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { renderDText } from "@/lib/dtext"; import { renderDText } from "@/lib/dtext";
import { import {
@@ -109,7 +110,7 @@ export default function PoolDetailPage() {
method: "POST", method: "POST",
json: { md5s }, json: { md5s },
}), }),
enabled: md5s.length > 0, enabled: hasBackend() && md5s.length > 0,
staleTime: 60_000, staleTime: 60_000,
}); });
const inLibrary = useMemo( const inLibrary = useMemo(
+11 -2
View File
@@ -5,6 +5,7 @@ import { Button, Spinner, inputClass } from "@/components/ui";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { import {
DEFAULT_BACKEND_URL, DEFAULT_BACKEND_URL,
NO_BACKEND,
getBackendUrl, getBackendUrl,
getStoredBackendUrl, getStoredBackendUrl,
normalizeBackendUrl, normalizeBackendUrl,
@@ -57,7 +58,10 @@ async function testBackend(base: string): Promise<TestResult> {
*/ */
export function SetupPage() { export function SetupPage() {
const stored = getStoredBackendUrl(); 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 [testing, setTesting] = useState(false);
const [result, setResult] = useState<TestResult>(null); const [result, setResult] = useState<TestResult>(null);
@@ -101,7 +105,9 @@ export function SetupPage() {
<p className="mt-3 text-sm leading-relaxed text-ctp-subtext0"> <p className="mt-3 text-sm leading-relaxed text-ctp-subtext0">
Enter the origin this app should call for its API. Leave it blank 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> </p>
<label className="mt-5 flex flex-col gap-1.5"> <label className="mt-5 flex flex-col gap-1.5">
@@ -154,6 +160,9 @@ export function SetupPage() {
Use this server Use this server
</Button> </Button>
) : null} ) : null}
<Button variant="ghost" onClick={() => save(NO_BACKEND)}>
{localMode ? "Keep running without a backend" : "Continue without a backend"}
</Button>
</div> </div>
<p className="mt-4 text-xs leading-relaxed text-ctp-overlay0"> <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 type { TempUpload } from "@/lib/types";
import { getBackendUrl } from "@/lib/backend"; import { getBackendUrl, hasBackend } from "@/lib/backend";
const TOKEN_KEY = "j621.token"; 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> { 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 { json, headers, ...rest } = options;
const finalHeaders = new Headers(headers); const finalHeaders = new Headers(headers);
let body = options.body; 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 * Stored per browser in `localStorage["j621.backend"]`:
* answer per browser, so one build works same-origin AND against a backend on * - a URL → that origin serves the API
* another domain. Same-origin is the empty string. * - "" → 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 * 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 * reachable to prefill the setup screen — the equivalent of the matrix.org
@@ -11,26 +14,38 @@
*/ */
export const DEFAULT_BACKEND_URL = ""; export const DEFAULT_BACKEND_URL = "";
/** Sentinel for "run without a backend" (local mode). */
export const NO_BACKEND = "none";
const STORAGE_KEY = "j621.backend"; const STORAGE_KEY = "j621.backend";
/** Trim slashes, add a scheme when missing (http for localhost, else https). */ /** Trim slashes, add a scheme when missing (http for localhost, else https). */
export function normalizeBackendUrl(value: string): string { export function normalizeBackendUrl(value: string): string {
const trimmed = value.trim().replace(/\/+$/, ""); const trimmed = value.trim().replace(/\/+$/, "");
if (!trimmed) return ""; if (!trimmed) return "";
if (trimmed === NO_BACKEND) return NO_BACKEND;
if (/^https?:\/\//i.test(trimmed)) return trimmed; if (/^https?:\/\//i.test(trimmed)) return trimmed;
const local = /^(localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(trimmed); const local = /^(localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/i.test(trimmed);
return `${local ? "http" : "https"}://${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 { export function getStoredBackendUrl(): string | null {
const raw = window.localStorage.getItem(STORAGE_KEY); const raw = window.localStorage.getItem(STORAGE_KEY);
return raw === null ? null : normalizeBackendUrl(raw); 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 { 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 { 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 * Production builds must be told where the API is on first start (they may
* server keeps defaulting to the same-origin Vite proxy (visit /setup to * pick "no backend" there). The dev server keeps defaulting to the
* change it manually). * same-origin Vite proxy; visit /setup to change it manually.
*/ */
export function needsBackendSetup(): boolean { export function needsBackendSetup(): boolean {
if (getStoredBackendUrl() !== null) return false; return getStoredBackendUrl() === null && Boolean(import.meta.env?.PROD);
return import.meta.env.PROD;
} }
+6
View File
@@ -1,6 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { api, getToken, setToken } from "@/lib/api"; import { api, getToken, setToken } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { applyPreferences } from "@/lib/preferences"; import { applyPreferences } from "@/lib/preferences";
import { queryClient } from "@/lib/queryClient"; import { queryClient } from "@/lib/queryClient";
import type { User } from "@/lib/types"; import type { User } from "@/lib/types";
@@ -58,6 +59,11 @@ export const useAuth = create<AuthState>((set) => ({
}, },
loadUser: async () => { loadUser: async () => {
if (!hasBackend()) {
// Local mode: no account, and the e621 features work on their own.
set({ user: null, ready: true });
return;
}
if (!getToken()) { if (!getToken()) {
set({ user: null, ready: true }); set({ user: null, ready: true });
return; return;
+49
View File
@@ -1,6 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { hasBackend } from "@/lib/backend";
import { import {
effectiveCredentials, effectiveCredentials,
updateBlacklist, updateBlacklist,
@@ -9,6 +10,43 @@ import {
type E621User, type E621User,
} from "@/lib/e621"; } 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 { interface E621State {
credentials: E621Credentials | null; credentials: E621Credentials | null;
profile: E621User | null; profile: E621User | null;
@@ -35,6 +73,11 @@ export const useE621 = create<E621State>((set, get) => ({
load: async () => { load: async () => {
set({ loading: true }); set({ loading: true });
if (!hasBackend()) {
set({ credentials: readLocalCredentials(), ready: true, loading: false });
void get().loadBlacklist();
return;
}
try { try {
const credentials = await api<E621Credentials>("/api/auth/e621/"); const credentials = await api<E621Credentials>("/api/auth/e621/");
set({ credentials, ready: true, loading: false }); set({ credentials, ready: true, loading: false });
@@ -71,6 +114,12 @@ export const useE621 = create<E621State>((set, get) => ({
}, },
save: async (values) => { save: async (values) => {
if (!hasBackend()) {
const credentials = writeLocalCredentials(values);
set({ credentials });
void get().loadBlacklist();
return credentials;
}
const credentials = await api<E621Credentials>("/api/auth/e621/", { const credentials = await api<E621Credentials>("/api/auth/e621/", {
method: "PUT", method: "PUT",
json: values, json: values,