362 lines
15 KiB
TypeScript
362 lines
15 KiB
TypeScript
import { useEffect, useReducer, useRef, useState } from 'react';
|
|
import { useI18n, LANGS } from '../i18n/I18nProvider';
|
|
import { ACCENTS, FONT_SETS, useTheme, type FontSetId } from '../theme/ThemeProvider';
|
|
import { scanSession, watchScan } from '../engine/library';
|
|
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,
|
|
hasImmich,
|
|
uploadingImmich,
|
|
onUploadImmich,
|
|
}: {
|
|
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;
|
|
hasImmich?: boolean;
|
|
uploadingImmich?: boolean;
|
|
onUploadImmich?: () => void;
|
|
}) {
|
|
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
|
|
// header says so while it does.
|
|
const live = scanSession();
|
|
const [, bump] = useReducer((n: number) => n + 1, 0);
|
|
useEffect(() => {
|
|
const unwatch = watchScan(() => bump());
|
|
bump(); // a scan already running when the studio comes up
|
|
return unwatch;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!menu && !bar) return;
|
|
const away = (e: MouseEvent) => {
|
|
if (wrap.current?.contains(e.target as Node)) return;
|
|
setMenu(null);
|
|
setBar(false);
|
|
};
|
|
document.addEventListener('mousedown', away);
|
|
return () => document.removeEventListener('mousedown', away);
|
|
}, [menu, bar]);
|
|
|
|
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>
|
|
|
|
{/* 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 ghost icon"
|
|
data-key="undo"
|
|
disabled={!canUndo}
|
|
title={t('act.undo')}
|
|
aria-label={t('act.undo')}
|
|
onClick={onUndo}
|
|
>
|
|
<span aria-hidden="true">↺</span>
|
|
<span className="mob-label">{t('act.undo')}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn ghost icon"
|
|
data-key="redo"
|
|
disabled={!canRedo}
|
|
title={t('act.redo')}
|
|
aria-label={t('act.redo')}
|
|
onClick={onRedo}
|
|
>
|
|
<span aria-hidden="true">↻</span>
|
|
<span className="mob-label">{t('act.redo')}</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>
|
|
) : 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${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">
|
|
{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>
|
|
{/* 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>
|
|
);
|
|
}
|