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
+17 -15
View File
@@ -25,7 +25,8 @@ import { offerInstall } from './pwa/install';
import { cameraSupported } from './engine/camera';
import { clearSession, forgetPhoto, loadPhoto, loadState, savePhoto, saveState } from './engine/session';
import { preloadSuperRes, upscaleJpeg } from './engine/superRes';
import { api, type Photo, type SavedRecipe, type User } from './api';
import { api, type Photo, type SavedRecipe } from './api';
import { refreshAuth, useAuth } from './auth';
import { TIERS } from './config/tiers';
import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes';
import { PARAM_DEFS } from '../shared/utils/paramDefs';
@@ -605,7 +606,11 @@ export function Workspace() {
// target bigger than the photo has a model run behind it first.
const [choosingExport, setChoosingExport] = useState(false);
const [savingPhoto, setSavingPhoto] = useState(false);
const [user, setUser] = useState<User | null>(null);
// The account, out of the one record the whole page reads (see auth.ts): the top
// bar's LIBRARY link, the tier this screen offers and the library's own gate all
// move together when someone signs in or out — including when the sign-in
// happens somewhere other than here.
const { user } = useAuth();
// `api.me()` has answered — the account is known, guest or not. Opening a
// photo asks a tier question (see `loadFile`), so the session's photo is
// restored only once this is true; before that `user` is null and every
@@ -729,17 +734,12 @@ export function Workspace() {
if (!loaded.gpu) setError(t('err.noWebgl'));
})
.catch((err) => alive && setError(String(err)));
api
.me()
.then((r) => {
if (!alive) return;
// The account and the flag that says the account is known land in ONE
// batch: the handover effect below then sees a render that already
// carries `user`, so a PRO opening a RAW is not read as a guest.
setUser(r.user);
setAuthReady(true);
})
.catch(() => alive && setAuthReady(true)); // signed out is a valid state — the demo needs no account
// The account comes out of the shared record and lands in it — the studio, the
// library and the top bar are one page, and one of them asking is all of them
// knowing. It is one `/me` however many screens ask: see `refreshAuth`.
refreshAuth().finally(() => {
if (alive) setAuthReady(true);
});
return () => {
alive = false;
};
@@ -4209,8 +4209,10 @@ export function Workspace() {
onClose={() => setAuthMode(null)}
onDone={async () => {
setAuthMode(null);
const me = await api.me().catch(() => null);
if (me) setUser(me.user);
// The dialog's own idea of the account is not the account: the record
// is re-read, so a login here is a login on the LIBRARY link and on
// the library screen at the same moment.
await refreshAuth();
}}
/>
) : null}