@djangocfg/ui-nextjs 2.1.65 → 2.1.67

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.
Files changed (92) hide show
  1. package/package.json +13 -8
  2. package/src/blocks/SplitHero/SplitHeroMedia.tsx +2 -1
  3. package/src/stores/index.ts +8 -0
  4. package/src/stores/mediaCache.ts +464 -0
  5. package/src/tools/AudioPlayer/@refactoring/00-PLAN.md +148 -0
  6. package/src/tools/AudioPlayer/@refactoring/01-TYPES.md +301 -0
  7. package/src/tools/AudioPlayer/@refactoring/02-HOOKS.md +281 -0
  8. package/src/tools/AudioPlayer/@refactoring/03-CONTEXT.md +328 -0
  9. package/src/tools/AudioPlayer/@refactoring/04-COMPONENTS.md +251 -0
  10. package/src/tools/AudioPlayer/@refactoring/05-EFFECTS.md +427 -0
  11. package/src/tools/AudioPlayer/@refactoring/06-UTILS-AND-INDEX.md +193 -0
  12. package/src/tools/AudioPlayer/@refactoring/07-EXECUTION-CHECKLIST.md +146 -0
  13. package/src/tools/AudioPlayer/README.md +325 -0
  14. package/src/tools/AudioPlayer/components/AudioEqualizer.tsx +200 -0
  15. package/src/tools/AudioPlayer/components/AudioPlayer.tsx +231 -0
  16. package/src/tools/AudioPlayer/components/AudioShortcutsPopover.tsx +99 -0
  17. package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +147 -0
  18. package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +110 -0
  19. package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +58 -0
  20. package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +45 -0
  21. package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +82 -0
  22. package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +8 -0
  23. package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +6 -0
  24. package/src/tools/AudioPlayer/components/SimpleAudioPlayer.tsx +280 -0
  25. package/src/tools/AudioPlayer/components/VisualizationToggle.tsx +64 -0
  26. package/src/tools/AudioPlayer/components/index.ts +21 -0
  27. package/src/tools/AudioPlayer/context/AudioProvider.tsx +292 -0
  28. package/src/tools/AudioPlayer/context/index.ts +11 -0
  29. package/src/tools/AudioPlayer/context/selectors.ts +96 -0
  30. package/src/tools/AudioPlayer/effects/index.ts +412 -0
  31. package/src/tools/AudioPlayer/hooks/index.ts +29 -0
  32. package/src/tools/AudioPlayer/hooks/useAudioAnalysis.ts +110 -0
  33. package/src/tools/AudioPlayer/hooks/useAudioHotkeys.ts +149 -0
  34. package/src/tools/AudioPlayer/hooks/useSharedWebAudio.ts +106 -0
  35. package/src/tools/AudioPlayer/hooks/useVisualization.tsx +201 -0
  36. package/src/tools/AudioPlayer/index.ts +139 -0
  37. package/src/tools/AudioPlayer/types/audio.ts +107 -0
  38. package/src/tools/AudioPlayer/types/components.ts +98 -0
  39. package/src/tools/AudioPlayer/types/effects.ts +73 -0
  40. package/src/tools/AudioPlayer/types/index.ts +35 -0
  41. package/src/tools/AudioPlayer/utils/formatTime.ts +10 -0
  42. package/src/tools/AudioPlayer/utils/index.ts +5 -0
  43. package/src/tools/ImageViewer/@refactoring/00-PLAN.md +71 -0
  44. package/src/tools/ImageViewer/@refactoring/01-TYPES.md +121 -0
  45. package/src/tools/ImageViewer/@refactoring/02-UTILS.md +143 -0
  46. package/src/tools/ImageViewer/@refactoring/03-HOOKS.md +261 -0
  47. package/src/tools/ImageViewer/@refactoring/04-COMPONENTS.md +427 -0
  48. package/src/tools/ImageViewer/@refactoring/05-EXECUTION-CHECKLIST.md +126 -0
  49. package/src/tools/ImageViewer/README.md +174 -0
  50. package/src/tools/ImageViewer/components/ImageInfo.tsx +44 -0
  51. package/src/tools/ImageViewer/components/ImageToolbar.tsx +150 -0
  52. package/src/tools/ImageViewer/components/ImageViewer.tsx +235 -0
  53. package/src/tools/ImageViewer/components/index.ts +7 -0
  54. package/src/tools/ImageViewer/hooks/index.ts +9 -0
  55. package/src/tools/ImageViewer/hooks/useImageLoading.ts +153 -0
  56. package/src/tools/ImageViewer/hooks/useImageTransform.ts +101 -0
  57. package/src/tools/ImageViewer/index.ts +60 -0
  58. package/src/tools/ImageViewer/types.ts +75 -0
  59. package/src/tools/ImageViewer/utils/constants.ts +59 -0
  60. package/src/tools/ImageViewer/utils/index.ts +16 -0
  61. package/src/tools/ImageViewer/utils/lqip.ts +47 -0
  62. package/src/tools/VideoPlayer/@refactoring/00-PLAN.md +91 -0
  63. package/src/tools/VideoPlayer/@refactoring/01-TYPES.md +284 -0
  64. package/src/tools/VideoPlayer/@refactoring/02-UTILS.md +141 -0
  65. package/src/tools/VideoPlayer/@refactoring/03-HOOKS.md +178 -0
  66. package/src/tools/VideoPlayer/@refactoring/04-COMPONENTS.md +95 -0
  67. package/src/tools/VideoPlayer/@refactoring/05-EXECUTION-CHECKLIST.md +139 -0
  68. package/src/tools/VideoPlayer/README.md +212 -187
  69. package/src/tools/VideoPlayer/{VideoControls.tsx → components/VideoControls.tsx} +8 -9
  70. package/src/tools/VideoPlayer/components/VideoErrorFallback.tsx +174 -0
  71. package/src/tools/VideoPlayer/components/VideoPlayer.tsx +201 -0
  72. package/src/tools/VideoPlayer/components/index.ts +14 -0
  73. package/src/tools/VideoPlayer/context/VideoPlayerContext.tsx +52 -0
  74. package/src/tools/VideoPlayer/context/index.ts +8 -0
  75. package/src/tools/VideoPlayer/hooks/index.ts +9 -0
  76. package/src/tools/VideoPlayer/hooks/useVideoPositionCache.ts +109 -0
  77. package/src/tools/VideoPlayer/index.ts +70 -9
  78. package/src/tools/VideoPlayer/providers/NativeProvider.tsx +206 -0
  79. package/src/tools/VideoPlayer/providers/StreamProvider.tsx +401 -0
  80. package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +332 -0
  81. package/src/tools/VideoPlayer/providers/index.ts +8 -0
  82. package/src/tools/VideoPlayer/types/index.ts +38 -0
  83. package/src/tools/VideoPlayer/types/player.ts +116 -0
  84. package/src/tools/VideoPlayer/types/provider.ts +93 -0
  85. package/src/tools/VideoPlayer/types/sources.ts +97 -0
  86. package/src/tools/VideoPlayer/utils/fileSource.ts +78 -0
  87. package/src/tools/VideoPlayer/utils/index.ts +11 -0
  88. package/src/tools/VideoPlayer/utils/resolvers.ts +75 -0
  89. package/src/tools/index.ts +92 -4
  90. package/src/tools/VideoPlayer/NativePlayer.tsx +0 -141
  91. package/src/tools/VideoPlayer/VideoPlayer.tsx +0 -231
  92. package/src/tools/VideoPlayer/types.ts +0 -118
