Files
RecipesCam/docker/frontend/src/ui/TopBar.tsx
T

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>
);
}