diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 9380550..5037339 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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(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} diff --git a/docker/frontend/src/Landing.tsx b/docker/frontend/src/Landing.tsx index 266aa39..226bd1f 100644 --- a/docker/frontend/src/Landing.tsx +++ b/docker/frontend/src/Landing.tsx @@ -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(() => { diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index 8847a99..031a9f6 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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

{t('auth.busy')}

; - if (state === 'guest') - return ( -

- {t('lib.locked')}{' '} - - {t('nav.login')} - -

- ); - return ; + // 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

{t('auth.busy')}

; + return ; } function Catalogue({ admin }: { admin: boolean }) { diff --git a/docker/frontend/src/auth.ts b/docker/frontend/src/auth.ts new file mode 100644 index 0000000..1b81450 --- /dev/null +++ b/docker/frontend/src/auth.ts @@ -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 | 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 { + if (!inFlight) { + inFlight = api + .me() + .then((me) => { + setAuthUser(me?.user ?? null); + return me?.user ?? null; + }) + .catch(() => null) + .finally(() => { + inFlight = null; + }); + } + return inFlight; +} diff --git a/docker/frontend/src/engine/library.ts b/docker/frontend/src/engine/library.ts index 58a85a7..20dda52 100644 --- a/docker/frontend/src/engine/library.ts +++ b/docker/frontend/src/engine/library.ts @@ -1919,6 +1919,39 @@ export async function getPhotoThumbs(ids: string[]): Promise> 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 { + 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>(); + // 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 { // 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 { +export function makeThumb(photo: LibraryPhoto): Promise { + 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 { 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 { // 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(); } }) ); diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index c4c9502..cae50b2 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -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', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index 74fc9a5..aa42569 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -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', diff --git a/docker/frontend/src/nav.ts b/docker/frontend/src/nav.ts index 0eb20d7..2b4b499 100644 --- a/docker/frontend/src/nav.ts +++ b/docker/frontend/src/nav.ts @@ -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')); } - diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 2575fb1..3ed4a58 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -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 { diff --git a/docker/frontend/src/ui/TopBar.tsx b/docker/frontend/src/ui/TopBar.tsx index c0a2f59..0205952 100644 --- a/docker/frontend/src/ui/TopBar.tsx +++ b/docker/frontend/src/ui/TopBar.tsx @@ -109,8 +109,9 @@ export function TopBar({ - {/* 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 ? ( {`${live.progress.done}/${live.progress.total}`} ) : null} - {t('nav.library')} + {user ? ( + {t('nav.library')} + ) : ( + /* 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. */ + + {t('nav.library')} + + )}