feat(studio): a phone's header folds into one button, the open ruler no longer rides the photo
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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