feat(studio): a phone's header folds into one button, the open ruler no longer rides the photo
This commit is contained in:
+248
-205
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user