@djangocfg/ui-nextjs 2.1.66 → 2.1.68
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 +474 -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,121 @@
|
|
|
1
|
+
# Phase 1: Types Extraction
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Extract all type definitions from ImageViewer.tsx into a dedicated types.ts file.
|
|
6
|
+
|
|
7
|
+
## Types to Extract
|
|
8
|
+
|
|
9
|
+
### ImageFile (Props)
|
|
10
|
+
```typescript
|
|
11
|
+
export interface ImageFile {
|
|
12
|
+
/** Display name for the image */
|
|
13
|
+
name: string;
|
|
14
|
+
/** File path used for change detection and caching */
|
|
15
|
+
path: string;
|
|
16
|
+
/** Optional MIME type */
|
|
17
|
+
mimeType?: string;
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### ImageViewerProps (Props)
|
|
22
|
+
```typescript
|
|
23
|
+
export interface ImageViewerProps {
|
|
24
|
+
/** Image file metadata */
|
|
25
|
+
file: ImageFile;
|
|
26
|
+
/** Image content as string or ArrayBuffer */
|
|
27
|
+
content: string | ArrayBuffer;
|
|
28
|
+
/** Whether viewer is inside a dialog (hides expand button) */
|
|
29
|
+
inDialog?: boolean;
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### ImageTransform (State)
|
|
34
|
+
```typescript
|
|
35
|
+
export interface ImageTransform {
|
|
36
|
+
/** Rotation angle: 0, 90, 180, or 270 degrees */
|
|
37
|
+
rotation: number;
|
|
38
|
+
/** Horizontal flip state */
|
|
39
|
+
flipH: boolean;
|
|
40
|
+
/** Vertical flip state */
|
|
41
|
+
flipV: boolean;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### ZoomPreset (UI)
|
|
46
|
+
```typescript
|
|
47
|
+
export interface ZoomPreset {
|
|
48
|
+
/** Zoom level (1 = 100%) */
|
|
49
|
+
value: number;
|
|
50
|
+
/** Display label (e.g., "100%") */
|
|
51
|
+
label: string;
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### ImageToolbarProps (Internal)
|
|
56
|
+
```typescript
|
|
57
|
+
export interface ImageToolbarProps {
|
|
58
|
+
/** Current zoom scale */
|
|
59
|
+
scale: number;
|
|
60
|
+
/** Expand to fullscreen callback */
|
|
61
|
+
onExpand: () => void;
|
|
62
|
+
/** Rotate image callback */
|
|
63
|
+
onRotate: () => void;
|
|
64
|
+
/** Flip horizontal callback */
|
|
65
|
+
onFlipH: () => void;
|
|
66
|
+
/** Flip vertical callback */
|
|
67
|
+
onFlipV: () => void;
|
|
68
|
+
/** Whether horizontal flip is active */
|
|
69
|
+
flipH: boolean;
|
|
70
|
+
/** Whether vertical flip is active */
|
|
71
|
+
flipV: boolean;
|
|
72
|
+
/** Whether inside dialog (hides expand) */
|
|
73
|
+
inDialog?: boolean;
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### ImageInfoProps (Internal)
|
|
78
|
+
```typescript
|
|
79
|
+
export interface ImageInfoProps {
|
|
80
|
+
/** Blob URL source of the image */
|
|
81
|
+
src: string;
|
|
82
|
+
/** Content key for cache lookup */
|
|
83
|
+
contentKey: string;
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## File Structure
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
// types.ts
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* ImageViewer type definitions
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
// =============================================================================
|
|
97
|
+
// FILE TYPES
|
|
98
|
+
// =============================================================================
|
|
99
|
+
|
|
100
|
+
export interface ImageFile { ... }
|
|
101
|
+
|
|
102
|
+
// =============================================================================
|
|
103
|
+
// COMPONENT PROPS
|
|
104
|
+
// =============================================================================
|
|
105
|
+
|
|
106
|
+
export interface ImageViewerProps { ... }
|
|
107
|
+
export interface ImageToolbarProps { ... }
|
|
108
|
+
export interface ImageInfoProps { ... }
|
|
109
|
+
|
|
110
|
+
// =============================================================================
|
|
111
|
+
// STATE TYPES
|
|
112
|
+
// =============================================================================
|
|
113
|
+
|
|
114
|
+
export interface ImageTransform { ... }
|
|
115
|
+
|
|
116
|
+
// =============================================================================
|
|
117
|
+
// UI TYPES
|
|
118
|
+
// =============================================================================
|
|
119
|
+
|
|
120
|
+
export interface ZoomPreset { ... }
|
|
121
|
+
```
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Phase 2: Utils Extraction
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Extract constants and utility functions into dedicated files.
|
|
6
|
+
|
|
7
|
+
## Files to Create
|
|
8
|
+
|
|
9
|
+
### utils/constants.ts
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
/**
|
|
13
|
+
* ImageViewer constants
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ZoomPreset } from '../types';
|
|
17
|
+
|
|
18
|
+
// =============================================================================
|
|
19
|
+
// SIZE LIMITS
|
|
20
|
+
// =============================================================================
|
|
21
|
+
|
|
22
|
+
/** Maximum image size before blocking (50MB) */
|
|
23
|
+
export const MAX_IMAGE_SIZE = 50 * 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
/** Image size threshold for warning (10MB) */
|
|
26
|
+
export const WARN_IMAGE_SIZE = 10 * 1024 * 1024;
|
|
27
|
+
|
|
28
|
+
// =============================================================================
|
|
29
|
+
// ZOOM CONFIGURATION
|
|
30
|
+
// =============================================================================
|
|
31
|
+
|
|
32
|
+
/** Minimum zoom level */
|
|
33
|
+
export const MIN_ZOOM = 0.25;
|
|
34
|
+
|
|
35
|
+
/** Maximum zoom level */
|
|
36
|
+
export const MAX_ZOOM = 4;
|
|
37
|
+
|
|
38
|
+
/** Available zoom presets */
|
|
39
|
+
export const ZOOM_PRESETS: ZoomPreset[] = [
|
|
40
|
+
{ value: 0.25, label: '25%' },
|
|
41
|
+
{ value: 0.5, label: '50%' },
|
|
42
|
+
{ value: 1, label: '100%' },
|
|
43
|
+
{ value: 2, label: '200%' },
|
|
44
|
+
{ value: 4, label: '400%' },
|
|
45
|
+
{ value: -1, label: 'Fit' }, // Special value for fit-to-view
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// DEFAULT VALUES
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
/** Default transform state */
|
|
53
|
+
export const DEFAULT_TRANSFORM: ImageTransform = {
|
|
54
|
+
rotation: 0,
|
|
55
|
+
flipH: false,
|
|
56
|
+
flipV: false,
|
|
57
|
+
};
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### utils/lqip.ts
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
/**
|
|
64
|
+
* LQIP (Low-Quality Image Placeholder) generator
|
|
65
|
+
*
|
|
66
|
+
* Creates a tiny blurred preview image for progressive loading.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
// =============================================================================
|
|
70
|
+
// CONSTANTS
|
|
71
|
+
// =============================================================================
|
|
72
|
+
|
|
73
|
+
const LQIP_SIZE = 32;
|
|
74
|
+
const LQIP_QUALITY = 0.5;
|
|
75
|
+
|
|
76
|
+
// =============================================================================
|
|
77
|
+
// GENERATOR
|
|
78
|
+
// =============================================================================
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Create a low-quality image placeholder from source URL
|
|
82
|
+
*
|
|
83
|
+
* @param src - Full quality image URL
|
|
84
|
+
* @returns Data URL of tiny preview, or null on error
|
|
85
|
+
*/
|
|
86
|
+
export async function createLQIP(src: string): Promise<string | null> {
|
|
87
|
+
return new Promise((resolve) => {
|
|
88
|
+
const img = new Image();
|
|
89
|
+
|
|
90
|
+
img.onload = () => {
|
|
91
|
+
try {
|
|
92
|
+
// Calculate aspect-preserving dimensions
|
|
93
|
+
const aspectRatio = img.width / img.height;
|
|
94
|
+
const width = aspectRatio >= 1 ? LQIP_SIZE : Math.round(LQIP_SIZE * aspectRatio);
|
|
95
|
+
const height = aspectRatio >= 1 ? Math.round(LQIP_SIZE / aspectRatio) : LQIP_SIZE;
|
|
96
|
+
|
|
97
|
+
// Create tiny canvas
|
|
98
|
+
const canvas = document.createElement('canvas');
|
|
99
|
+
canvas.width = width;
|
|
100
|
+
canvas.height = height;
|
|
101
|
+
|
|
102
|
+
// Draw and export as JPEG
|
|
103
|
+
const ctx = canvas.getContext('2d');
|
|
104
|
+
if (ctx) {
|
|
105
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
106
|
+
resolve(canvas.toDataURL('image/jpeg', LQIP_QUALITY));
|
|
107
|
+
} else {
|
|
108
|
+
resolve(null);
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
resolve(null);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
img.onerror = () => resolve(null);
|
|
116
|
+
img.src = src;
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### utils/index.ts
|
|
122
|
+
|
|
123
|
+
```typescript
|
|
124
|
+
/**
|
|
125
|
+
* ImageViewer utilities - Public API
|
|
126
|
+
*/
|
|
127
|
+
|
|
128
|
+
export { createLQIP } from './lqip';
|
|
129
|
+
export {
|
|
130
|
+
MAX_IMAGE_SIZE,
|
|
131
|
+
WARN_IMAGE_SIZE,
|
|
132
|
+
MIN_ZOOM,
|
|
133
|
+
MAX_ZOOM,
|
|
134
|
+
ZOOM_PRESETS,
|
|
135
|
+
DEFAULT_TRANSFORM,
|
|
136
|
+
} from './constants';
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Notes
|
|
140
|
+
|
|
141
|
+
- Constants are separated for easy modification
|
|
142
|
+
- LQIP is async and handles errors gracefully
|
|
143
|
+
- No dependencies on React in utils
|
|
@@ -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
|