- Tags and custom fields update the cached entry optimistically and build from the latest cache; one entry's PATCHes run in order (mutation scope). - The review textarea follows server changes (Restore) unless edited. - friendlyError() maps validation reasons and 413 to plain copy everywhere. - Rating scale from me/preferences; /login and /register use auth/status; Attachments lists from the API. - Gelasio vendored (official OFL variable TTFs); minors from the review. - Docs: DESIGN (AA on orange, faint labels), DECISIONS, TODO, ARCHITECTURE. Co-Authored-By: Claude Opus 5.5 <[email protected]>
75 lines
2.5 KiB
TypeScript
75 lines
2.5 KiB
TypeScript
import { QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { StrictMode, type ReactNode } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import { Redirect, Route, Switch } from "wouter";
|
|
import { ApiError } from "./api.ts";
|
|
import { friendlyError } from "./format.ts";
|
|
import { Shell } from "./components/Shell.tsx";
|
|
import { useMe } from "./hooks.ts";
|
|
import { Library } from "./routes/library.tsx";
|
|
import { Login } from "./routes/login.tsx";
|
|
import { NotYet } from "./routes/not-yet.tsx";
|
|
import { Register } from "./routes/register.tsx";
|
|
|
|
const queryClient: QueryClient = new QueryClient({
|
|
queryCache: new QueryCache({
|
|
// A 401 anywhere means the session ended: re-check who we are, which sends the user to sign in.
|
|
onError: (e, query) => {
|
|
if (e instanceof ApiError && e.status === 401 && query.queryKey[0] !== "me") void queryClient.invalidateQueries({ queryKey: ["me"] });
|
|
},
|
|
}),
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 15_000,
|
|
retry: (n, e) => !(e instanceof ApiError && e.status > 0 && e.status < 500) && n < 2,
|
|
},
|
|
},
|
|
});
|
|
|
|
function RequireAuth({ children }: { children: ReactNode }) {
|
|
const me = useMe();
|
|
if (me.isPending) return <div className="boot"><span className="logo">STTracker</span></div>;
|
|
if (me.error instanceof ApiError && me.error.status === 401) return <Redirect to="/login" replace />;
|
|
if (me.error) {
|
|
return (
|
|
<div className="auth">
|
|
<div className="auth-card" role="alert">
|
|
<span className="logo">STTracker</span>
|
|
<h1>Can't load your library</h1>
|
|
<p className="lede">{friendlyError(me.error)}</p>
|
|
<button className="btn primary" onClick={() => void me.refetch()}>Try again</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return <>{children}</>;
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<Switch>
|
|
<Route path="/login" component={Login} />
|
|
<Route path="/register" component={Register} />
|
|
<Route>
|
|
<RequireAuth>
|
|
<Shell>
|
|
<Switch>
|
|
<Route path="/">{() => <Library selectedId={null} />}</Route>
|
|
<Route path="/entries/:id">{(p) => <Library selectedId={p.id} />}</Route>
|
|
<Route path="/:page/*?">{(p) => <NotYet page={p.page} />}</Route>
|
|
</Switch>
|
|
</Shell>
|
|
</RequireAuth>
|
|
</Route>
|
|
</Switch>
|
|
);
|
|
}
|
|
|
|
createRoot(document.getElementById("root")!).render(
|
|
<StrictMode>
|
|
<QueryClientProvider client={queryClient}>
|
|
<App />
|
|
</QueryClientProvider>
|
|
</StrictMode>,
|
|
);
|