81637b8502
A RAW studio that cannot see a folder is one photo at a time. /library now takes a folder through Chromium's directory picker, keeps the handle in IndexedDB so the folder is there on the next visit, and walks it into a grid: one thumbnail per frame, the frame's own date, and the recipe it was last graded with. Nothing is uploaded and nothing is read twice — the RAW itself is opened only when a tile is clicked, at which point the studio develops it and the recipe comes back on top. The studio files every change back against the frame, debounced, so reopening a RAW is not doing the grade again. Thumbnails come off LibRaw's unpack_thumb for a RAW, which is a seek and a copy where a develop is a full decode of every pixel, and off createImageBitmap for anything else. A RAW with no preview inside it gets a placeholder tile rather than a minute of decoding per file. node scripts/library-check.mjs ok both frames indexed as tiles — 2 tiles from P1010256.JPG + P1010256.RW2 ok thumbnail for P1010256.JPG — 40400 bytes, jpeg=true ok thumbnail for P1010256.RW2 — 39895 bytes, jpeg=true (LibRaw preview) ok studio developed the frame from its handle ok the address was handed back — url=/app (no ?lib= left behind) ok the look was filed back against the frame — baseFilter=none, 19 knobs ok the tile says the frame is edited
239 lines
8.4 KiB
TypeScript
239 lines
8.4 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { useI18n, LANGS } from '../i18n/I18nProvider';
|
|
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from '../theme/ThemeProvider';
|
|
import { Avatar } from './Avatar';
|
|
import type { User } from '../api';
|
|
|
|
// Header: the app's TopBar minus everything camera-only, plus the two controls
|
|
// the web build adds — theme (light/dark + accent group) and language.
|
|
export function TopBar({
|
|
recipeName,
|
|
pro,
|
|
user,
|
|
exporting,
|
|
savingPhoto,
|
|
canUndo,
|
|
canRedo,
|
|
onUndo,
|
|
onRedo,
|
|
onReset,
|
|
onSavePhoto,
|
|
onExport,
|
|
photoCount,
|
|
photoMax,
|
|
onAuth,
|
|
onSignup,
|
|
onLogout,
|
|
}: {
|
|
recipeName: string;
|
|
// Saving into the account's own folder is the account's own act, so the
|
|
// button wears the same PRO marker the chips do until the address is proven.
|
|
pro: boolean;
|
|
user: User | null;
|
|
exporting: boolean;
|
|
savingPhoto: boolean;
|
|
canUndo: boolean;
|
|
canRedo: boolean;
|
|
onUndo: () => void;
|
|
onRedo: () => void;
|
|
onReset: () => void;
|
|
onSavePhoto: () => void;
|
|
onExport: () => void;
|
|
// This account's own folder: the SAVE PHOTO button states the count, and the
|
|
// API refuses past the cap. A guest sees 0/12 and is sent to sign in. null is
|
|
// the admin, who the API lets past the cap — the button then just counts.
|
|
photoCount: number;
|
|
photoMax: number | null;
|
|
onAuth: () => void;
|
|
onSignup: () => void;
|
|
onLogout: () => void;
|
|
}) {
|
|
const { t, lang, setLang } = useI18n();
|
|
const { mode, accent, fonts, setMode, setAccent, setFonts } = useTheme();
|
|
const [menu, setMenu] = useState<'theme' | 'lang' | 'account' | null>(null);
|
|
const wrap = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
if (!menu) return;
|
|
const away = (e: MouseEvent) => {
|
|
if (!wrap.current?.contains(e.target as Node)) setMenu(null);
|
|
};
|
|
document.addEventListener('mousedown', away);
|
|
return () => document.removeEventListener('mousedown', away);
|
|
}, [menu]);
|
|
|
|
return (
|
|
<header className="header" ref={wrap}>
|
|
<a className="brand" href="/">
|
|
<img src="/assets/RecipesCamIcon.png" alt="" width="26" height="26" />
|
|
<span>Recipes<em>Cam</em></span>
|
|
</a>
|
|
<b className="page-name">{t('nav.studio')}</b>
|
|
<span className="recipe-name">{recipeName}</span>
|
|
|
|
{/* Both arrows are one icon wide, so the pair reads as one control: the
|
|
counter-clockwise arrow steps back, the clockwise one forward. They sit
|
|
with the name they would take back — after the mark and the preset. */}
|
|
<button
|
|
type="button"
|
|
className="btn ghost icon"
|
|
data-key="undo"
|
|
disabled={!canUndo}
|
|
title={t('act.undo')}
|
|
aria-label={t('act.undo')}
|
|
onClick={onUndo}
|
|
>
|
|
↺
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn ghost icon"
|
|
data-key="redo"
|
|
disabled={!canRedo}
|
|
title={t('act.redo')}
|
|
aria-label={t('act.redo')}
|
|
onClick={onRedo}
|
|
>
|
|
↻
|
|
</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. */}
|
|
<a className="btn ghost" data-key="nav-library" href="/library">{t('nav.library')}</a>
|
|
<button type="button" className="btn ghost" onClick={onReset}>{t('act.reset')}</button>
|
|
<button
|
|
type="button"
|
|
className="btn ghost"
|
|
data-key="save-photo"
|
|
disabled={savingPhoto || exporting}
|
|
title={
|
|
photoMax !== null && photoCount >= photoMax ? t('save.photoFull', { max: photoMax }) : undefined
|
|
}
|
|
onClick={onSavePhoto}
|
|
>
|
|
{savingPhoto
|
|
? t('photo.uploading')
|
|
: photoMax === null
|
|
? t('save.photoNoMax', { n: photoCount })
|
|
: t('save.photo', { n: photoCount, max: photoMax })}
|
|
{!pro ? <span className="pro">PRO</span> : null}
|
|
</button>
|
|
<button type="button" className="btn primary" data-key="export-photo" disabled={exporting} onClick={onExport}>
|
|
{exporting ? t('act.exporting') : t('act.export')}
|
|
</button>
|
|
|
|
<div className="menu-wrap">
|
|
<button type="button" className="btn ghost" title={t('nav.theme')} onClick={() => setMenu(menu === 'theme' ? null : 'theme')}>
|
|
◐
|
|
</button>
|
|
{menu === 'theme' ? (
|
|
<div className="popover">
|
|
<div className="chip-row">
|
|
<button type="button" className={`chip${mode === 'light' ? ' on' : ''}`} onClick={() => setMode('light')}>Light</button>
|
|
<button type="button" className={`chip${mode === 'dark' ? ' on' : ''}`} onClick={() => setMode('dark')}>Dark</button>
|
|
<button type="button" className={`chip${mode === 'auto' ? ' on' : ''}`} data-key="theme-auto" onClick={() => setMode('auto')}>Auto</button>
|
|
</div>
|
|
<div className="chip-row" style={{ marginTop: 8 }}>
|
|
{ACCENTS.map((entry) => (
|
|
<button
|
|
key={entry.id}
|
|
type="button"
|
|
className={`chip${accent === entry.id ? ' on' : ''}`}
|
|
onClick={() => setAccent(entry.id)}
|
|
>
|
|
{entry.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{/* A dropdown keeps the group compact — the labels are long and the
|
|
current choice is the only one that needs to be visible. */}
|
|
<select
|
|
className="chip font-select"
|
|
data-key="fonts"
|
|
title={FONT_SETS.find((f) => f.id === fonts)?.note}
|
|
value={fonts}
|
|
onChange={(e) => setFonts(e.target.value as FontSetId)}
|
|
>
|
|
{FONT_SETS.map((entry) => (
|
|
<option key={entry.id} value={entry.id} title={entry.note}>{entry.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="menu-wrap">
|
|
<button type="button" className="btn ghost" title={t('nav.language')} onClick={() => setMenu(menu === 'lang' ? null : 'lang')}>
|
|
{lang.toUpperCase()}
|
|
</button>
|
|
{menu === 'lang' ? (
|
|
<div className="popover">
|
|
{LANGS.map((entry) => (
|
|
<button
|
|
key={entry.id}
|
|
type="button"
|
|
className={`chip${lang === entry.id ? ' on' : ''}`}
|
|
onClick={() => {
|
|
setLang(entry.id);
|
|
setMenu(null);
|
|
}}
|
|
>
|
|
{entry.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="menu-wrap">
|
|
<button
|
|
type="button"
|
|
className="btn ghost account-btn"
|
|
data-key="nav-account"
|
|
aria-expanded={menu === 'account'}
|
|
onClick={() => setMenu(menu === 'account' ? null : 'account')}
|
|
>
|
|
{user ? (
|
|
<>
|
|
<Avatar user={user} small />
|
|
{user.email.split('@')[0]}
|
|
</>
|
|
) : (
|
|
t('nav.guest')
|
|
)}
|
|
</button>
|
|
{menu === 'account' ? (
|
|
<div className="popover">
|
|
{user ? (
|
|
<>
|
|
<p className="hint">{t('auth.loggedInAs', { email: user.email })}</p>
|
|
{/* The account control opens the one page that account can act
|
|
on: moderation for an admin, its own profile for a member. */}
|
|
{user.admin ? (
|
|
<a className="btn" data-key="nav-admin" href="/admin">{t('nav.admin')}</a>
|
|
) : (
|
|
<>
|
|
<a className="btn" data-key="nav-photos" href="/photos">{t('nav.photos')}</a>
|
|
<a className="btn" data-key="nav-profile" href="/profile">{t('nav.profile')}</a>
|
|
</>
|
|
)}
|
|
<button type="button" className="btn" data-key="nav-logout" onClick={onLogout}>{t('nav.logout')}</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button type="button" className="btn primary" data-key="nav-login" onClick={onAuth}>
|
|
{t('nav.login')}
|
|
</button>
|
|
<button type="button" className="btn" data-key="nav-signup" onClick={onSignup}>
|
|
{t('nav.signup')}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|