@djangocfg/widget-media 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,508 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ImageViewer - Image viewer with zoom, pan, rotate, flip, gallery navigation
5
+ *
6
+ * Two presentations share one engine (react-zoom-pan-pinch):
7
+ * - Embedded (default): checkerboard canvas + editing toolbar (zoom presets,
8
+ * rotate, flip, expand-to-fullscreen).
9
+ * - Lightbox (`lightbox` / `inDialog`): macOS Quick-Look-grade fullscreen —
10
+ * clean dark backdrop, idle-fading floating chrome, no rotate/flip slab.
11
+ *
12
+ * Gesture model (macOS-native):
13
+ * - Trackpad pinch arrives as wheel events with `ctrlKey=true`; the library's
14
+ * `smooth` path maps `|deltaY|` to the zoom magnitude so scale tracks the
15
+ * gesture 1:1. We raise `smoothStep` (see WHEEL_SMOOTH_STEP) so a real pinch
16
+ * reaches a meaningful zoom in one motion.
17
+ * - Zoom is anchored to the FOCAL POINT (cursor / pinch midpoint) — the point
18
+ * under the fingers stays put — handled natively by the library.
19
+ * - Pan by drag when zoomed; double-click/tap toggles fit ↔ zoom toward the
20
+ * point; keyboard +/-/0 and arrows (pan when zoomed, gallery nav at fit).
21
+ */
22
+
23
+ import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
24
+ import { ImageIcon, AlertCircle, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
25
+ import {
26
+ TransformWrapper,
27
+ TransformComponent,
28
+ type ReactZoomPanPinchRef,
29
+ } from 'react-zoom-pan-pinch';
30
+ import { cn, Dialog, DialogContent, DialogTitle, Alert, AlertDescription, Tooltip, TooltipContent, TooltipTrigger } from '@djangocfg/ui-core';
31
+ import { useMediaCopy } from '../../i18n';
32
+ import { useHotkey } from '@djangocfg/ui-core/hooks';
33
+
34
+ import { ImageToolbar } from './ImageToolbar';
35
+ import { ImageInfo } from './ImageInfo';
36
+ import { LightboxChrome } from './LightboxChrome';
37
+ import { useImageTransform, useImageLoading, useIdleChrome } from '../hooks';
38
+ import {
39
+ KEYBOARD_PAN_STEP,
40
+ MIN_ZOOM,
41
+ MAX_ZOOM,
42
+ WHEEL_SMOOTH_STEP,
43
+ PINCH_STEP,
44
+ DOUBLE_CLICK_STEP,
45
+ } from '../utils';
46
+ import type { ImageViewerProps } from '../types';
47
+
48
+ // =============================================================================
49
+ // COMPONENT
50
+ // =============================================================================
51
+
52
+ export function ImageViewer({
53
+ images,
54
+ initialIndex = 0,
55
+ inDialog = false,
56
+ lightbox = false,
57
+ onRequestClose,
58
+ autoFocus = false,
59
+ fitScale = 1,
60
+ copy,
61
+ }: ImageViewerProps) {
62
+ const labels = useMediaCopy(copy);
63
+
64
+ // `lightbox` implies the in-dialog presentation.
65
+ const isLightbox = lightbox || inDialog;
66
+
67
+ // Cap the at-rest image size to `fitScale` of the viewport in the lightbox so
68
+ // it opens with a margin instead of edge-to-edge. Clamp to a sane range; a
69
+ // value of 1 (the default) is a no-op (falls back to the max-w/h-full class).
70
+ const fit = Math.min(1, Math.max(0.1, fitScale));
71
+ const fitStyle: React.CSSProperties | undefined =
72
+ isLightbox && fit < 1
73
+ ? { maxWidth: `${fit * 100}%`, maxHeight: `${fit * 100}%` }
74
+ : undefined;
75
+
76
+ const [currentIndex, setCurrentIndex] = useState(() =>
77
+ Math.max(0, Math.min(initialIndex, images.length - 1))
78
+ );
79
+
80
+ // Reset index when initialIndex changes (e.g. opening different image)
81
+ useEffect(() => {
82
+ setCurrentIndex(Math.max(0, Math.min(initialIndex, images.length - 1)));
83
+ }, [initialIndex, images.length]);
84
+
85
+ const current = images[currentIndex];
86
+ const hasMultiple = images.length > 1;
87
+
88
+ const [scale, setScale] = useState(1);
89
+ const [dialogOpen, setDialogOpen] = useState(false);
90
+ const [loadError, setLoadError] = useState(false);
91
+ const [imgDecoded, setImgDecoded] = useState(false);
92
+ const containerRef = useRef<HTMLDivElement>(null);
93
+ const controlsRef = useRef<ReactZoomPanPinchRef | null>(null);
94
+ const imgRef = useRef<HTMLImageElement>(null);
95
+
96
+ // Idle-fading chrome (lightbox only). Pointer movement over the canvas keeps
97
+ // the controls visible; resting for CHROME_IDLE_MS fades them out.
98
+ const { active: chromeActive, onActivity, setPinned } = useIdleChrome(isLightbox);
99
+
100
+ const {
101
+ src,
102
+ lqip,
103
+ isFullyLoaded,
104
+ useProgressiveLoading,
105
+ error,
106
+ hasContent,
107
+ } = useImageLoading({
108
+ content: current?.content ?? '',
109
+ mimeType: current?.file.mimeType,
110
+ src: current?.src,
111
+ });
112
+
113
+ // Save the image the viewer RESOLVED, not the prop: `src` is already a blob
114
+ // URL when the caller passed bytes, so one anchor covers both a hosted URL
115
+ // and an in-memory image without branching on which arrived.
116
+ //
117
+ // A generated picture is served as /api/admin/attachments/<sha256> — a path
118
+ // with no extension — so the store's file name is the only source of a
119
+ // meaningful one, and the hash is a poor thing to hand a user's Downloads
120
+ // folder. Undefined without a src: a control that cannot act is worse absent.
121
+ // `downloadSrc` wins when the host supplied one: `download` is honoured only
122
+ // for a same-origin address, so a picture displayed from a CDN must be SAVED
123
+ // from wherever this page can serve the same bytes. The resolved `src` is the
124
+ // fallback and covers the in-memory case, where it is already a blob URL.
125
+ const saveFrom = current?.downloadSrc ?? src;
126
+ const handleDownload = useMemo(() => {
127
+ if (!saveFrom) return undefined;
128
+ return () => {
129
+ const link = document.createElement('a');
130
+ link.href = saveFrom;
131
+ link.download = current?.file.name ?? 'image';
132
+ link.rel = 'noopener';
133
+ document.body.appendChild(link);
134
+ link.click();
135
+ document.body.removeChild(link);
136
+ };
137
+ }, [saveFrom, current?.file.name]);
138
+
139
+ // Reset per-source load flags whenever the source changes.
140
+ //
141
+ // Race guard (first-open stuck on "Loading…"): the `<img onLoad>` handler is
142
+ // the ONLY thing that clears `imgDecoded`, but the browser can finish
143
+ // decoding — and fire `load` — BEFORE React commits the element and attaches
144
+ // that handler (a same-origin blob/attachment URL that resolves synchronously,
145
+ // or an already-cached image). The event is then missed and the spinner hangs
146
+ // forever; reopening "fixes" it only because the fresh mount happens to win
147
+ // the race. So after resetting, synchronously reconcile against the live img:
148
+ // if it's already `complete` with real pixels, mark it decoded now instead of
149
+ // waiting for an onLoad that already fired.
150
+ useEffect(() => {
151
+ setLoadError(false);
152
+ const img = imgRef.current;
153
+ if (img && img.complete && img.naturalWidth > 0) {
154
+ setImgDecoded(true);
155
+ } else {
156
+ setImgDecoded(false);
157
+ }
158
+ }, [src]);
159
+
160
+ const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({
161
+ resetKey: current?.file.path ?? '',
162
+ });
163
+
164
+ const handleZoomPreset = useCallback((value: number | 'fit') => {
165
+ const controls = controlsRef.current;
166
+ if (!controls) return;
167
+ if (value === 'fit') {
168
+ controls.resetTransform();
169
+ return;
170
+ }
171
+ // Anchor preset zoom to the canvas center instead of the top-left origin
172
+ const el = controls.instance.wrapperComponent;
173
+ if (el) {
174
+ const cx = el.offsetWidth / 2;
175
+ const cy = el.offsetHeight / 2;
176
+ const x = cx - cx * value;
177
+ const y = cy - cy * value;
178
+ controls.setTransform(x, y, value);
179
+ } else {
180
+ controls.setTransform(0, 0, value);
181
+ }
182
+ }, []);
183
+
184
+ const handleExpand = useCallback(() => setDialogOpen(true), []);
185
+
186
+ // Reset zoom/pan to fit whenever the gallery image changes — a fresh image
187
+ // should never inherit the previous one's pan offset.
188
+ const goTo = useCallback((next: number) => {
189
+ setCurrentIndex(next);
190
+ controlsRef.current?.resetTransform();
191
+ }, []);
192
+
193
+ const prev = useCallback(() =>
194
+ goTo((currentIndex - 1 + images.length) % images.length),
195
+ [goTo, currentIndex, images.length]
196
+ );
197
+
198
+ const next = useCallback(() =>
199
+ goTo((currentIndex + 1) % images.length),
200
+ [goTo, currentIndex, images.length]
201
+ );
202
+
203
+ // Pan the image by a fixed offset (arrow keys)
204
+ const panBy = useCallback((dx: number, dy: number) => {
205
+ const controls = controlsRef.current;
206
+ if (!controls) return false;
207
+ const { positionX, positionY, scale: s } = controls.instance.transformState;
208
+ // Only pan when the image is zoomed in — otherwise it cannot move
209
+ if (s <= 1) return false;
210
+ controls.setTransform(positionX + dx, positionY + dy, s);
211
+ return true;
212
+ }, []);
213
+
214
+ // Declarative autoFocus: focus the container once on mount. Pair with
215
+ // `key={src}` upstream for per-source focus reset.
216
+ useEffect(() => {
217
+ if (!autoFocus) return;
218
+ containerRef.current?.focus({ preventScroll: true });
219
+ }, [autoFocus]);
220
+
221
+ // Keyboard: zoom / rotate / pan (only when container focused)
222
+ useEffect(() => {
223
+ const handleKeyDown = (e: KeyboardEvent) => {
224
+ if (!containerRef.current?.contains(document.activeElement) &&
225
+ document.activeElement !== containerRef.current) return;
226
+ const controls = controlsRef.current;
227
+ if (!controls) return;
228
+ switch (e.key) {
229
+ case '+': case '=': e.preventDefault(); controls.zoomIn(); break;
230
+ case '-': case '_': e.preventDefault(); controls.zoomOut(); break;
231
+ case '0': e.preventDefault(); controls.resetTransform(); break;
232
+ case 'r': case 'R':
233
+ // Rotate is an editing affordance — embedded mode only.
234
+ if (!isLightbox && !e.metaKey && !e.ctrlKey) { e.preventDefault(); rotate(); }
235
+ break;
236
+ case 'Escape':
237
+ if (isLightbox && onRequestClose) { e.preventDefault(); onRequestClose(); }
238
+ break;
239
+ // Arrow keys pan only when zoomed in. While at fit scale they
240
+ // fall through to gallery navigation (handled by useHotkey).
241
+ case 'ArrowUp': if (panBy(0, KEYBOARD_PAN_STEP)) e.preventDefault(); break;
242
+ case 'ArrowDown': if (panBy(0, -KEYBOARD_PAN_STEP)) e.preventDefault(); break;
243
+ case 'ArrowLeft': if (panBy(KEYBOARD_PAN_STEP, 0)) e.preventDefault(); break;
244
+ case 'ArrowRight': if (panBy(-KEYBOARD_PAN_STEP, 0)) e.preventDefault(); break;
245
+ }
246
+ };
247
+ window.addEventListener('keydown', handleKeyDown);
248
+ return () => window.removeEventListener('keydown', handleKeyDown);
249
+ }, [rotate, panBy, isLightbox, onRequestClose]);
250
+
251
+ // Keyboard: gallery navigation (global, only while not zoomed in so it
252
+ // never competes with arrow-key panning)
253
+ useHotkey('ArrowLeft', prev, {
254
+ enabled: hasMultiple && scale <= 1,
255
+ preventDefault: true,
256
+ });
257
+ useHotkey('ArrowRight', next, {
258
+ enabled: hasMultiple && scale <= 1,
259
+ preventDefault: true,
260
+ });
261
+
262
+ if (!current) {
263
+ return (
264
+ <div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
265
+ <ImageIcon className="w-12 h-12 text-muted-foreground/50" />
266
+ <p className="text-sm text-muted-foreground">{labels.noImage}</p>
267
+ </div>
268
+ );
269
+ }
270
+
271
+ if (error || loadError) {
272
+ return (
273
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 bg-muted/30 p-4">
274
+ <AlertCircle className="w-12 h-12 text-destructive/70" />
275
+ <Alert variant="destructive" className="max-w-md">
276
+ <AlertCircle className="h-4 w-4" />
277
+ <AlertDescription>{error || labels.failedToLoad}</AlertDescription>
278
+ </Alert>
279
+ </div>
280
+ );
281
+ }
282
+
283
+ if (!hasContent) {
284
+ return (
285
+ <div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
286
+ <ImageIcon className="w-12 h-12 text-muted-foreground/50" />
287
+ <p className="text-sm text-muted-foreground">{labels.noImage}</p>
288
+ </div>
289
+ );
290
+ }
291
+
292
+ return (
293
+ <div
294
+ ref={containerRef}
295
+ tabIndex={0}
296
+ // `group`, not `img`: this element is the viewer's keyboard scope (zoom,
297
+ // rotate, gallery hotkeys) and in the lightbox it CONTAINS the save and
298
+ // close controls. `img` is a leaf role, so focusable descendants inside
299
+ // it are an a11y violation and a screen reader stops at the label without
300
+ // reaching the buttons. The picture itself is the inner <img>, which
301
+ // carries its own alt.
302
+ role="group"
303
+ aria-label={current.file.name}
304
+ onMouseMove={isLightbox ? onActivity : undefined}
305
+ className={cn(
306
+ 'flex-1 h-full relative overflow-hidden outline-none',
307
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
308
+ isLightbox
309
+ // Lightbox: a deep, slightly translucent vibrancy backdrop. The
310
+ // saturate+blur is the macOS Quick Look recipe — the page behind
311
+ // the dialog's scrim bleeds through softly so the image floats.
312
+ ? cn(
313
+ '[background-color:color-mix(in_oklab,#000_82%,transparent)]',
314
+ 'backdrop-blur-2xl backdrop-saturate-150',
315
+ )
316
+ // Embedded: the classic transparency checkerboard.
317
+ : cn(
318
+ 'bg-[length:16px_16px]',
319
+ '[background-color:color-mix(in_oklab,var(--muted)_20%,transparent)]',
320
+ '[background-image:linear-gradient(45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(-45deg,color-mix(in_oklab,var(--muted)_40%,transparent)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%),linear-gradient(-45deg,transparent_75%,color-mix(in_oklab,var(--muted)_40%,transparent)_75%)]',
321
+ '[background-position:0_0,0_8px,8px_-8px,-8px_0px]',
322
+ ),
323
+ )}
324
+ >
325
+ {/* Dimensions badge — embedded only (lightbox keeps chrome minimal). */}
326
+ {!isLightbox && src && <ImageInfo src={src} />}
327
+
328
+ {useProgressiveLoading && !isFullyLoaded && (
329
+ <div className="absolute top-3 left-3 z-10 px-2 py-1 bg-black/60 backdrop-blur-sm border border-white/10 rounded text-[10px] text-white/80 font-mono flex items-center gap-1.5">
330
+ <div className="w-2 h-2 bg-primary rounded-full animate-pulse" />
331
+ {labels.loading}
332
+ </div>
333
+ )}
334
+
335
+ {/* Spinner while a non-progressive image is still decoding */}
336
+ {!useProgressiveLoading && !imgDecoded && !loadError && (
337
+ <div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 pointer-events-none">
338
+ <Loader2 className="w-8 h-8 text-muted-foreground/60 animate-spin" />
339
+ <span className="text-xs text-muted-foreground">{labels.loading}</span>
340
+ </div>
341
+ )}
342
+
343
+ <TransformWrapper
344
+ initialScale={1}
345
+ minScale={MIN_ZOOM}
346
+ maxScale={MAX_ZOOM}
347
+ centerOnInit
348
+ centerZoomedOut
349
+ // `smooth` (default) makes the wheel/trackpad zoom magnitude scale with
350
+ // `|deltaY|`, i.e. 1:1 with the gesture. The raised `smoothStep` gives
351
+ // a macOS trackpad pinch real reach in a single motion (the library's
352
+ // 0.001 default felt dead). Focal-point anchoring is handled natively.
353
+ smooth
354
+ wheel={{ smoothStep: WHEEL_SMOOTH_STEP }}
355
+ pinch={{ step: PINCH_STEP }}
356
+ doubleClick={{ mode: 'toggle', step: DOUBLE_CLICK_STEP }}
357
+ panning={{ velocityDisabled: false }}
358
+ onTransformed={(ref, state) => {
359
+ controlsRef.current = ref;
360
+ // Avoid a re-render on every pan frame — only when zoom changes
361
+ setScale((prev) => (prev === state.scale ? prev : state.scale));
362
+ }}
363
+ onInit={(ref) => { controlsRef.current = ref; }}
364
+ >
365
+ {/* Embedded editing toolbar (rotate/flip/expand) — not in the lightbox. */}
366
+ {!isLightbox && (
367
+ <ImageToolbar
368
+ scale={scale}
369
+ transform={transform}
370
+ onRotate={rotate}
371
+ onFlipH={flipH}
372
+ onFlipV={flipV}
373
+ onZoomPreset={handleZoomPreset}
374
+ onExpand={!inDialog ? handleExpand : undefined}
375
+ />
376
+ )}
377
+
378
+ <TransformComponent
379
+ wrapperClass="!w-full !h-full cursor-grab active:cursor-grabbing"
380
+ contentClass="!w-full !h-full flex items-center justify-center"
381
+ >
382
+ {/*
383
+ Fill the TransformComponent content box so the children's
384
+ `max-w-full / max-h-full` resolve against the actual viewport
385
+ instead of the image's natural box. Without `w-full h-full`
386
+ this wrapper shrink-fits the image, which collapses the
387
+ max-* constraints and renders the image at intrinsic size —
388
+ visible as cropping / half-height in tall containers.
389
+ */}
390
+ <div className="relative w-full h-full flex items-center justify-center">
391
+ {useProgressiveLoading && lqip && !isFullyLoaded && (
392
+ <img
393
+ src={lqip}
394
+ alt=""
395
+ aria-hidden="true"
396
+ className="absolute max-w-full max-h-full object-contain select-none"
397
+ style={{ transform: transformStyle, filter: 'blur(20px)', transition: 'opacity 0.3s ease-out', opacity: isFullyLoaded ? 0 : 1, ...fitStyle }}
398
+ draggable={false}
399
+ />
400
+ )}
401
+ {src && (
402
+ <img
403
+ ref={imgRef}
404
+ src={src}
405
+ alt={current.file.name}
406
+ className={cn(
407
+ 'max-w-full max-h-full object-contain select-none',
408
+ // A soft drop shadow lifts the image off the dark lightbox
409
+ // backdrop (Preview / Photos do the same).
410
+ isLightbox && 'drop-shadow-[0_8px_40px_rgba(0,0,0,0.55)]',
411
+ )}
412
+ style={{
413
+ transform: transformStyle,
414
+ transition: 'transform 0.15s ease-out, opacity 0.3s ease-out',
415
+ opacity:
416
+ (useProgressiveLoading && !isFullyLoaded) || !imgDecoded ? 0 : 1,
417
+ ...fitStyle,
418
+ }}
419
+ draggable={false}
420
+ onLoad={() => setImgDecoded(true)}
421
+ onError={() => setLoadError(true)}
422
+ />
423
+ )}
424
+ </div>
425
+ </TransformComponent>
426
+ </TransformWrapper>
427
+
428
+ {/* Lightbox: restrained macOS-grade chrome that idle-fades. */}
429
+ {isLightbox ? (
430
+ <LightboxChrome
431
+ active={chromeActive}
432
+ index={currentIndex + 1}
433
+ total={images.length}
434
+ scale={scale}
435
+ fileName={current.file.name}
436
+ onPrev={prev}
437
+ onNext={next}
438
+ onClose={() => onRequestClose?.()}
439
+ onDownload={handleDownload}
440
+ onPinChange={setPinned}
441
+ labels={{
442
+ prev: labels.previous,
443
+ next: labels.next,
444
+ close: labels.close,
445
+ download: labels.download,
446
+ }}
447
+ />
448
+ ) : (
449
+ hasMultiple && (
450
+ // Embedded gallery nav (checkerboard canvas).
451
+ <>
452
+ <Tooltip>
453
+ <TooltipTrigger asChild>
454
+ <button
455
+ type="button"
456
+ onClick={prev}
457
+ aria-label={labels.previous}
458
+ className="absolute left-2 top-1/2 -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
459
+ >
460
+ <ChevronLeft className="h-5 w-5" />
461
+ </button>
462
+ </TooltipTrigger>
463
+ <TooltipContent side="right">{labels.previous}</TooltipContent>
464
+ </Tooltip>
465
+ <Tooltip>
466
+ <TooltipTrigger asChild>
467
+ <button
468
+ type="button"
469
+ onClick={next}
470
+ aria-label={labels.next}
471
+ className="absolute right-2 top-1/2 -translate-y-1/2 z-10 bg-black/50 hover:bg-black/70 text-white rounded-full p-1.5 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
472
+ >
473
+ <ChevronRight className="h-5 w-5" />
474
+ </button>
475
+ </TooltipTrigger>
476
+ <TooltipContent side="left">{labels.next}</TooltipContent>
477
+ </Tooltip>
478
+ <div className="absolute top-3 left-1/2 -translate-x-1/2 z-10 bg-black/60 backdrop-blur-sm border border-white/10 text-white/80 text-xs font-mono px-2 py-0.5 rounded-full pointer-events-none">
479
+ {currentIndex + 1} / {images.length}
480
+ </div>
481
+ </>
482
+ )
483
+ )}
484
+
485
+ {/* Embedded expand-to-fullscreen dialog (uses the lightbox presentation). */}
486
+ {!inDialog && !lightbox && (
487
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
488
+ <DialogContent
489
+ fullscreen
490
+ closeButton={false}
491
+ className="border-0 bg-transparent p-0 shadow-none"
492
+ >
493
+ <DialogTitle className="sr-only">{current.file.name}</DialogTitle>
494
+ {dialogOpen && (
495
+ <ImageViewer
496
+ images={images}
497
+ initialIndex={currentIndex}
498
+ lightbox
499
+ autoFocus
500
+ onRequestClose={() => setDialogOpen(false)}
501
+ />
502
+ )}
503
+ </DialogContent>
504
+ </Dialog>
505
+ )}
506
+ </div>
507
+ );
508
+ }
@@ -0,0 +1,157 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * LightboxChrome - Restrained, macOS-grade floating controls for the
5
+ * fullscreen image lightbox.
6
+ *
7
+ * Design intent (Apple HIG / Quick Look / Preview):
8
+ * - Minimal chrome: a single close affordance (top-right), prev/next only
9
+ * when there is more than one image, and one unobtrusive status pill
10
+ * (counter + live zoom %).
11
+ * - Everything floats on liquid-glass and fades together when the pointer
12
+ * goes idle, reappearing on the slightest movement.
13
+ * - No checkerboard, no rotate/flip slab — the image is the subject.
14
+ */
15
+
16
+ import { ChevronLeft, ChevronRight, Download, X } from 'lucide-react';
17
+
18
+ import { cn } from '@djangocfg/ui-core/lib';
19
+
20
+ import { MiddleEllipsis } from './MiddleEllipsis';
21
+
22
+ interface LightboxChromeProps {
23
+ /** Visible (recent pointer activity) vs faded (idle). */
24
+ active: boolean;
25
+ /** 1-based index for the counter. */
26
+ index: number;
27
+ total: number;
28
+ /** Live zoom scale (1 = fit). The % pill only shows when zoomed in/out. */
29
+ scale: number;
30
+ fileName?: string;
31
+ onPrev: () => void;
32
+ onNext: () => void;
33
+ onClose: () => void;
34
+ /** Save the shown image. Undefined hides the control: a viewer with no
35
+ * resolved source has nothing to save, and a button that cannot act is
36
+ * worse than an absent one. */
37
+ onDownload?: () => void;
38
+ /** Pin chrome open while the pointer hovers a control. */
39
+ onPinChange: (pinned: boolean) => void;
40
+ labels: { prev: string; next: string; close: string; download: string };
41
+ }
42
+
43
+ // Liquid-glass circular control — light icon on translucent vibrancy so it
44
+ // stays legible over any photo (bright or dark) without a hard slab.
45
+ const GLASS_BTN =
46
+ 'glass-liquid pointer-events-auto inline-flex items-center justify-center ' +
47
+ 'rounded-full text-white/90 transition-[background-color,transform,opacity] ' +
48
+ 'hover:text-white hover:bg-white/15 active:scale-95 ' +
49
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/70';
50
+
51
+ export function LightboxChrome({
52
+ active,
53
+ index,
54
+ total,
55
+ scale,
56
+ fileName,
57
+ onPrev,
58
+ onNext,
59
+ onClose,
60
+ onDownload,
61
+ onPinChange,
62
+ labels,
63
+ }: LightboxChromeProps) {
64
+ const hasMultiple = total > 1;
65
+ // Only surface the zoom pill once the image is meaningfully off fit — at
66
+ // exactly fit (1.0) the % is noise.
67
+ const zoomed = Math.abs(scale - 1) > 0.01;
68
+ const zoomPct = `${Math.round(scale * 100)}%`;
69
+
70
+ // Whole chrome layer fades as one. `pointer-events-none` on the layer means
71
+ // the faded state never eats clicks meant for the image (pan/double-click).
72
+ return (
73
+ <div
74
+ className={cn(
75
+ 'pointer-events-none absolute inset-0 z-20 transition-opacity duration-300 ease-out',
76
+ active ? 'opacity-100' : 'opacity-0',
77
+ )}
78
+ onMouseEnter={() => onPinChange(true)}
79
+ onMouseLeave={() => onPinChange(false)}
80
+ >
81
+ {/* Top bar: filename (left, whisper-quiet) + close (right).
82
+ The chip caps its width and middle-truncates long names so the start
83
+ and extension stay readable (Finder convention); `gap` + the pinned
84
+ close button guarantee no overlap. */}
85
+ <div className="absolute inset-x-0 top-0 flex items-start justify-between gap-3 p-3 sm:p-4">
86
+ {fileName ? (
87
+ <MiddleEllipsis
88
+ text={fileName}
89
+ className="glass-liquid pointer-events-auto max-w-[clamp(8rem,40vw,28rem)] rounded-full px-3 py-1.5 text-xs font-medium text-white/85"
90
+ />
91
+ ) : (
92
+ <span aria-hidden />
93
+ )}
94
+ {/* Save sits beside close, not in the bottom pill: that pill is status
95
+ (counter, zoom) and reads as output, while these two are what the
96
+ viewer can DO. Close stays rightmost so its position is unchanged. */}
97
+ <div className="flex items-center gap-2">
98
+ {onDownload && (
99
+ <button
100
+ type="button"
101
+ aria-label={labels.download}
102
+ onClick={onDownload}
103
+ className={cn(GLASS_BTN, 'h-9 w-9')}
104
+ >
105
+ <Download className="h-[18px] w-[18px]" />
106
+ </button>
107
+ )}
108
+ <button
109
+ type="button"
110
+ aria-label={labels.close}
111
+ onClick={onClose}
112
+ className={cn(GLASS_BTN, 'h-9 w-9')}
113
+ >
114
+ <X className="h-[18px] w-[18px]" />
115
+ </button>
116
+ </div>
117
+ </div>
118
+
119
+ {/* Prev / next — edge-anchored, only when there is a gallery. */}
120
+ {hasMultiple && (
121
+ <>
122
+ <button
123
+ type="button"
124
+ aria-label={labels.prev}
125
+ onClick={onPrev}
126
+ className={cn(GLASS_BTN, 'absolute left-3 top-1/2 h-11 w-11 -translate-y-1/2 sm:left-5')}
127
+ >
128
+ <ChevronLeft className="h-5 w-5" />
129
+ </button>
130
+ <button
131
+ type="button"
132
+ aria-label={labels.next}
133
+ onClick={onNext}
134
+ className={cn(GLASS_BTN, 'absolute right-3 top-1/2 h-11 w-11 -translate-y-1/2 sm:right-5')}
135
+ >
136
+ <ChevronRight className="h-5 w-5" />
137
+ </button>
138
+ </>
139
+ )}
140
+
141
+ {/* Bottom status pill: counter + live zoom %. One quiet line. */}
142
+ {(hasMultiple || zoomed) && (
143
+ <div className="absolute inset-x-0 bottom-0 flex justify-center p-3 sm:p-4">
144
+ <div className="glass-liquid flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium tabular-nums text-white/85">
145
+ {hasMultiple && (
146
+ <span>
147
+ {index} / {total}
148
+ </span>
149
+ )}
150
+ {hasMultiple && zoomed && <span className="h-3 w-px bg-white/25" />}
151
+ {zoomed && <span className="font-mono text-white/70">{zoomPct}</span>}
152
+ </div>
153
+ </div>
154
+ )}
155
+ </div>
156
+ );
157
+ }