@djangocfg/ui-nextjs 2.1.66 → 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.
- package/package.json +8 -6
- package/src/stores/index.ts +8 -0
- package/src/stores/mediaCache.ts +464 -0
- package/src/tools/AudioPlayer/@refactoring/00-PLAN.md +148 -0
- package/src/tools/AudioPlayer/@refactoring/01-TYPES.md +301 -0
- package/src/tools/AudioPlayer/@refactoring/02-HOOKS.md +281 -0
- package/src/tools/AudioPlayer/@refactoring/03-CONTEXT.md +328 -0
- package/src/tools/AudioPlayer/@refactoring/04-COMPONENTS.md +251 -0
- package/src/tools/AudioPlayer/@refactoring/05-EFFECTS.md +427 -0
- package/src/tools/AudioPlayer/@refactoring/06-UTILS-AND-INDEX.md +193 -0
- package/src/tools/AudioPlayer/@refactoring/07-EXECUTION-CHECKLIST.md +146 -0
- package/src/tools/AudioPlayer/README.md +35 -11
- package/src/tools/AudioPlayer/{AudioEqualizer.tsx → components/AudioEqualizer.tsx} +29 -64
- package/src/tools/AudioPlayer/{AudioPlayer.tsx → components/AudioPlayer.tsx} +22 -14
- package/src/tools/AudioPlayer/{AudioShortcutsPopover.tsx → components/AudioShortcutsPopover.tsx} +6 -2
- package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +147 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +110 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +58 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +45 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +82 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +8 -0
- package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +6 -0
- package/src/tools/AudioPlayer/{SimpleAudioPlayer.tsx → components/SimpleAudioPlayer.tsx} +12 -7
- package/src/tools/AudioPlayer/{VisualizationToggle.tsx → components/VisualizationToggle.tsx} +2 -6
- package/src/tools/AudioPlayer/components/index.ts +21 -0
- package/src/tools/AudioPlayer/context/AudioProvider.tsx +292 -0
- package/src/tools/AudioPlayer/context/index.ts +11 -0
- package/src/tools/AudioPlayer/context/selectors.ts +96 -0
- package/src/tools/AudioPlayer/hooks/index.ts +29 -0
- package/src/tools/AudioPlayer/hooks/useAudioAnalysis.ts +110 -0
- package/src/tools/AudioPlayer/{useAudioHotkeys.ts → hooks/useAudioHotkeys.ts} +11 -4
- package/src/tools/AudioPlayer/hooks/useSharedWebAudio.ts +106 -0
- package/src/tools/AudioPlayer/{useAudioVisualization.tsx → hooks/useVisualization.tsx} +11 -5
- package/src/tools/AudioPlayer/index.ts +104 -49
- package/src/tools/AudioPlayer/types/audio.ts +107 -0
- package/src/tools/AudioPlayer/{types.ts → types/components.ts} +20 -84
- package/src/tools/AudioPlayer/types/effects.ts +73 -0
- package/src/tools/AudioPlayer/types/index.ts +35 -0
- package/src/tools/AudioPlayer/utils/formatTime.ts +10 -0
- package/src/tools/AudioPlayer/utils/index.ts +5 -0
- package/src/tools/ImageViewer/@refactoring/00-PLAN.md +71 -0
- package/src/tools/ImageViewer/@refactoring/01-TYPES.md +121 -0
- package/src/tools/ImageViewer/@refactoring/02-UTILS.md +143 -0
- package/src/tools/ImageViewer/@refactoring/03-HOOKS.md +261 -0
- package/src/tools/ImageViewer/@refactoring/04-COMPONENTS.md +427 -0
- package/src/tools/ImageViewer/@refactoring/05-EXECUTION-CHECKLIST.md +126 -0
- package/src/tools/ImageViewer/README.md +16 -3
- package/src/tools/ImageViewer/components/ImageInfo.tsx +44 -0
- package/src/tools/ImageViewer/components/ImageToolbar.tsx +150 -0
- package/src/tools/ImageViewer/components/ImageViewer.tsx +235 -0
- package/src/tools/ImageViewer/components/index.ts +7 -0
- package/src/tools/ImageViewer/hooks/index.ts +9 -0
- package/src/tools/ImageViewer/hooks/useImageLoading.ts +153 -0
- package/src/tools/ImageViewer/hooks/useImageTransform.ts +101 -0
- package/src/tools/ImageViewer/index.ts +47 -3
- package/src/tools/ImageViewer/types.ts +75 -0
- package/src/tools/ImageViewer/utils/constants.ts +59 -0
- package/src/tools/ImageViewer/utils/index.ts +16 -0
- package/src/tools/ImageViewer/utils/lqip.ts +47 -0
- package/src/tools/VideoPlayer/@refactoring/00-PLAN.md +91 -0
- package/src/tools/VideoPlayer/@refactoring/01-TYPES.md +284 -0
- package/src/tools/VideoPlayer/@refactoring/02-UTILS.md +141 -0
- package/src/tools/VideoPlayer/@refactoring/03-HOOKS.md +178 -0
- package/src/tools/VideoPlayer/@refactoring/04-COMPONENTS.md +95 -0
- package/src/tools/VideoPlayer/@refactoring/05-EXECUTION-CHECKLIST.md +139 -0
- package/src/tools/VideoPlayer/README.md +26 -10
- package/src/tools/VideoPlayer/{VideoControls.tsx → components/VideoControls.tsx} +8 -9
- package/src/tools/VideoPlayer/{VideoErrorFallback.tsx → components/VideoErrorFallback.tsx} +2 -2
- package/src/tools/VideoPlayer/{VideoPlayer.tsx → components/VideoPlayer.tsx} +4 -5
- package/src/tools/VideoPlayer/components/index.ts +14 -0
- package/src/tools/VideoPlayer/context/VideoPlayerContext.tsx +52 -0
- package/src/tools/VideoPlayer/context/index.ts +8 -0
- package/src/tools/VideoPlayer/hooks/index.ts +9 -0
- package/src/tools/VideoPlayer/hooks/useVideoPositionCache.ts +109 -0
- package/src/tools/VideoPlayer/index.ts +29 -20
- package/src/tools/VideoPlayer/providers/StreamProvider.tsx +118 -28
- package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +89 -11
- package/src/tools/VideoPlayer/types/index.ts +38 -0
- package/src/tools/VideoPlayer/types/player.ts +116 -0
- package/src/tools/VideoPlayer/types/provider.ts +93 -0
- package/src/tools/VideoPlayer/types/sources.ts +97 -0
- package/src/tools/VideoPlayer/utils/fileSource.ts +78 -0
- package/src/tools/VideoPlayer/utils/index.ts +11 -0
- package/src/tools/VideoPlayer/utils/resolvers.ts +75 -0
- package/src/tools/index.ts +10 -0
- package/src/tools/AudioPlayer/AudioReactiveCover.tsx +0 -389
- package/src/tools/AudioPlayer/context.tsx +0 -426
- package/src/tools/ImageViewer/ImageViewer.tsx +0 -416
- package/src/tools/VideoPlayer/VideoPlayerContext.tsx +0 -125
- package/src/tools/VideoPlayer/types.ts +0 -367
|
@@ -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
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Execution Checklist
|
|
2
|
+
|
|
3
|
+
## Pre-flight
|
|
4
|
+
|
|
5
|
+
- [ ] Read current ImageViewer.tsx
|
|
6
|
+
- [ ] Backup or rename original file
|
|
7
|
+
- [ ] Create folder structure
|
|
8
|
+
|
|
9
|
+
## Phase 1: Folder Structure
|
|
10
|
+
|
|
11
|
+
- [ ] Create `components/` directory
|
|
12
|
+
- [ ] Create `hooks/` directory
|
|
13
|
+
- [ ] Create `utils/` directory
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
mkdir -p components hooks utils
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Phase 2: Types
|
|
20
|
+
|
|
21
|
+
- [ ] Create `types.ts` with all interfaces
|
|
22
|
+
- ImageFile
|
|
23
|
+
- ImageViewerProps
|
|
24
|
+
- ImageTransform
|
|
25
|
+
- ZoomPreset
|
|
26
|
+
- ImageToolbarProps
|
|
27
|
+
- ImageInfoProps
|
|
28
|
+
- [ ] Run `pnpm check`
|
|
29
|
+
|
|
30
|
+
## Phase 3: Utils
|
|
31
|
+
|
|
32
|
+
- [ ] Create `utils/constants.ts`
|
|
33
|
+
- MAX_IMAGE_SIZE, WARN_IMAGE_SIZE
|
|
34
|
+
- MIN_ZOOM, MAX_ZOOM
|
|
35
|
+
- ZOOM_PRESETS
|
|
36
|
+
- DEFAULT_TRANSFORM
|
|
37
|
+
- [ ] Create `utils/lqip.ts`
|
|
38
|
+
- createLQIP function
|
|
39
|
+
- [ ] Create `utils/index.ts`
|
|
40
|
+
- Re-export all
|
|
41
|
+
- [ ] Run `pnpm check`
|
|
42
|
+
|
|
43
|
+
## Phase 4: Hooks
|
|
44
|
+
|
|
45
|
+
- [ ] Create `hooks/useImageTransform.ts`
|
|
46
|
+
- Transform state management
|
|
47
|
+
- rotate, flipH, flipV callbacks
|
|
48
|
+
- transformStyle computed value
|
|
49
|
+
- [ ] Create `hooks/useImageLoading.ts`
|
|
50
|
+
- Blob URL management
|
|
51
|
+
- LQIP generation
|
|
52
|
+
- Loading/error states
|
|
53
|
+
- [ ] Create `hooks/index.ts`
|
|
54
|
+
- Re-export all
|
|
55
|
+
- [ ] Run `pnpm check`
|
|
56
|
+
|
|
57
|
+
## Phase 5: Components
|
|
58
|
+
|
|
59
|
+
- [ ] Create `components/ImageToolbar.tsx`
|
|
60
|
+
- Zoom controls
|
|
61
|
+
- Rotate/flip buttons
|
|
62
|
+
- Preset dropdown
|
|
63
|
+
- [ ] Create `components/ImageInfo.tsx`
|
|
64
|
+
- Dimensions display
|
|
65
|
+
- [ ] Create `components/ImageViewer.tsx`
|
|
66
|
+
- Main component (simplified)
|
|
67
|
+
- Uses hooks
|
|
68
|
+
- Includes fullscreen dialog
|
|
69
|
+
- [ ] Create `components/index.ts`
|
|
70
|
+
- Re-export all
|
|
71
|
+
- [ ] Run `pnpm check`
|
|
72
|
+
|
|
73
|
+
## Phase 6: Main Index
|
|
74
|
+
|
|
75
|
+
- [ ] Update `index.ts` with new imports
|
|
76
|
+
- Export ImageViewer from components
|
|
77
|
+
- Export types
|
|
78
|
+
- Export hooks (if public)
|
|
79
|
+
- [ ] Run `pnpm check`
|
|
80
|
+
|
|
81
|
+
## Phase 7: Cleanup
|
|
82
|
+
|
|
83
|
+
- [ ] Delete old `ImageViewer.tsx`
|
|
84
|
+
- [ ] Verify all exports work
|
|
85
|
+
- [ ] Run `pnpm check`
|
|
86
|
+
- [ ] Run `pnpm build` (optional)
|
|
87
|
+
|
|
88
|
+
## Post-flight
|
|
89
|
+
|
|
90
|
+
- [ ] Test in browser
|
|
91
|
+
- [ ] Verify zoom/pan/rotate work
|
|
92
|
+
- [ ] Verify fullscreen works
|
|
93
|
+
- [ ] Verify LQIP loads for large images
|
|
94
|
+
- [ ] Verify error states display
|
|
95
|
+
|
|
96
|
+
## Final Structure
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
ImageViewer/
|
|
100
|
+
├── index.ts
|
|
101
|
+
├── types.ts
|
|
102
|
+
├── README.md
|
|
103
|
+
├── components/
|
|
104
|
+
│ ├── index.ts
|
|
105
|
+
│ ├── ImageViewer.tsx
|
|
106
|
+
│ ├── ImageToolbar.tsx
|
|
107
|
+
│ └── ImageInfo.tsx
|
|
108
|
+
├── hooks/
|
|
109
|
+
│ ├── index.ts
|
|
110
|
+
│ ├── useImageTransform.ts
|
|
111
|
+
│ └── useImageLoading.ts
|
|
112
|
+
└── utils/
|
|
113
|
+
├── index.ts
|
|
114
|
+
├── constants.ts
|
|
115
|
+
└── lqip.ts
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Metrics
|
|
119
|
+
|
|
120
|
+
| Metric | Before | After |
|
|
121
|
+
|--------|--------|-------|
|
|
122
|
+
| Main file lines | 589 | ~150 |
|
|
123
|
+
| Total files | 3 | 12 |
|
|
124
|
+
| Sub-components | 2 inline | 2 separate |
|
|
125
|
+
| Custom hooks | 0 | 2 |
|
|
126
|
+
| Utility files | 0 | 2 |
|
|
@@ -149,9 +149,22 @@ function FilePreview({ file, content }: { file: OpenFile; content: ArrayBuffer }
|
|
|
149
149
|
|
|
150
150
|
```
|
|
151
151
|
ImageViewer/
|
|
152
|
-
├── index.ts
|
|
153
|
-
├──
|
|
154
|
-
|
|
152
|
+
├── index.ts # Public API exports
|
|
153
|
+
├── types.ts # Type definitions
|
|
154
|
+
├── components/
|
|
155
|
+
│ ├── index.ts # Component exports
|
|
156
|
+
│ ├── ImageViewer.tsx # Main viewer component
|
|
157
|
+
│ ├── ImageToolbar.tsx # Zoom/rotate/flip controls
|
|
158
|
+
│ └── ImageInfo.tsx # Dimensions display
|
|
159
|
+
├── hooks/
|
|
160
|
+
│ ├── index.ts
|
|
161
|
+
│ ├── useImageLoading.ts # Blob URL & LQIP management
|
|
162
|
+
│ └── useImageTransform.ts # Rotation/flip state
|
|
163
|
+
├── utils/
|
|
164
|
+
│ ├── index.ts
|
|
165
|
+
│ ├── constants.ts # Size limits, zoom presets
|
|
166
|
+
│ └── lqip.ts # Low-Quality Image Placeholder
|
|
167
|
+
└── README.md
|
|
155
168
|
```
|
|
156
169
|
|
|
157
170
|
## Dependencies
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ImageInfo - Displays image dimensions badge
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useState } from 'react';
|
|
8
|
+
import { useImageCache } from '../../../stores/mediaCache';
|
|
9
|
+
import type { ImageInfoProps } from '../types';
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// COMPONENT
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
export function ImageInfo({ src }: ImageInfoProps) {
|
|
16
|
+
const { getDimensions, cacheDimensions } = useImageCache();
|
|
17
|
+
const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
// Check cache first
|
|
21
|
+
const cached = getDimensions(src);
|
|
22
|
+
if (cached) {
|
|
23
|
+
setDimensions({ w: cached.width, h: cached.height });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Load and cache dimensions
|
|
28
|
+
const img = new Image();
|
|
29
|
+
img.onload = () => {
|
|
30
|
+
const dims = { w: img.naturalWidth, h: img.naturalHeight };
|
|
31
|
+
setDimensions(dims);
|
|
32
|
+
cacheDimensions(src, { width: dims.w, height: dims.h });
|
|
33
|
+
};
|
|
34
|
+
img.src = src;
|
|
35
|
+
}, [src, getDimensions, cacheDimensions]);
|
|
36
|
+
|
|
37
|
+
if (!dimensions) return null;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="absolute top-3 right-3 z-10 px-2 py-1 bg-background/80 backdrop-blur-sm border rounded text-[10px] text-muted-foreground font-mono">
|
|
41
|
+
{dimensions.w} × {dimensions.h}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|