@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.
- package/package.json +13 -8
- package/src/blocks/SplitHero/SplitHeroMedia.tsx +2 -1
- 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 +325 -0
- package/src/tools/AudioPlayer/components/AudioEqualizer.tsx +200 -0
- package/src/tools/AudioPlayer/components/AudioPlayer.tsx +231 -0
- package/src/tools/AudioPlayer/components/AudioShortcutsPopover.tsx +99 -0
- 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/components/SimpleAudioPlayer.tsx +280 -0
- package/src/tools/AudioPlayer/components/VisualizationToggle.tsx +64 -0
- 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/effects/index.ts +412 -0
- package/src/tools/AudioPlayer/hooks/index.ts +29 -0
- package/src/tools/AudioPlayer/hooks/useAudioAnalysis.ts +110 -0
- package/src/tools/AudioPlayer/hooks/useAudioHotkeys.ts +149 -0
- package/src/tools/AudioPlayer/hooks/useSharedWebAudio.ts +106 -0
- package/src/tools/AudioPlayer/hooks/useVisualization.tsx +201 -0
- package/src/tools/AudioPlayer/index.ts +139 -0
- package/src/tools/AudioPlayer/types/audio.ts +107 -0
- package/src/tools/AudioPlayer/types/components.ts +98 -0
- 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 +174 -0
- 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 +60 -0
- 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 +212 -187
- package/src/tools/VideoPlayer/{VideoControls.tsx → components/VideoControls.tsx} +8 -9
- package/src/tools/VideoPlayer/components/VideoErrorFallback.tsx +174 -0
- package/src/tools/VideoPlayer/components/VideoPlayer.tsx +201 -0
- 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 +70 -9
- package/src/tools/VideoPlayer/providers/NativeProvider.tsx +206 -0
- package/src/tools/VideoPlayer/providers/StreamProvider.tsx +401 -0
- package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +332 -0
- package/src/tools/VideoPlayer/providers/index.ts +8 -0
- 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 +92 -4
- package/src/tools/VideoPlayer/NativePlayer.tsx +0 -141
- package/src/tools/VideoPlayer/VideoPlayer.tsx +0 -231
- 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
|