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
+1 -36
View File
@@ -505,12 +505,6 @@ export function Workspace() {
const [flipH, setFlipH] = useState(() => session.flipH ?? false);
const [flipV, setFlipV] = useState(() => session.flipV ?? false);
const [openParam, setOpenParam] = useState<string | null>(null);
// A finger on the photo, moving the open ruler. The look it is moving is not
// rendered while the hand is down: a develop and a JPEG is the one thing here
// that costs more than a frame, and a render landing between two moves of a
// drag is the drag the reader feels. The number the finger stops on is applied
// when it lifts, and the render follows it then — one for the whole gesture.
const [rulerDragging, setRulerDragging] = useState(false);
// The HSL mixer: which hue band the ruler edits, whether the eyedropper is
// armed, and the last colour it read off the photo.
const [hslBand, setHslBand] = useState<HslBandId>('red');
@@ -1054,11 +1048,6 @@ export function Workspace() {
// recipe, so a preview behind it would only compete for the same renderer —
// and the user is watching the live one. It comes back with the camera.
if (!engine || !previewBytes || shooting) return;
// A hand on the photo is a drag of the open ruler, and a drag is not a
// moment to render: the value it lands on comes with the finger's own lift,
// and this effect runs again then (rulerDragging is a dependency) — so one
// render for the whole gesture instead of one in the middle of it.
if (rulerDragging) return;
const token = ++renderToken.current;
const timer = setTimeout(async () => {
setBusy(t('act.exporting'));
@@ -1118,7 +1107,7 @@ export function Workspace() {
}, PREVIEW_DEBOUNCE_MS);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [engine, previewBytes, recipe, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting, rulerDragging]);
}, [engine, previewBytes, recipe, preview, frameId, cropEditing, cropLive, cropRect, rotation, straighten, flipH, flipV, useGeotag, gpsInfo, gpsStyle, markOn, markText, markStyle, comparing, shooting]);
// The source copy behind previewBytes, re-made only when previewMax moves:
// applying a crop upgrades it, CANCEL/UNDO brings the 1600px one back.
@@ -1455,9 +1444,6 @@ export function Workspace() {
const sliderEditRef = useRef(false);
useEffect(() => {
sliderEditRef.current = false;
// A row that is no longer the open one is not being held: whatever gesture
// was moving it ended with it.
setRulerDragging(false);
}, [openParam]);
// The watermark's drag box rides the same one-edit-per-gesture rule as a knob:
// a whole drag is one undo step, not one per pointer move. The ref clears
@@ -4734,27 +4720,6 @@ export function Workspace() {
canCompare={!!source}
onSaveOriginal={() => void saveOriginal()}
onClear={() => setClearing(true)}
// The open ruler, handed to the stage as the track the photo itself
// becomes: a chip's ruler stands at the foot of a phone's screen, and
// the corner being judged is at the top of it, so a finger dragged
// across the picture moves the knob instead. STRAIGHTEN and TONE CURVE
// open no row (see `slider`), and neither hands anything over.
rulerDrag={
slider
? {
value: slider.value,
min: slider.min,
max: slider.max,
step: slider.step,
onChange: slider.onChange,
// The gesture's two edges: while the hand is on the photo the
// look is not rendered, and the number it stops on is the one
// that lands (see `rulerDragging`).
onStart: () => setRulerDragging(true),
onEnd: () => setRulerDragging(false),
}
: null
}
picking={picking}
onPickColor={pickColor}
brushTool={brushTool}
+1
View File
@@ -13,6 +13,7 @@ export const en: Dict = {
// The phone's way back from a tab's strip to the rail that opened it.
'nav.tabs': 'TABS',
'nav.back': 'Back to home',
'nav.menu': 'MENU',
'nav.guest': 'Guest',
// The install offer (see vi.ts).
+3
View File
@@ -18,6 +18,9 @@ export const vi = {
'nav.admin': 'Quản trị',
'nav.photos': 'Ảnh của tôi',
'nav.profile': 'Hồ sơ',
// The phone's one button in the header's corner: every control the bar
// carries, folded into a panel under it (see TopBar).
'nav.menu': 'MENU',
'nav.library': 'THƯ VIỆN',
'nav.studio': 'Studio',
'nav.guest': 'Khách',
+60
View File
@@ -36,6 +36,15 @@
white-space: nowrap;
}
.menu-wrap { position: relative; }
/* The bar's controls in one box. A wide screen has the room for every one of
them, so the box is not there at all — `display: contents` leaves them as
children of the row — and the phone, which has not, gets the panel under the
hamburger (see the narrow-screen rules below). */
.bar-menu { display: contents; }
.bar-toggle { display: none; }
/* The word a button keeps for that panel: the icon-only rules below take the
words out of the row, and the panel is where they are read again. */
.mob-label { display: none; }
.workspace { display: flex; flex: 1 1 auto; min-height: 0; }
@@ -1906,6 +1915,57 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track-
.header { position: sticky; top: 0; z-index: 5; }
.menu-wrap { position: static; }
.popover { right: 8px; max-width: calc(100vw - 16px); }
/* --- the phone's bar: one line and one panel -----------------------------
The row of controls is what wraps the bar onto a second and a third line
here, so it is taken out of the row and hung under a hamburger in the
corner: brand, page name, the button — and every control one tap away,
each wearing the name the icon-only rules below took from it. */
.bar-menu { display: none; }
.bar-menu.open {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 6;
padding: 8px 12px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border-soft);
box-shadow: 0 10px 18px rgba(0, 0, 0, 0.28);
}
/* Nothing for the spacer to push against in a panel. */
.bar-menu.open .spacer { display: none; }
.bar-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
font-size: 15px;
line-height: 1.2;
padding: 4px 10px;
}
/* The names come back. LIBRARY, RESET, SAVE PHOTO, EXPORT and the Immich
button wear their word as text at font-size 0 in the row; the panel draws
it, and their ::before glyph comes along as the mark beside it. */
.bar-menu.open [data-key="nav-library"],
.bar-menu.open [data-key="reset-look"],
.bar-menu.open [data-key="save-photo"],
.bar-menu.open [data-key="export-photo"],
.bar-menu.open [data-key="upload-immich"] {
width: auto;
height: auto;
padding: 7px 10px;
gap: 6px;
font-size: 12px;
}
.bar-menu.open [data-key="upload-immich"] > span { display: inline; }
/* The count is only half of what SAVE PHOTO says; the PRO marker rides with
the word it belongs to. */
.bar-menu.open [data-key="save-photo"] .pro { display: inline; }
.bar-menu.open .mob-label { display: inline; margin-left: 6px; font-size: 12px; letter-spacing: 0.06em; }
/* Four labelled buttons — LIBRARY, RESET, SAVE PHOTO, EXPORT — are most of what
wraps the bar onto a third row on a 390px screen: the row's own content is
990px wide with the words and 716px with icons, and the bar measured 135px
+3 -156
View File
@@ -60,41 +60,6 @@ export type WmMark = {
onMove: (p: { x?: number; y?: number; size?: number }) => void;
};
// A ruler that is open while the photo is under the hand. A phone has no wheel
// to notch and the row the ruler stands in is off at the foot of the screen,
// away from the thing being judged: with one of these in hand, the photo itself
// is the track — a finger dragged across it moves the value the same distance
// the drag covers, so the number can be found on the picture it is changing.
// The range travels the whole width, the same travel the row's own slider has.
export type RulerDrag = {
value: number;
min: number;
max: number;
step: number;
onChange: (value: number) => void;
// The gesture's own two edges, and what they are for: rendering the look is
// the one thing here that costs more than a frame — a full develop and a JPEG
// — so a hand on the photo holds it back (see PREVIEW_DEBOUNCE_MS in App) and
// the number the finger stops on is the one that lands, instead of a render
// standing between two moves of a drag that is not finished.
onStart: () => void;
onEnd: () => void;
};
// The ruler's own drag while it is in hand: which finger started it, where it
// began, the width the stage was when it did, what has already been applied,
// and the value still waiting for the frame it will be applied on.
type RulerHold = {
id: number;
x: number;
value: number;
width: number;
last: number;
started: boolean;
pending: number | null;
raf: number;
};
// One mark's box, from the numbers the engine draws it with. The width of the
// text is the face's own when the mark carries a family (measureMark), and the
// monospace em above otherwise; the ascent/descent come back with it, because
@@ -305,7 +270,6 @@ export function ImageStage({
marks = [],
straightenRuler = null,
curvePanel = null,
rulerDrag = null,
// FX's brush: the tool that takes the pointer on the photo, and the size a
// spot is laid down at. One spot is a circle — HEAL's the area it fills,
// MOSAIC's the cell it hides. Either way they are fractions of the render,
@@ -387,13 +351,6 @@ export function ImageStage({
// rather than in a column beside it. It is anchored, not draggable — the graph
// is a fixed square and the photo moves under it.
curvePanel?: ReactNode;
// The ruler a chip has open, when one is. While it is here a finger dragged
// across the photo moves that knob instead of the view — the value travels
// with the hand's own direction, right for up the scale and left for down it
// — and two fingers are still the phone's own zoom, because a pinch is not a
// value (see the listeners on the stage's box). Null leaves the photo's
// gestures exactly as they were.
rulerDrag?: RulerDrag | null;
// FX's brush, HEAL or MOSAIC: the circle under the cursor is the size of the
// spot a gesture lays down (the wheel sets it), and one gesture reports its
// spots together — one gesture is one undo step however many spots it laid.
@@ -1041,24 +998,8 @@ export function ImageStage({
// the pinch is what two fingers mean, and what is left of the stroke is not
// laid down (the pointer releases the stroke rather than repainting it with
// the finger after).
//
// The same listeners carry the ruler: a chip's ruler stands in a row at the
// foot of a phone's screen, which is a long way from the corner a reader is
// looking at, and one finger is not spoken for while a ruler is open (opening
// one puts FIX's brush and the mask tool down). So one finger there is that
// ruler's track and two are still the zoom — the pinch wins the moment its
// second finger lands.
const fingersRef = useRef(new Map<number, { x: number; y: number }>());
const pinchRef = useRef<{ d: number; s: number; px: number; py: number } | null>(null);
// The open ruler, read through a ref so the listener below is bound once per
// stage rather than once per render: the value it is on belongs to the parent
// and changes on every move of the very drag it is measuring.
const rulerDragRef = useRef(rulerDrag);
rulerDragRef.current = rulerDrag;
// The ruler's own drag, in hand. Read against its own beginning rather than
// against the last move, so a step the pointer snaps to is never counted twice
// and the number under the finger never drifts away from it.
const rulerRef = useRef<RulerHold | null>(null);
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
@@ -1075,66 +1016,12 @@ export function ImageStage({
d: Math.hypot(two[0].x - two[1].x, two[0].y - two[1].y),
};
};
// One value, applied through the ruler's own setter, and the app told the
// first time one lands that the photo is in hand and its render can wait.
const applyRuler = (r: RulerHold, v: number) => {
const d = rulerDragRef.current;
if (!d || v === r.last) return;
r.last = v;
if (!r.started) {
r.started = true;
d.onStart();
}
d.onChange(v);
};
// Letting the ruler go: the finger lifted, the touch was cancelled, or a
// second finger turned the gesture into the zoom. Whatever the last frame
// had not applied goes in here (flush), the app is told it may render again,
// and the hand is emptied.
const dropRuler = (flush: boolean) => {
const r = rulerRef.current;
if (!r) return;
if (r.raf) {
cancelAnimationFrame(r.raf);
r.raf = 0;
}
if (flush && r.pending !== null) applyRuler(r, r.pending);
if (r.started) rulerDragRef.current?.onEnd();
rulerRef.current = null;
};
const down = (e: PointerEvent) => {
if (e.pointerType !== 'touch') return;
fingersRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
// The first finger is the open ruler's, when there is one: where it went
// down, what the knob read at that moment, and how wide the stage was —
// measured once, because the box does not move under a drag and measuring
// it again on every move would lay the document out between two frames of
// the gesture being read. Nothing is changed yet: a tap on the photo is
// not a move of the number.
const r = rulerDragRef.current;
if (fingersRef.current.size === 1) {
if (r) {
const rect = wrap.getBoundingClientRect();
if (rect.width > 0 && r.max > r.min) {
rulerRef.current = {
id: e.pointerId,
x: e.clientX,
value: r.value,
width: rect.width,
last: r.value,
started: false,
pending: null,
raf: 0,
};
}
}
return;
}
// One finger is the photo's own drag — a pan, a stroke, a repair in hand
// (see `startPan` and the brush's layer). Two are this: the zoom.
if (fingersRef.current.size !== 2) return;
// The second finger is the zoom, and a pinch is not a value: whatever the
// first finger had hold of is let go the moment this one lands, and the
// look renders again — the zoom is what is being looked at now.
dropRuler(false);
const m = pinch();
if (!m || !(m.d > 0)) return;
// The photo's own point under the fingers, and the size the stage is at:
@@ -1150,33 +1037,7 @@ export function ImageStage({
finger.y = e.clientY;
}
const p = pinchRef.current;
if (!p) {
// One finger, no pinch: the ruler's own drag. The photo's width is the
// track — the range travels it end to end, the same travel the row's
// slider has — and the value is read from where this drag began, so a
// hand that comes back the way it went lands on the number it left.
const r = rulerRef.current;
if (!r || r.id !== e.pointerId || !rulerDragRef.current) return;
e.preventDefault();
const d = rulerDragRef.current;
const step = d.step > 0 ? d.step : 1;
const raw = r.value + ((e.clientX - r.x) / r.width) * (d.max - d.min);
r.pending = Math.min(d.max, Math.max(d.min, Number((Math.round(raw / step) * step).toFixed(4))));
// One value a frame, not one a move: a finger reports more moves than a
// screen has frames, and what is applied is whatever the last of them
// said — the row is re-rendered once for it instead of once each.
if (!r.raf) {
r.raf = requestAnimationFrame(() => {
const held = rulerRef.current;
if (!held) return;
held.raf = 0;
const v = held.pending;
held.pending = null;
if (v !== null) applyRuler(held, v);
});
}
return;
}
if (!p) return;
// What the first finger had begun — a stroke, a repair in hand, a pan — is
// dropped here rather than at the second `pointerdown`, because the
// brush's own handler on that layer runs after this listener does.
@@ -1192,10 +1053,6 @@ export function ImageStage({
setView({ s, ...clampPan(s, m.x - s * p.px, m.y - s * p.py) });
};
const up = (e: PointerEvent) => {
// The finger that was holding the ruler lets it go here, with whatever the
// last frame had not applied: the number the hand stopped on lands, and
// the render it was holding back follows it in the same step.
if (rulerRef.current?.id === e.pointerId) dropRuler(true);
fingersRef.current.delete(e.pointerId);
// One finger left is not a pinch: the stage stays where the gesture put it
// and the finger that is left paints nothing until it is lifted and put
@@ -1213,12 +1070,6 @@ export function ImageStage({
wrap.removeEventListener('pointercancel', up);
fingersRef.current.clear();
pinchRef.current = null;
// The listener is re-bound whenever a preview lands, and a face on the
// photo is not the end of a gesture: the ruler's hold is kept unless the
// box itself has gone (a frame replaced, a photo cleared) — where the app
// would otherwise be left holding its render back for a hand that is no
// longer on anything.
if (!wrap.isConnected) dropRuler(false);
};
// `previewUrl` because the wrap is only on the stage once a frame is: the
// listener has to follow the node it is bound to.
@@ -1480,10 +1331,6 @@ export function ImageStage({
const startPan = (e: React.PointerEvent<HTMLImageElement>) => {
if (cropEditing || compare || e.button !== 0) return;
// A ruler that is open owns the finger: dragging the photo is moving that
// knob, which is the same drag the listener on the wrap reads (see above).
// Two fingers still pan and zoom, so the view is never out of reach.
if (rulerDrag && e.pointerType === 'touch') return;
if (frameId === 'custom' && onCustomFramePan) {
e.preventDefault();
e.stopPropagation();
+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>
);