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:
@@ -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/.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+66
-34
@@ -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={
|
||||
<RequireAuth>
|
||||
<FollowedPage />
|
||||
</RequireAuth>
|
||||
<RequireBackend>
|
||||
<RequireAuth>
|
||||
<FollowedPage />
|
||||
</RequireAuth>
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/duplicates"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<DuplicatesPage />
|
||||
</RequireAuth>
|
||||
<RequireBackend>
|
||||
<RequireAuth>
|
||||
<DuplicatesPage />
|
||||
</RequireAuth>
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/delete"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<DeletePage />
|
||||
</RequireAuth>
|
||||
<RequireBackend>
|
||||
<RequireAuth>
|
||||
<DeletePage />
|
||||
</RequireAuth>
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/stats"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<StatsPage />
|
||||
</RequireAuth>
|
||||
<RequireBackend>
|
||||
<RequireAuth>
|
||||
<StatsPage />
|
||||
</RequireAuth>
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<UsersPage />
|
||||
</RequireAuth>
|
||||
<RequireBackend>
|
||||
<RequireAuth>
|
||||
<UsersPage />
|
||||
</RequireAuth>
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/similar"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<SimilarPage />
|
||||
</RequireAuth>
|
||||
<RequireBackend>
|
||||
<RequireAuth>
|
||||
<SimilarPage />
|
||||
</RequireAuth>
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/upload"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<UploadPage />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AccountPage />
|
||||
</RequireAuth>
|
||||
<RequireBackend>
|
||||
<RequireAuth>
|
||||
<UploadPage />
|
||||
</RequireAuth>
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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,45 +150,66 @@ export function AppShell() {
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<StatusPill status={status} />
|
||||
{user ? (
|
||||
{backend ? (
|
||||
<>
|
||||
<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
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
<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>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
@@ -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)} />
|
||||
|
||||
@@ -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.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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[] = [
|
||||
{
|
||||
id: "library",
|
||||
label: "Go to Library",
|
||||
icon: FolderOpen,
|
||||
run: () => navigate("/"),
|
||||
},
|
||||
...(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,20 +183,30 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
icon: Menu,
|
||||
run: toggleSidebar,
|
||||
},
|
||||
...(backend
|
||||
? [
|
||||
{
|
||||
id: "search",
|
||||
label: "Focus library search",
|
||||
hint: "/",
|
||||
icon: Search,
|
||||
run: () => {
|
||||
navigate("/");
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById("library-search")?.focus();
|
||||
});
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "search",
|
||||
label: "Focus library search",
|
||||
hint: "/",
|
||||
icon: Search,
|
||||
run: () => {
|
||||
navigate("/");
|
||||
window.requestAnimationFrame(() => {
|
||||
document.getElementById("library-search")?.focus();
|
||||
});
|
||||
},
|
||||
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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user