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:
+17
-15
@@ -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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api, type Highlights, type Photo, type PhotoSlot, type Rating, type User } from './api';
|
||||
import { refreshAuth } from './auth';
|
||||
import { clearSession } from './engine/session';
|
||||
import { useI18n } from './i18n/I18nProvider';
|
||||
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from './theme/ThemeProvider';
|
||||
@@ -327,10 +328,10 @@ export function Landing() {
|
||||
api.listPhotos().then((r) => setPhotos(r.photos)).catch(() => undefined);
|
||||
api.ratings().then((r) => setRatings(r.ratings)).catch(() => undefined);
|
||||
api.highlights().then((r) => setHighlights(r.highlights)).catch(() => undefined);
|
||||
return api.me().then((r) => {
|
||||
setUser(r.user);
|
||||
return r.user;
|
||||
}).catch(() => null);
|
||||
return refreshAuth().then((u) => {
|
||||
setUser(u);
|
||||
return u;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
// Who this browser is signed in as, in one place.
|
||||
//
|
||||
// Three screens ask the same question — the studio (its account menu and its
|
||||
// tier), the library (whether to open at all) and the top bar (whether the
|
||||
// LIBRARY link is a link). Read separately, each of them answers it once and then
|
||||
// keeps that answer for the life of the page — the library is mounted once and
|
||||
// never asked again — so a visitor who signs in at the studio left the library
|
||||
// behind holding "guest"
|
||||
// — the next click on LIBRARY handed them a panel asking for a login they had just
|
||||
// given. One record, one reader.
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { api, type User } from './api';
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
// The API has answered once: the account is known, guest or not. Before that
|
||||
// `user` is null because nobody has said — which is not the same as "guest", and
|
||||
// is why the LIBRARY link is held back rather than switched off, and why opening
|
||||
// `/library` on a record nobody has answered for yet is not a reason to send the
|
||||
// visitor home.
|
||||
known: boolean;
|
||||
}
|
||||
|
||||
let state: AuthState = { user: null, known: false };
|
||||
let inFlight: Promise<User | null> | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function authState(): AuthState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export function subscribeAuth(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
// The snapshot is a value the store replaces only when it changes, so React can
|
||||
// compare it by identity — one object per answer, not one per render.
|
||||
export function useAuth(): AuthState {
|
||||
return useSyncExternalStore(subscribeAuth, authState);
|
||||
}
|
||||
|
||||
// Take the answer the caller already has — a login, a logout, `/me` on the studio's
|
||||
// own mount — so every subscribed screen moves at once.
|
||||
export function setAuthUser(user: User | null): void {
|
||||
if (state.known && state.user === user) return;
|
||||
state = { user, known: true };
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
// Ask the API again. One request at a time: the studio and the library both ask as
|
||||
// they mount, and one page load is one round trip. A call that fails leaves the
|
||||
// record as it was — the API being away is not the visitor's account ending.
|
||||
export function refreshAuth(): Promise<User | null> {
|
||||
if (!inFlight) {
|
||||
inFlight = api
|
||||
.me()
|
||||
.then((me) => {
|
||||
setAuthUser(me?.user ?? null);
|
||||
return me?.user ?? null;
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
}
|
||||
@@ -1919,6 +1919,39 @@ export async function getPhotoThumbs(ids: string[]): Promise<Map<string, Blob>>
|
||||
return res;
|
||||
}
|
||||
|
||||
// Tiles are made behind a screen somebody is using, so they are not made at the
|
||||
// walk's pace. A tile is a decode, a draw and a JPEG encode — the last two on this
|
||||
// thread — and the job is only ever filling in what the wall will want next, never
|
||||
// what the reader is waiting for: two frames at a time, and the thread handed back
|
||||
// between them. `LANES` four-wide back to back is what made opening a folder
|
||||
// stutter, and it bought nothing: the wall mints the frames in view itself.
|
||||
const THUMB_LANES = 2;
|
||||
|
||||
// The breath between frames. The idle queue where the browser has one — the tile
|
||||
// then waits for a gap rather than making one — and a plain macrotask otherwise;
|
||||
// the timeout is there because an idle queue on a busy page can stay empty.
|
||||
//
|
||||
// ponytail: the walk's own scan is not paced from here and does not stop for a
|
||||
// tile; the two share the thread when a folder is opened mid-read. One budget for
|
||||
// both is the next step if a scan ever runs visibly slower beside a tile job.
|
||||
function breathe(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const idle = (
|
||||
globalThis as {
|
||||
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
|
||||
}
|
||||
).requestIdleCallback;
|
||||
if (idle) idle(() => resolve(), { timeout: 120 });
|
||||
else setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
// One frame's tile, in flight. The wall scrolls to a frame and asks for its tile
|
||||
// while the folder's own job is working through that same folder: without this
|
||||
// both would read and decode the same file, which is the same seconds of disk and
|
||||
// the same draw on this thread for one tile. The second caller waits on the first.
|
||||
const inFlight = new Map<string, Promise<Blob | null>>();
|
||||
|
||||
// A RAW or a HEIC hands back a preview that is a whole develop, seconds of it:
|
||||
// shrunk to the tile's size the one time it is paid for and then kept, so every
|
||||
// later visit reads the tile back instead of the file.
|
||||
@@ -1937,7 +1970,15 @@ async function tileOf(id: string, preview: Blob): Promise<Blob> {
|
||||
// ponytail: a TIFF has no preview reader here, so it is decoded whole through the
|
||||
// browser (`makeTile`) — `tiffDevelop` is the way in if a folder of scans ever
|
||||
// makes that wait a real one.
|
||||
export async function makeThumb(photo: LibraryPhoto): Promise<Blob | null> {
|
||||
export function makeThumb(photo: LibraryPhoto): Promise<Blob | null> {
|
||||
const running = inFlight.get(photo.id);
|
||||
if (running) return running;
|
||||
const job = makeOneThumb(photo).finally(() => inFlight.delete(photo.id));
|
||||
inFlight.set(photo.id, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
async function makeOneThumb(photo: LibraryPhoto): Promise<Blob | null> {
|
||||
const kept = await getPhotoThumb(photo.id);
|
||||
if (kept) return kept;
|
||||
if (!photo.handle) return null;
|
||||
@@ -2001,9 +2042,10 @@ export async function missingThumbs(ids: string[]): Promise<string[]> {
|
||||
|
||||
// MAKE THUMBS: every frame of the folder — and, unless told otherwise, of what
|
||||
// lies under it — given a tile, and only the frames the catalogue is short of one.
|
||||
// A tile is a decode, so the frames are read a few at a time, and a job the
|
||||
// caller has moved on from gives up at its next frame: a reader who has walked to
|
||||
// another folder is not waiting for this one.
|
||||
// A tile is a decode, so the frames are read two at a time with the thread handed
|
||||
// back between them (see THUMB_LANES), and a job the caller has moved on from gives
|
||||
// up at its next frame: a reader who has walked to another folder is not waiting
|
||||
// for this one.
|
||||
export async function makeThumbs(
|
||||
folder: LibraryFolder,
|
||||
rel = '',
|
||||
@@ -2025,11 +2067,15 @@ export async function makeThumbs(
|
||||
const queue = [...missing];
|
||||
let made = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(LANES, queue.length) }, async () => {
|
||||
Array.from({ length: Math.min(THUMB_LANES, queue.length) }, async () => {
|
||||
// The folder the reader just opened gets to paint first: these tiles are what
|
||||
// the wall will want, and the wall itself is what the reader is waiting for.
|
||||
await breathe();
|
||||
while (queue.length) {
|
||||
if (opts.shouldStop?.()) return;
|
||||
const photo = byId.get(queue.shift()!);
|
||||
if (photo && (await makeThumb(photo))) made++;
|
||||
await breathe();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -241,6 +241,9 @@ export const en: Dict = {
|
||||
|
||||
'lib.title': 'LIBRARY',
|
||||
'lib.locked': 'The library is for accounts. Log in to browse your photo folders.',
|
||||
// The LIBRARY button while there is no account: the link is not offered, and
|
||||
// this is what says why.
|
||||
'lib.needAccount': 'The library is for accounts — log in to open it.',
|
||||
'lib.hint':
|
||||
'Pick a photo folder on this machine. Nothing is uploaded — the page only remembers where the folder is and keeps one thumbnail per frame.',
|
||||
'lib.add': 'ADD FOLDER',
|
||||
|
||||
@@ -253,6 +253,8 @@ export const vi = {
|
||||
|
||||
'lib.title': 'THƯ VIỆN',
|
||||
'lib.locked': 'Thư viện dành cho tài khoản. Đăng nhập để duyệt các thư mục ảnh của bạn.',
|
||||
// Nút LIBRARY khi chưa có tài khoản: liên kết không được mời, và đây là lý do.
|
||||
'lib.needAccount': 'Thư viện dành cho tài khoản — hãy đăng nhập để mở.',
|
||||
'lib.hint':
|
||||
'Chọn thư mục ảnh trên máy. Ảnh không được tải lên đâu cả — trang chỉ nhớ đường dẫn và giữ một ảnh thu nhỏ cho mỗi tấm.',
|
||||
'lib.add': 'THÊM THƯ MỤC',
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
// Internal navigation, in one place. SPA in-place navigation preserves
|
||||
// in-memory photo and folder caches when transitioning between Web Studio
|
||||
// (/app) and Library (/library).
|
||||
export function go(url: string): void {
|
||||
history.pushState(null, '', url);
|
||||
//
|
||||
// `replace` is for the moves that are not steps: a screen sending a visitor back
|
||||
// where they belong (the library, to a guest) must not leave itself as the page
|
||||
// the Back button returns to, or the visitor is pushed off it on every press.
|
||||
export function go(url: string, replace = false): void {
|
||||
if (replace) history.replaceState(null, '', url);
|
||||
else history.pushState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}
|
||||
|
||||
|
||||
@@ -266,6 +266,10 @@
|
||||
gap off the label it qualifies. */
|
||||
.btn .pro { margin-left: 6px; }
|
||||
.chip:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
/* A button that is not offered yet — the LIBRARY one, while there is no account
|
||||
to open it with. It keeps its shape so the header does not move when the
|
||||
account lands, and loses the promise of a click. */
|
||||
.btn[aria-disabled="true"] { opacity: 0.45; cursor: not-allowed; }
|
||||
/* The font group as a dropdown: one row that shows the current pairing instead
|
||||
of three chips that all have to fit. */
|
||||
.font-select {
|
||||
|
||||
@@ -109,8 +109,9 @@ export function TopBar({
|
||||
</button>
|
||||
<span className="spacer" />
|
||||
|
||||
{/* The catalogue is a page of its own, open to guests as much as to
|
||||
members — it reads the visitor's own folders and never the server. */}
|
||||
{/* The catalogue's own reading runs in this tab whatever screen is up, so
|
||||
the header says so while it does. The shelf itself is the accounts'
|
||||
(Library.tsx turns a guest away), which is why the link waits for one. */}
|
||||
{live ? (
|
||||
<span
|
||||
className="hint lib-scan"
|
||||
@@ -125,7 +126,16 @@ export function TopBar({
|
||||
<span data-key="studio-scan-count">{`${live.progress.done}/${live.progress.total}`}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<a className="btn ghost" data-key="nav-library" href="/library">{t('nav.library')}</a>
|
||||
{user ? (
|
||||
<a className="btn ghost" data-key="nav-library" href="/library">{t('nav.library')}</a>
|
||||
) : (
|
||||
/* Not a link. 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. */
|
||||
<span className="btn ghost" data-key="nav-library" aria-disabled="true" title={t('lib.needAccount')}>
|
||||
{t('nav.library')}
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="btn ghost" data-key="reset-look" onClick={onReset}>{t('act.reset')}</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user