@@ -0,0 +1,261 @@
1
+ # Phase 3: Hooks Extraction
2
+
3
+ ## Goal
4
+
5
+ Extract state management logic into reusable custom hooks.
6
+
7
+ ## Files to Create
8
+
9
+ ### hooks/useImageTransform.ts
10
+
11
+ Manages rotation and flip transformations.
12
+
13
+ ```typescript
14
+ 'use client';
15
+
16
+ /**
17
+ * useImageTransform - Manages image rotation and flip state
18
+ */
19
+
20
+ import { useState, useCallback, useEffect, useMemo } from 'react';
21
+ import type { ImageTransform } from '../types';
22
+ import { DEFAULT_TRANSFORM } from '../utils';
23
+
24
+ export interface UseImageTransformOptions {
25
+ /** Reset transform when this key changes */
26
+ resetKey?: string;
27
+ }
28
+
29
+ export interface UseImageTransformReturn {
30
+ /** Current transform state */
31
+ transform: ImageTransform;
32
+ /** Rotate 90 degrees clockwise */
33
+ rotate: () => void;
34
+ /** Toggle horizontal flip */
35
+ flipH: () => void;
36
+ /** Toggle vertical flip */
37
+ flipV: () => void;
38
+ /** Reset all transforms */
39
+ reset: () => void;
40
+ /** CSS transform string for applying to image */
41
+ transformStyle: string;
42
+ }
43
+
44
+ export function useImageTransform(
45
+ options: UseImageTransformOptions = {}
46
+ ): UseImageTransformReturn {
47
+ const { resetKey } = options;
48
+
49
+ const [transform, setTransform] = useState<ImageTransform>(DEFAULT_TRANSFORM);
50
+
51
+ // Reset transform when key changes
52
+ useEffect(() => {
53
+ if (resetKey) {
54
+ setTransform(DEFAULT_TRANSFORM);
55
+ }
56
+ }, [resetKey]);
57
+
58
+ const rotate = useCallback(() => {
59
+ setTransform((prev) => ({
60
+ ...prev,
61
+ rotation: (prev.rotation + 90) % 360,
62
+ }));
63
+ }, []);
64
+
65
+ const flipH = useCallback(() => {
66
+ setTransform((prev) => ({
67
+ ...prev,
68
+ flipH: !prev.flipH,
69
+ }));
70
+ }, []);
71
+
72
+ const flipV = useCallback(() => {
73
+ setTransform((prev) => ({
74
+ ...prev,
75
+ flipV: !prev.flipV,
76
+ }));
77
+ }, []);
78
+
79
+ const reset = useCallback(() => {
80
+ setTransform(DEFAULT_TRANSFORM);
81
+ }, []);
82
+
83
+ // Build CSS transform string
84
+ const transformStyle = useMemo(() => {
85
+ const transforms: string[] = [];
86
+
87
+ if (transform.rotation !== 0) {
88
+ transforms.push(`rotate(${transform.rotation}deg)`);
89
+ }
90
+ if (transform.flipH) {
91
+ transforms.push('scaleX(-1)');
92
+ }
93
+ if (transform.flipV) {
94
+ transforms.push('scaleY(-1)');
95
+ }
96
+
97
+ return transforms.join(' ');
98
+ }, [transform]);
99
+
100
+ return {
101
+ transform,
102
+ rotate,
103
+ flipH,
104
+ flipV,
105
+ reset,
106
+ transformStyle,
107
+ };
108
+ }
109
+ ```
110
+
111
+ ### hooks/useImageLoading.ts
112
+
113
+ Manages image loading, blob URLs, and LQIP generation.
114
+
115
+ ```typescript
116
+ 'use client';
117
+
118
+ /**
119
+ * useImageLoading - Manages image loading state with LQIP
120
+ */
121
+
122
+ import { useState, useEffect, useRef } from 'react';
123
+ import { useImageCache, generateContentKey } from '../../../stores/mediaCache';
124
+ import { createLQIP, MAX_IMAGE_SIZE, WARN_IMAGE_SIZE } from '../utils';
125
+
126
+ export interface UseImageLoadingOptions {
127
+ /** Image content (ArrayBuffer or string) */
128
+ content: string | ArrayBuffer;
129
+ /** MIME type for blob creation */
130
+ mimeType?: string;
131
+ }
132
+
133
+ export interface UseImageLoadingReturn {
134
+ /** Blob URL source for the image */
135
+ src: string | null;
136
+ /** Low-quality placeholder URL */
137
+ lqip: string | null;
138
+ /** Whether image is loading */
139
+ isLoading: boolean;
140
+ /** Whether high-quality image is ready */
141
+ isReady: boolean;
142
+ /** Error message if any */
143
+ error: string | null;
144
+ /** Content key for caching */
145
+ contentKey: string;
146
+ /** Image size in bytes */
147
+ size: number;
148
+ /** Whether image exceeds size limit */
149
+ isOversized: boolean;
150
+ /** Whether image exceeds warning threshold */
151
+ isLarge: boolean;
152
+ }
153
+
154
+ export function useImageLoading(
155
+ options: UseImageLoadingOptions
156
+ ): UseImageLoadingReturn {
157
+ const { content, mimeType } = options;
158
+
159
+ const { getOrCreateBlobUrl, releaseBlobUrl } = useImageCache();
160
+
161
+ const [src, setSrc] = useState<string | null>(null);
162
+ const [lqip, setLqip] = useState<string | null>(null);
163
+ const [isLoading, setIsLoading] = useState(true);
164
+ const [isReady, setIsReady] = useState(false);
165
+ const [error, setError] = useState<string | null>(null);
166
+
167
+ const contentKeyRef = useRef<string>('');
168
+
169
+ // Calculate size and limits
170
+ const size = content instanceof ArrayBuffer
171
+ ? content.byteLength
172
+ : content.length;
173
+ const isOversized = size > MAX_IMAGE_SIZE;
174
+ const isLarge = size > WARN_IMAGE_SIZE;
175
+
176
+ // Generate content key
177
+ const contentKey = generateContentKey(content);
178
+ contentKeyRef.current = contentKey;
179
+
180
+ // Create blob URL and LQIP
181
+ useEffect(() => {
182
+ if (isOversized) {
183
+ setError(`Image too large (${(size / 1024 / 1024).toFixed(1)}MB). Maximum: 50MB`);
184
+ setIsLoading(false);
185
+ return;
186
+ }
187
+
188
+ setIsLoading(true);
189
+ setError(null);
190
+ setIsReady(false);
191
+ setLqip(null);
192
+
193
+ // Create blob URL
194
+ const blobUrl = getOrCreateBlobUrl(content, mimeType);
195
+ setSrc(blobUrl);
196
+
197
+ // Generate LQIP for progressive loading
198
+ if (isLarge) {
199
+ createLQIP(blobUrl).then((placeholder) => {
200
+ if (contentKeyRef.current === contentKey) {
201
+ setLqip(placeholder);
202
+ }
203
+ });
204
+ }
205
+
206
+ // Preload full image
207
+ const img = new Image();
208
+ img.onload = () => {
209
+ if (contentKeyRef.current === contentKey) {
210
+ setIsReady(true);
211
+ setIsLoading(false);
212
+ }
213
+ };
214
+ img.onerror = () => {
215
+ if (contentKeyRef.current === contentKey) {
216
+ setError('Failed to load image');
217
+ setIsLoading(false);
218
+ }
219
+ };
220
+ img.src = blobUrl;
221
+
222
+ // Cleanup
223
+ return () => {
224
+ releaseBlobUrl(blobUrl);
225
+ };
226
+ }, [content, contentKey, mimeType, isOversized, isLarge, size, getOrCreateBlobUrl, releaseBlobUrl]);
227
+
228
+ return {
229
+ src,
230
+ lqip,
231
+ isLoading,
232
+ isReady,
233
+ error,
234
+ contentKey,
235
+ size,
236
+ isOversized,
237
+ isLarge,
238
+ };
239
+ }
240
+ ```
241
+
242
+ ### hooks/index.ts
243
+
244
+ ```typescript
245
+ /**
246
+ * ImageViewer hooks - Public API
247
+ */
248
+
249
+ export { useImageTransform } from './useImageTransform';
250
+ export type { UseImageTransformOptions, UseImageTransformReturn } from './useImageTransform';
251
+
252
+ export { useImageLoading } from './useImageLoading';
253
+ export type { UseImageLoadingOptions, UseImageLoadingReturn } from './useImageLoading';
254
+ ```
255
+
256
+ ## Benefits
257
+
258
+ 1. **Reusable** - Hooks can be used in other components
259
+ 2. **Testable** - Easy to unit test state logic
260
+ 3. **Clean** - Main component becomes much simpler
261
+ 4. **Separated** - Transform logic independent of loading logic
@@ -0,0 +1,427 @@
1
+ # Phase 4: Components Extraction
2
+
3
+ ## Goal
4
+
5
+ Split the monolithic ImageViewer.tsx into separate component files.
6
+
7
+ ## Files to Create
8
+
9
+ ### components/ImageToolbar.tsx
10
+
11
+ Floating toolbar with zoom/rotate/flip controls.
12
+
13
+ ```typescript
14
+ 'use client';
15
+
16
+ /**
17
+ * ImageToolbar - Floating toolbar for image controls
18
+ */
19
+
20
+ import { useCallback } from 'react';
21
+ import { Button, cn } from '@djangocfg/ui-core';
22
+ import {
23
+ ZoomIn,
24
+ ZoomOut,
25
+ RotateCw,
26
+ FlipHorizontal,
27
+ FlipVertical,
28
+ Maximize2,
29
+ Expand,
30
+ } from 'lucide-react';
31
+ import {
32
+ DropdownMenu,
33
+ DropdownMenuContent,
34
+ DropdownMenuItem,
35
+ DropdownMenuTrigger,
36
+ } from '../../components/dropdown-menu';
37
+ import { useControls } from 'react-zoom-pan-pinch';
38
+ import { ZOOM_PRESETS, MIN_ZOOM, MAX_ZOOM } from '../utils';
39
+ import type { ImageToolbarProps } from '../types';
40
+
41
+ export function ImageToolbar({
42
+ scale,
43
+ onExpand,
44
+ onRotate,
45
+ onFlipH,
46
+ onFlipV,
47
+ flipH,
48
+ flipV,
49
+ inDialog = false,
50
+ }: ImageToolbarProps) {
51
+ const { zoomIn, zoomOut, resetTransform, centerView, setTransform } = useControls();
52
+
53
+ // Calculate zoom label
54
+ const zoomLabel = `${Math.round(scale * 100)}%`;
55
+
56
+ // Handle zoom preset selection
57
+ const handleZoomPreset = useCallback(
58
+ (value: number) => {
59
+ if (value === -1) {
60
+ // Fit to view
61
+ resetTransform();
62
+ } else {
63
+ setTransform(0, 0, value);
64
+ centerView(value);
65
+ }
66
+ },
67
+ [resetTransform, setTransform, centerView]
68
+ );
69
+
70
+ return (
71
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-1 bg-background/95 backdrop-blur-sm border rounded-lg p-1.5 shadow-lg">
72
+ {/* Zoom Out */}
73
+ <Button
74
+ variant="ghost"
75
+ size="icon"
76
+ className="h-8 w-8"
77
+ onClick={() => zoomOut()}
78
+ disabled={scale <= MIN_ZOOM}
79
+ title="Zoom out"
80
+ >
81
+ <ZoomOut className="h-4 w-4" />
82
+ </Button>
83
+
84
+ {/* Zoom Dropdown */}
85
+ <DropdownMenu>
86
+ <DropdownMenuTrigger asChild>
87
+ <Button variant="ghost" size="sm" className="h-8 min-w-16 px-2 font-mono text-xs">
88
+ {zoomLabel}
89
+ </Button>
90
+ </DropdownMenuTrigger>
91
+ <DropdownMenuContent align="center">
92
+ {ZOOM_PRESETS.map((preset) => (
93
+ <DropdownMenuItem
94
+ key={preset.value}
95
+ onClick={() => handleZoomPreset(preset.value)}
96
+ >
97
+ {preset.label}
98
+ </DropdownMenuItem>
99
+ ))}
100
+ </DropdownMenuContent>
101
+ </DropdownMenu>
102
+
103
+ {/* Zoom In */}
104
+ <Button
105
+ variant="ghost"
106
+ size="icon"
107
+ className="h-8 w-8"
108
+ onClick={() => zoomIn()}
109
+ disabled={scale >= MAX_ZOOM}
110
+ title="Zoom in"
111
+ >
112
+ <ZoomIn className="h-4 w-4" />
113
+ </Button>
114
+
115
+ <div className="w-px h-6 bg-border mx-1" />
116
+
117
+ {/* Fit to View */}
118
+ <Button
119
+ variant="ghost"
120
+ size="icon"
121
+ className="h-8 w-8"
122
+ onClick={() => resetTransform()}
123
+ title="Fit to view"
124
+ >
125
+ <Maximize2 className="h-4 w-4" />
126
+ </Button>
127
+
128
+ {/* Rotate */}
129
+ <Button
130
+ variant="ghost"
131
+ size="icon"
132
+ className="h-8 w-8"
133
+ onClick={onRotate}
134
+ title="Rotate 90°"
135
+ >
136
+ <RotateCw className="h-4 w-4" />
137
+ </Button>
138
+
139
+ {/* Flip Horizontal */}
140
+ <Button
141
+ variant="ghost"
142
+ size="icon"
143
+ className={cn('h-8 w-8', flipH && 'bg-muted text-primary')}
144
+ onClick={onFlipH}
145
+ title="Flip horizontal"
146
+ >
147
+ <FlipHorizontal className="h-4 w-4" />
148
+ </Button>
149
+
150
+ {/* Flip Vertical */}
151
+ <Button
152
+ variant="ghost"
153
+ size="icon"
154
+ className={cn('h-8 w-8', flipV && 'bg-muted text-primary')}
155
+ onClick={onFlipV}
156
+ title="Flip vertical"
157
+ >
158
+ <FlipVertical className="h-4 w-4" />
159
+ </Button>
160
+
161
+ {/* Expand (hidden in dialog) */}
162
+ {!inDialog && (
163
+ <>
164
+ <div className="w-px h-6 bg-border mx-1" />
165
+ <Button
166
+ variant="ghost"
167
+ size="icon"
168
+ className="h-8 w-8"
169
+ onClick={onExpand}
170
+ title="Expand fullscreen"
171
+ >
172
+ <Expand className="h-4 w-4" />
173
+ </Button>
174
+ </>
175
+ )}
176
+ </div>
177
+ );
178
+ }
179
+ ```
180
+
181
+ ### components/ImageInfo.tsx
182
+
183
+ Displays image dimensions badge.
184
+
185
+ ```typescript
186
+ 'use client';
187
+
188
+ /**
189
+ * ImageInfo - Displays image dimensions
190
+ */
191
+
192
+ import { useEffect, useState } from 'react';
193
+ import { useImageCache } from '../../../stores/mediaCache';
194
+ import type { ImageInfoProps } from '../types';
195
+
196
+ export function ImageInfo({ src, contentKey }: ImageInfoProps) {
197
+ const { getDimensions, cacheDimensions } = useImageCache();
198
+
199
+ const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(
200
+ () => getDimensions(contentKey)
201
+ );
202
+
203
+ useEffect(() => {
204
+ // Already have dimensions
205
+ if (dimensions) return;
206
+
207
+ // Load image to get dimensions
208
+ const img = new Image();
209
+ img.onload = () => {
210
+ const dims = { width: img.naturalWidth, height: img.naturalHeight };
211
+ cacheDimensions(contentKey, dims);
212
+ setDimensions(dims);
213
+ };
214
+ img.src = src;
215
+ }, [src, contentKey, dimensions, cacheDimensions]);
216
+
217
+ if (!dimensions) return null;
218
+
219
+ return (
220
+ <div className="absolute top-2 right-2 z-10 px-2 py-1 text-xs font-mono bg-background/80 backdrop-blur-sm border rounded text-muted-foreground">
221
+ {dimensions.width} × {dimensions.height}
222
+ </div>
223
+ );
224
+ }
225
+ ```
226
+
227
+ ### components/ImageViewer.tsx
228
+
229
+ Main component, now simplified.
230
+
231
+ ```typescript
232
+ 'use client';
233
+
234
+ /**
235
+ * ImageViewer - Image display with zoom/pan/rotate capabilities
236
+ */
237
+
238
+ import { useRef, useEffect, useCallback, useState } from 'react';
239
+ import { TransformWrapper, TransformComponent, useControls } from 'react-zoom-pan-pinch';
240
+ import { Dialog, DialogContent, Alert, AlertDescription, cn } from '@djangocfg/ui-core';
241
+ import { AlertCircle, ImageIcon } from 'lucide-react';
242
+
243
+ import { ImageToolbar } from './ImageToolbar';
244
+ import { ImageInfo } from './ImageInfo';
245
+ import { useImageTransform, useImageLoading } from '../hooks';
246
+ import { MIN_ZOOM, MAX_ZOOM } from '../utils';
247
+ import type { ImageViewerProps } from '../types';
248
+
249
+ // Controls wrapper for accessing zoom state
250
+ function ImageViewerContent({
251
+ file,
252
+ src,
253
+ lqip,
254
+ isLoading,
255
+ isReady,
256
+ contentKey,
257
+ isLarge,
258
+ transform,
259
+ transformStyle,
260
+ rotate,
261
+ flipH,
262
+ flipV,
263
+ onExpand,
264
+ inDialog,
265
+ }: /* props type */) {
266
+ const { zoomIn, zoomOut, resetTransform } = useControls();
267
+ const [scale, setScale] = useState(1);
268
+
269
+ // Keyboard shortcuts
270
+ useEffect(() => {
271
+ const handleKeyDown = (e: KeyboardEvent) => {
272
+ if (e.key === '+' || e.key === '=') zoomIn();
273
+ if (e.key === '-') zoomOut();
274
+ if (e.key === '0') resetTransform();
275
+ if (e.key === 'r' || e.key === 'R') rotate();
276
+ };
277
+
278
+ window.addEventListener('keydown', handleKeyDown);
279
+ return () => window.removeEventListener('keydown', handleKeyDown);
280
+ }, [zoomIn, zoomOut, resetTransform, rotate]);
281
+
282
+ return (
283
+ <>
284
+ <ImageToolbar
285
+ scale={scale}
286
+ onExpand={onExpand}
287
+ onRotate={rotate}
288
+ onFlipH={flipH}
289
+ onFlipV={flipV}
290
+ flipH={transform.flipH}
291
+ flipV={transform.flipV}
292
+ inDialog={inDialog}
293
+ />
294
+
295
+ <ImageInfo src={src} contentKey={contentKey} />
296
+
297
+ <TransformComponent wrapperClass="!w-full !h-full" contentClass="!w-full !h-full">
298
+ <div className="relative w-full h-full flex items-center justify-center">
299
+ {/* LQIP placeholder */}
300
+ {isLarge && lqip && !isReady && (
301
+ <img
302
+ src={lqip}
303
+ alt=""
304
+ aria-hidden="true"
305
+ className="absolute inset-0 w-full h-full object-contain blur-lg scale-105"
306
+ />
307
+ )}
308
+
309
+ {/* Main image */}
310
+ <img
311
+ src={src}
312
+ alt={file.name}
313
+ className={cn(
314
+ 'max-w-full max-h-full object-contain transition-opacity',
315
+ isLoading && 'opacity-50'
316
+ )}
317
+ style={{ transform: transformStyle }}
318
+ draggable={false}
319
+ />
320
+ </div>
321
+ </TransformComponent>
322
+ </>
323
+ );
324
+ }
325
+
326
+ export function ImageViewer({ file, content, inDialog = false }: ImageViewerProps) {
327
+ const [dialogOpen, setDialogOpen] = useState(false);
328
+
329
+ // Loading state
330
+ const {
331
+ src,
332
+ lqip,
333
+ isLoading,
334
+ isReady,
335
+ error,
336
+ contentKey,
337
+ isOversized,
338
+ isLarge,
339
+ } = useImageLoading({ content, mimeType: file.mimeType });
340
+
341
+ // Transform state
342
+ const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({
343
+ resetKey: file.path,
344
+ });
345
+
346
+ // Error state
347
+ if (error || isOversized) {
348
+ return (
349
+ <Alert variant="destructive">
350
+ <AlertCircle className="h-4 w-4" />
351
+ <AlertDescription>{error || 'Image too large to display'}</AlertDescription>
352
+ </Alert>
353
+ );
354
+ }
355
+
356
+ // Loading state (no src yet)
357
+ if (!src) {
358
+ return (
359
+ <div className="flex items-center justify-center h-64 bg-muted/30">
360
+ <ImageIcon className="h-8 w-8 text-muted-foreground animate-pulse" />
361
+ </div>
362
+ );
363
+ }
364
+
365
+ const viewer = (
366
+ <TransformWrapper
367
+ initialScale={1}
368
+ minScale={MIN_ZOOM}
369
+ maxScale={MAX_ZOOM}
370
+ centerOnInit
371
+ limitToBounds={false}
372
+ onTransformed={(_, state) => {/* update scale */}}
373
+ >
374
+ <ImageViewerContent
375
+ file={file}
376
+ src={src}
377
+ lqip={lqip}
378
+ isLoading={isLoading}
379
+ isReady={isReady}
380
+ contentKey={contentKey}
381
+ isLarge={isLarge}
382
+ transform={transform}
383
+ transformStyle={transformStyle}
384
+ rotate={rotate}
385
+ flipH={flipH}
386
+ flipV={flipV}
387
+ onExpand={() => setDialogOpen(true)}
388
+ inDialog={inDialog}
389
+ />
390
+ </TransformWrapper>
391
+ );
392
+
393
+ return (
394
+ <>
395
+ <div className="relative w-full h-[400px] overflow-hidden bg-checkerboard rounded-lg">
396
+ {viewer}
397
+ </div>
398
+
399
+ {/* Fullscreen dialog */}
400
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
401
+ <DialogContent className="max-w-[95vw] max-h-[95vh] p-0">
402
+ <ImageViewer file={file} content={content} inDialog />
403
+ </DialogContent>
404
+ </Dialog>
405
+ </>
406
+ );
407
+ }
408
+ ```
409
+
410
+ ### components/index.ts
411
+
412
+ ```typescript
413
+ /**
414
+ * ImageViewer components - Public API
415
+ */
416
+
417
+ export { ImageViewer } from './ImageViewer';
418
+ export { ImageToolbar } from './ImageToolbar';
419
+ export { ImageInfo } from './ImageInfo';
420
+ ```
421
+
422
+ ## Notes
423
+
424
+ - ImageToolbar uses `useControls` from react-zoom-pan-pinch
425
+ - ImageInfo caches dimensions for performance
426
+ - Main component orchestrates everything
427
+ - Fullscreen dialog renders nested ImageViewer with `inDialog` flag