feat(studio): a phone's header folds into one button, the open ruler no longer rides the photo

This commit is contained in:
2026-10-13 23:35:44 +07:00
parent 522e4e9ee4
commit e31074be4a
7 changed files with 374 additions and 490 deletions
+248 -205
View File
@@ -58,6 +58,11 @@ export function TopBar({
const { t, lang, setLang } = useI18n();
const { mode, accent, fonts, setMode, setAccent, setFonts } = useTheme();
const [menu, setMenu] = useState<'theme' | 'lang' | 'account' | null>(null);
// The phone's one button: on a narrow screen the row of controls is the whole
// of a second and a third line, so it folds away into a panel under the
// hamburger in the corner (see .bar-menu in app.css). A wide screen never
// opens it — there the panel is the row itself.
const [bar, setBar] = useState(false);
const wrap = useRef<HTMLDivElement>(null);
// The catalogue's own reading runs in this tab whatever screen is up, so the
@@ -71,13 +76,15 @@ export function TopBar({
}, []);
useEffect(() => {
if (!menu) return;
if (!menu && !bar) return;
const away = (e: MouseEvent) => {
if (!wrap.current?.contains(e.target as Node)) setMenu(null);
if (wrap.current?.contains(e.target as Node)) return;
setMenu(null);
setBar(false);
};
document.addEventListener('mousedown', away);
return () => document.removeEventListener('mousedown', away);
}, [menu]);
}, [menu, bar]);
return (
<header className="header" ref={wrap}>
@@ -88,230 +95,266 @@ export function TopBar({
<b className="page-name">{t('nav.studio')}</b>
<span className="recipe-name">{recipeName}</span>
{/* First of the controls, on purpose: sending the frame to Immich Cloud is
what the phone's header puts at the head of its row, and it is the one
control here that hands the photo to another box. Drawn only once the
account has a server to send to. The pinwheel is the label — the word
beside it is a desktop convenience the phone drops (see app.css). */}
{hasImmich && onUploadImmich ? (
{/* Everything the bar carries, in one box so a phone can fold it away:
see `.bar-menu` — `display: contents` wherever the row has the room
for the buttons, a panel under the hamburger where it has not. */}
{/* A menu of the bar's own (theme, language, account) draws its popover
from inside this box, so the box is held open while one is: the popover
would be a child of a box that is not on the screen. */}
<div
className={`bar-menu${bar || menu ? ' open' : ''}`}
data-key="bar-menu"
onClick={() => setBar(false)}
>
{/* First of the controls, on purpose: sending the frame to Immich Cloud is
what the phone's header puts at the head of its row, and it is the one
control here that hands the photo to another box. Drawn only once the
account has a server to send to. The pinwheel is the label — the word
beside it is a desktop convenience the phone drops (see app.css). */}
{hasImmich && onUploadImmich ? (
<button
type="button"
className="btn"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
background: 'linear-gradient(135deg, rgba(66, 133, 244, 0.15), rgba(52, 168, 83, 0.15))',
borderColor: '#4285f4',
color: '#e4e4e7',
}}
data-key="upload-immich"
disabled={uploadingImmich || exporting}
onClick={onUploadImmich}
title={t('immich.uploadTitle') || 'Upload lên Immich Cloud'}
>
{/* Immich Pinwheel Logo */}
<svg width="18" height="18" viewBox="0 0 100 100" fill="none" style={{ flexShrink: 0 }}>
<path d="M50 50 C50 25, 75 10, 90 25 C105 40, 90 65, 65 65 Z" fill="#ea4335" />
<path d="M50 50 C75 50, 90 75, 75 90 C60 105, 35 90, 35 65 Z" fill="#fbbc05" />
<path d="M50 50 C50 75, 25 90, 10 75 C-5 60, 10 35, 35 35 Z" fill="#34a853" />
<path d="M50 50 C25 50, 10 25, 25 10 C40 -5, 65 10, 65 35 Z" fill="#4285f4" />
</svg>
<span>{uploadingImmich ? (t('immich.uploading') || 'Đang tải lên...') : 'Immich'}</span>
</button>
) : null}
{/* 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"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
background: 'linear-gradient(135deg, rgba(66, 133, 244, 0.15), rgba(52, 168, 83, 0.15))',
borderColor: '#4285f4',
color: '#e4e4e7',
}}
data-key="upload-immich"
disabled={uploadingImmich || exporting}
onClick={onUploadImmich}
title={t('immich.uploadTitle') || 'Upload lên Immich Cloud'}
className="btn ghost icon"
data-key="undo"
disabled={!canUndo}
title={t('act.undo')}
aria-label={t('act.undo')}
onClick={onUndo}
>
{/* Immich Pinwheel Logo */}
<svg width="18" height="18" viewBox="0 0 100 100" fill="none" style={{ flexShrink: 0 }}>
<path d="M50 50 C50 25, 75 10, 90 25 C105 40, 90 65, 65 65 Z" fill="#ea4335" />
<path d="M50 50 C75 50, 90 75, 75 90 C60 105, 35 90, 35 65 Z" fill="#fbbc05" />
<path d="M50 50 C50 75, 25 90, 10 75 C-5 60, 10 35, 35 35 Z" fill="#34a853" />
<path d="M50 50 C25 50, 10 25, 25 10 C40 -5, 65 10, 65 35 Z" fill="#4285f4" />
</svg>
<span>{uploadingImmich ? (t('immich.uploading') || 'Đang tải lên...') : 'Immich'}</span>
<span aria-hidden="true">↺</span>
<span className="mob-label">{t('act.undo')}</span>
</button>
) : null}
{/* 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'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"
data-key="studio-scan"
title={t('lib.scanning', {
done: live.progress.done,
total: live.progress.total,
added: live.progress.added,
})}
<button
type="button"
className="btn ghost icon"
data-key="redo"
disabled={!canRedo}
title={t('act.redo')}
aria-label={t('act.redo')}
onClick={onRedo}
>
<span className="lib-spin" aria-hidden="true" />
<span data-key="studio-scan-count">{`${live.progress.done}/${live.progress.total}`}</span>
</span>
) : null}
{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"
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>
<div className="menu-wrap">
<button type="button" className="btn ghost" title={t('nav.theme')} onClick={() => setMenu(menu === 'theme' ? null : 'theme')}>
◐
<span aria-hidden="true">↻</span>
<span className="mob-label">{t('act.redo')}</span>
</button>
{menu === 'theme' ? (
<div className="popover">
<div className="chip-row">
{/* The name is a span, not a bare text node: a chip is a flex row,
and a loose text node in one is an anonymous item — the shape
iOS stops painting until the page is touched (same as the
panel heads and ChipRow's chips). */}
<button type="button" className={`chip${mode === 'light' ? ' on' : ''}`} onClick={() => setMode('light')}><span className="chip-label">Light</span></button>
<button type="button" className={`chip${mode === 'dark' ? ' on' : ''}`} onClick={() => setMode('dark')}><span className="chip-label">Dark</span></button>
<button type="button" className={`chip${mode === 'auto' ? ' on' : ''}`} data-key="theme-auto" onClick={() => setMode('auto')}><span className="chip-label">Auto</span></button>
<span className="spacer" />
{/* 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"
data-key="studio-scan"
title={t('lib.scanning', {
done: live.progress.done,
total: live.progress.total,
added: live.progress.added,
})}
>
<span className="lib-spin" aria-hidden="true" />
<span data-key="studio-scan-count">{`${live.progress.done}/${live.progress.total}`}</span>
</span>
) : null}
{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"
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>
<div className="menu-wrap">
<button type="button" className="btn ghost" title={t('nav.theme')} onClick={() => setMenu(menu === 'theme' ? null : 'theme')}>
<span aria-hidden="true">◐</span>
<span className="mob-label">{t('nav.theme')}</span>
</button>
{menu === 'theme' ? (
<div className="popover">
<div className="chip-row">
{/* The name is a span, not a bare text node: a chip is a flex row,
and a loose text node in one is an anonymous item — the shape
iOS stops painting until the page is touched (same as the
panel heads and ChipRow's chips). */}
<button type="button" className={`chip${mode === 'light' ? ' on' : ''}`} onClick={() => setMode('light')}><span className="chip-label">Light</span></button>
<button type="button" className={`chip${mode === 'dark' ? ' on' : ''}`} onClick={() => setMode('dark')}><span className="chip-label">Dark</span></button>
<button type="button" className={`chip${mode === 'auto' ? ' on' : ''}`} data-key="theme-auto" onClick={() => setMode('auto')}><span className="chip-label">Auto</span></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)}
>
<span className="chip-label">{entry.label}</span>
</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>
<div className="chip-row" style={{ marginTop: 8 }}>
{ACCENTS.map((entry) => (
) : null}
</div>
<div className="menu-wrap">
<button type="button" className="btn ghost" title={t('nav.language')} onClick={() => setMenu(menu === 'lang' ? null : 'lang')}>
<span aria-hidden="true">{lang.toUpperCase()}</span>
<span className="mob-label">{t('nav.language')}</span>
</button>
{menu === 'lang' ? (
<div className="popover">
{LANGS.map((entry) => (
<button
key={entry.id}
type="button"
className={`chip${accent === entry.id ? ' on' : ''}`}
onClick={() => setAccent(entry.id)}
className={`chip${lang === entry.id ? ' on' : ''}`}
onClick={() => {
setLang(entry.id);
setMenu(null);
}}
>
<span className="chip-label">{entry.label}</span>
</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>
) : 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);
}}
>
<span className="chip-label">{entry.label}</span>
</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">
<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 ? (
<>
<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>
<Avatar user={user} small />
{user.email.split('@')[0]}
</>
) : (
<>
<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>
</>
t('nav.guest')
)}
</div>
) : null}
</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>
{/* Last in the row on purpose: EXPORT is the end of the job, so it sits at
the end of the bar — the one button the eye lands on when the edit is
done. */}
<button type="button" className="btn primary" data-key="export-photo" disabled={exporting || uploadingImmich} onClick={onExport}>
{exporting ? t('act.exporting') : t('act.export')}
</button>
</div>
{/* Last in the row on purpose: EXPORT is the end of the job, so it sits at
the end of the bar — the one button the eye lands on when the edit is
done. */}
<button type="button" className="btn primary" data-key="export-photo" disabled={exporting || uploadingImmich} onClick={onExport}>
{exporting ? t('act.exporting') : t('act.export')}
{/* The corner the panel hangs from, and only on a phone: a wide screen
draws the row itself instead (see .bar-toggle). */}
<button
type="button"
className="btn ghost bar-toggle"
data-key="bar-toggle"
aria-expanded={bar || menu !== null}
aria-label={t('nav.menu')}
title={t('nav.menu')}
onClick={() => {
// The panel goes away when the button is pressed, and the popover a
// menu of the panel's own may have left up goes with it: the button
// is the whole of how a phone puts the bar away.
const showing = bar || menu !== null;
setBar(!showing);
if (showing) setMenu(null);
}}
>
<span aria-hidden="true">{bar || menu !== null ? '✕' : '☰'}</span>
</button>
</header>
);