fix(library): one session for the whole tab, and a shelf that does not take the machine

The library is the accounts' shelf, but the tab kept two copies of that fact: the
screen read `/me` once as it mounted and then stopped listening, so a visitor who
signed in at the studio came back to the panel asking for the login they had just
given. One record, read by the studio, the landing page and the shelf alike, is
now the only copy — a sign-in anywhere is a sign-in everywhere.

- auth.ts (new): the tab's one session record. `useAuth()` reads it through
  `useSyncExternalStore`, a `/me` already in flight is joined rather than
  repeated, and a call that fails leaves the record *unknown* rather than signed
  out, so a cold API never reads as a guest.
- TopBar: LIBRARY is a link for a member and a dead button for everyone else. An
  anchor wearing `aria-disabled` still navigates on a click, and the screen
  behind it would only send the guest home again — so a guest gets the shape of
  the button and nothing to press.
- Library: the gate is that shared record, not a copy taken at mount. A guest who
  opens `/library` straight is turned home with `replace`, so a Back press does
  not walk them into the same wall. The screen is mounted the whole time the tab
  is open, so the turn is for the address the visitor actually opened: a guest on
  the studio stays on the studio.
- engine/library.ts: the thumbs hand the thread back. Two lanes instead of four,
  a frame's worth of idle between them, and one job per frame — a second caller
  joins the decode already running instead of starting another.

Verified in Chrome (stubbed API, harness run against both the dev server and the
built bundle): a guest gets a dead LIBRARY button and stays on /app; signing in at
the studio turns it into the link and opens the shelf, with no second login and no
"log in to browse" note; the same after a sign-in on the landing page; /library
opened cold as a guest lands on / with Back going to /app; opened cold signed in,
the shelf is up. On a folder of 22 frames the open runs 2 decodes at a time (was
5) and repeats no frame (44 decodes, was 56 — 12 frames were read twice); the
tiles still come out 22 on the open, 24 by hand, 22 again after a reload.
This commit is contained in:
2026-10-09 20:50:17 +07:00
parent d84384e9f7
commit 9ad61be26a
10 changed files with 200 additions and 70 deletions
+30 -40
View File
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, u
import { useI18n } from './i18n/I18nProvider';
import { go } from './nav';
import { api } from './api';
import { refreshAuth, useAuth } from './auth';
import { keep, recall } from './remember';
import { readGps, readSpecs, specsLine } from './engine/imageOps';
import { isRawName, isTiffName, rawThumbnail, tiffDevelop } from './engine/rawDevelop';
@@ -255,52 +256,41 @@ const why = (err: unknown) => {
// and there is no reason to hold them behind it. The record only ever holds this
// visitor's own last answer, and an answer the API then contradicts puts the note
// back on screen the moment it arrives.
const ACCOUNT_KEY = 'recipescam.library.account';
// The library is the accounts' shelf, and this screen is the gate on it. What the
// gate reads is the account the whole page shares (see auth.ts): a visitor who
// signed in at the studio is signed in here too, without a second round trip and
// without the panel below asking again for a login they have already given.
//
// `known` is "the API has answered". Until it has, the screen has nothing to
// draw: a session still in flight is not a guest, and opening the shelves early
// or turning the visitor away early would both be guesses.
export function Library() {
const { t } = useI18n();
const [state, setState] = useState<'loading' | 'guest' | 'ready'>(
() => (recall(ACCOUNT_KEY) as 'guest' | 'ready' | null) ?? 'loading'
);
// Whether the account on this browser may repair the catalogue: the API is the
// one that decides, and this only drives the button. Read from `me` below, and
// false until it answers — a control that is not there for a moment is not a
// control that was offered and taken back.
const [admin, setAdmin] = useState(false);
const { user, known } = useAuth();
// A visit that lands here first — a bookmark, a reload — starts on an empty
// record, so this screen asks as it mounts. It is one `/me` however many screens
// ask: the studio asks for its own tier, and both read the same answer.
useEffect(() => {
let alive = true;
api
.me()
.then((me) => {
if (!alive) return;
const next = me?.user ? 'ready' : 'guest';
keep(ACCOUNT_KEY, next);
setState(next);
setAdmin(Boolean(me?.user?.admin));
})
// No answer is treated like no account: the API being away is not a
// reason to open the shelves to whoever is on the page. It is also not an
// answer, so it does not go in the record — the next visit asks again
// rather than reading the outage as this account's standing.
.catch(() => {
if (alive) setState('guest');
});
return () => {
alive = false;
};
void refreshAuth();
}, []);
if (state === 'loading') return <p className="hint adm-note">{t('auth.busy')}</p>;
if (state === 'guest')
return (
<p className="adm-note">
{t('lib.locked')}{' '}
<a className="btn primary" href="/?auth=login">
{t('nav.login')}
</a>
</p>
);
return <Catalogue admin={admin} />;
// Signed out: not this screen, and this screen is mounted the whole time the
// tab is open, so the visitor is not always standing on it — the shelf is a
// panel that shows and hides (see Shell). The turn home is for the address they
// actually opened: a guest on `/library` goes back to the landing page, in place
// of the page they came in on, so a Back press does not walk them into the same
// wall — while a guest on the studio stays on the studio, with the shelf dark.
// The record settles first: an answer that never came is not a sign-out, and the
// catalogue reads this disk rather than the server.
const away = known && !user;
useEffect(() => {
if (away && window.location.pathname.startsWith('/library')) go('/', true);
}, [away]);
if (away) return null;
if (!known) return <p className="hint adm-note">{t('auth.busy')}</p>;
return <Catalogue admin={Boolean(user?.admin)} />;
}
function Catalogue({ admin }: { admin: boolean }) {