@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,235 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ImageViewer - Image viewer with zoom, pan, rotate, flip
5
+ *
6
+ * Features:
7
+ * - Zoom with mouse wheel and presets
8
+ * - Pan with drag
9
+ * - Rotate 90°
10
+ * - Flip horizontal/vertical
11
+ * - Fullscreen dialog
12
+ * - Keyboard shortcuts (+/-, 0, r)
13
+ */
14
+
15
+ import { useEffect, useState, useRef, useCallback } from 'react';
16
+ import { ImageIcon, AlertCircle } from 'lucide-react';
17
+ import { TransformWrapper, TransformComponent, useControls } from 'react-zoom-pan-pinch';
18
+ import { cn, Dialog, DialogContent, Alert, AlertDescription } from '@djangocfg/ui-core';
19
+
20
+ import { ImageToolbar } from './ImageToolbar';
21
+ import { ImageInfo } from './ImageInfo';
22
+ import { useImageTransform, useImageLoading } from '../hooks';
23
+ import type { ImageViewerProps } from '../types';
24
+
25
+ // =============================================================================
26
+ // COMPONENT
27
+ // =============================================================================
28
+
29
+ export function ImageViewer({ file, content, inDialog = false }: ImageViewerProps) {
30
+ const [scale, setScale] = useState(1);
31
+ const [dialogOpen, setDialogOpen] = useState(false);
32
+ const containerRef = useRef<HTMLDivElement>(null);
33
+ const controlsRef = useRef<ReturnType<typeof useControls> | null>(null);
34
+
35
+ // Loading state
36
+ const {
37
+ src,
38
+ lqip,
39
+ isFullyLoaded,
40
+ useProgressiveLoading,
41
+ error,
42
+ hasContent,
43
+ } = useImageLoading({ content, mimeType: file.mimeType });
44
+
45
+ // Transform state
46
+ const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({
47
+ resetKey: file.path,
48
+ });
49
+
50
+ // Zoom preset handler
51
+ const handleZoomPreset = useCallback((value: number | 'fit') => {
52
+ if (!controlsRef.current) return;
53
+ if (value === 'fit') {
54
+ controlsRef.current.resetTransform();
55
+ } else {
56
+ controlsRef.current.setTransform(0, 0, value);
57
+ }
58
+ }, []);
59
+
60
+ // Expand to fullscreen
61
+ const handleExpand = useCallback(() => {
62
+ setDialogOpen(true);
63
+ }, []);
64
+
65
+ // Keyboard shortcuts
66
+ useEffect(() => {
67
+ const handleKeyDown = (e: KeyboardEvent) => {
68
+ if (!containerRef.current?.contains(document.activeElement) &&
69
+ document.activeElement !== containerRef.current) {
70
+ return;
71
+ }
72
+
73
+ const controls = controlsRef.current;
74
+ if (!controls) return;
75
+
76
+ switch (e.key) {
77
+ case '+':
78
+ case '=':
79
+ e.preventDefault();
80
+ controls.zoomIn();
81
+ break;
82
+ case '-':
83
+ e.preventDefault();
84
+ controls.zoomOut();
85
+ break;
86
+ case '0':
87
+ e.preventDefault();
88
+ controls.resetTransform();
89
+ break;
90
+ case 'r':
91
+ if (!e.metaKey && !e.ctrlKey) {
92
+ e.preventDefault();
93
+ rotate();
94
+ }
95
+ break;
96
+ }
97
+ };
98
+
99
+ window.addEventListener('keydown', handleKeyDown);
100
+ return () => window.removeEventListener('keydown', handleKeyDown);
101
+ }, [rotate]);
102
+
103
+ // Show error for oversized images
104
+ if (error) {
105
+ return (
106
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 bg-muted/30 p-4">
107
+ <AlertCircle className="w-12 h-12 text-destructive/70" />
108
+ <Alert variant="destructive" className="max-w-md">
109
+ <AlertCircle className="h-4 w-4" />
110
+ <AlertDescription>{error}</AlertDescription>
111
+ </Alert>
112
+ </div>
113
+ );
114
+ }
115
+
116
+ // No content
117
+ if (!hasContent) {
118
+ return (
119
+ <div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
120
+ <ImageIcon className="w-12 h-12 text-muted-foreground/50" />
121
+ <p className="text-sm text-muted-foreground">Failed to load image</p>
122
+ </div>
123
+ );
124
+ }
125
+
126
+ return (
127
+ <div
128
+ ref={containerRef}
129
+ tabIndex={0}
130
+ className={cn(
131
+ 'flex-1 h-full relative overflow-hidden outline-none',
132
+ 'bg-[length:16px_16px]',
133
+ '[background-color:hsl(var(--muted)/0.2)]',
134
+ '[background-image:linear-gradient(45deg,hsl(var(--muted)/0.4)_25%,transparent_25%),linear-gradient(-45deg,hsl(var(--muted)/0.4)_25%,transparent_25%),linear-gradient(45deg,transparent_75%,hsl(var(--muted)/0.4)_75%),linear-gradient(-45deg,transparent_75%,hsl(var(--muted)/0.4)_75%)]',
135
+ '[background-position:0_0,0_8px,8px_-8px,-8px_0px]'
136
+ )}
137
+ >
138
+ {src && <ImageInfo src={src} />}
139
+
140
+ {/* Progressive loading indicator */}
141
+ {useProgressiveLoading && !isFullyLoaded && (
142
+ <div className="absolute top-3 left-3 z-10 px-2 py-1 bg-background/80 backdrop-blur-sm border rounded text-[10px] text-muted-foreground font-mono flex items-center gap-1.5">
143
+ <div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
144
+ Loading...
145
+ </div>
146
+ )}
147
+
148
+ <TransformWrapper
149
+ initialScale={1}
150
+ minScale={0.1}
151
+ maxScale={8}
152
+ centerOnInit
153
+ onTransformed={(ref, state) => {
154
+ setScale(state.scale);
155
+ controlsRef.current = ref;
156
+ }}
157
+ onInit={(ref) => {
158
+ controlsRef.current = ref;
159
+ }}
160
+ wheel={{ step: 0.1 }}
161
+ doubleClick={{ mode: 'toggle', step: 2 }}
162
+ panning={{ velocityDisabled: false }}
163
+ >
164
+ <ImageToolbar
165
+ scale={scale}
166
+ transform={transform}
167
+ onRotate={rotate}
168
+ onFlipH={flipH}
169
+ onFlipV={flipV}
170
+ onZoomPreset={handleZoomPreset}
171
+ onExpand={!inDialog ? handleExpand : undefined}
172
+ />
173
+
174
+ <TransformComponent
175
+ wrapperClass="!w-full !h-full cursor-grab active:cursor-grabbing"
176
+ contentClass="!w-full !h-full flex items-center justify-center"
177
+ >
178
+ <div className="relative">
179
+ {/* LQIP Placeholder (blurred, shown while loading) */}
180
+ {useProgressiveLoading && lqip && !isFullyLoaded && (
181
+ <img
182
+ src={lqip}
183
+ alt=""
184
+ aria-hidden="true"
185
+ className="absolute inset-0 max-w-full max-h-full object-contain select-none"
186
+ style={{
187
+ transform: transformStyle,
188
+ filter: 'blur(20px)',
189
+ transition: 'opacity 0.3s ease-out',
190
+ opacity: isFullyLoaded ? 0 : 1,
191
+ }}
192
+ draggable={false}
193
+ />
194
+ )}
195
+
196
+ {/* Full Image */}
197
+ {src && (
198
+ <img
199
+ src={src}
200
+ alt={file.name}
201
+ className="max-w-full max-h-full object-contain select-none"
202
+ style={{
203
+ transform: transformStyle,
204
+ transition: useProgressiveLoading
205
+ ? 'transform 0.15s ease-out, opacity 0.3s ease-out'
206
+ : 'transform 0.15s ease-out',
207
+ opacity: useProgressiveLoading && !isFullyLoaded ? 0 : 1,
208
+ }}
209
+ draggable={false}
210
+ />
211
+ )}
212
+ </div>
213
+ </TransformComponent>
214
+ </TransformWrapper>
215
+
216
+ {/* Fullscreen dialog */}
217
+ {!inDialog && (
218
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
219
+ <DialogContent className="max-w-[95vw] max-h-[95vh] w-[95vw] h-[95vh] p-0 overflow-hidden [&>button]:hidden">
220
+ <div className="h-full flex flex-col">
221
+ <div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
222
+ <span className="text-sm font-medium truncate">{file.name}</span>
223
+ </div>
224
+ <div className="flex-1 min-h-0 relative">
225
+ <div className="absolute inset-0">
226
+ <ImageViewer file={file} content={content} inDialog />
227
+ </div>
228
+ </div>
229
+ </div>
230
+ </DialogContent>
231
+ </Dialog>
232
+ )}
233
+ </div>
234
+ );
235
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * ImageViewer components - Public API
3
+ */
4
+
5
+ export { ImageViewer } from './ImageViewer';
6
+ export { ImageToolbar } from './ImageToolbar';
7
+ export { ImageInfo } from './ImageInfo';
@@ -0,0 +1,9 @@
1
+ /**
2
+ * ImageViewer hooks - Public API
3
+ */
4
+
5
+ export { useImageTransform } from './useImageTransform';
6
+ export type { UseImageTransformOptions, UseImageTransformReturn } from './useImageTransform';
7
+
8
+ export { useImageLoading } from './useImageLoading';
9
+ export type { UseImageLoadingOptions, UseImageLoadingReturn } from './useImageLoading';
@@ -0,0 +1,153 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useImageLoading - Manages image loading state with LQIP
5
+ */
6
+
7
+ import { useState, useEffect, useRef } from 'react';
8
+ import { useImageCache, generateContentKey } from '../../../stores/mediaCache';
9
+ import { createLQIP, MAX_IMAGE_SIZE, WARNING_IMAGE_SIZE, PROGRESSIVE_LOADING_THRESHOLD } from '../utils';
10
+
11
+ // =============================================================================
12
+ // TYPES
13
+ // =============================================================================
14
+
15
+ export interface UseImageLoadingOptions {
16
+ /** Image content (ArrayBuffer or string) */
17
+ content: string | ArrayBuffer;
18
+ /** MIME type for blob creation */
19
+ mimeType?: string;
20
+ }
21
+
22
+ export interface UseImageLoadingReturn {
23
+ /** Blob URL source for the image */
24
+ src: string | null;
25
+ /** Low-quality placeholder URL */
26
+ lqip: string | null;
27
+ /** Whether full image is loaded */
28
+ isFullyLoaded: boolean;
29
+ /** Whether to use progressive loading */
30
+ useProgressiveLoading: boolean;
31
+ /** Error message if any */
32
+ error: string | null;
33
+ /** Content key for caching */
34
+ contentKey: string | null;
35
+ /** Image size in bytes */
36
+ size: number;
37
+ /** Whether content exists */
38
+ hasContent: boolean;
39
+ }
40
+
41
+ // =============================================================================
42
+ // HOOK
43
+ // =============================================================================
44
+
45
+ export function useImageLoading(options: UseImageLoadingOptions): UseImageLoadingReturn {
46
+ const { content, mimeType } = options;
47
+
48
+ const { getOrCreateBlobUrl, releaseBlobUrl } = useImageCache();
49
+
50
+ const [src, setSrc] = useState<string | null>(null);
51
+ const [lqip, setLqip] = useState<string | null>(null);
52
+ const [isFullyLoaded, setIsFullyLoaded] = useState(false);
53
+ const [error, setError] = useState<string | null>(null);
54
+
55
+ const contentKeyRef = useRef<string | null>(null);
56
+
57
+ // Calculate size and flags
58
+ const size = content ? (typeof content === 'string' ? content.length : content.byteLength) : 0;
59
+ const hasContent = size > 0;
60
+ const useProgressiveLoading = size > PROGRESSIVE_LOADING_THRESHOLD;
61
+
62
+ // Create blob URL with caching and size validation
63
+ useEffect(() => {
64
+ // Reset error state
65
+ setError(null);
66
+
67
+ if (!hasContent) {
68
+ setSrc(null);
69
+ return;
70
+ }
71
+
72
+ // Size validation - reject oversized images
73
+ if (size > MAX_IMAGE_SIZE) {
74
+ const sizeMB = (size / 1024 / 1024).toFixed(1);
75
+ setError(`Image too large: ${sizeMB}MB (maximum: 50MB)`);
76
+ setSrc(null);
77
+ return;
78
+ }
79
+
80
+ // Warn about large images
81
+ if (size > WARNING_IMAGE_SIZE) {
82
+ const sizeMB = (size / 1024 / 1024).toFixed(1);
83
+ console.warn(`[ImageViewer] Large image: ${sizeMB}MB - may impact performance`);
84
+ }
85
+
86
+ // Handle string content (data URLs or binary strings)
87
+ if (typeof content === 'string') {
88
+ // Pass through data URLs directly
89
+ if (content.startsWith('data:')) {
90
+ setSrc(content);
91
+ return;
92
+ }
93
+
94
+ // Convert binary string to ArrayBuffer and use Blob URL
95
+ const encoder = new TextEncoder();
96
+ const buffer = encoder.encode(content).buffer;
97
+ const contentKey = generateContentKey(buffer);
98
+ contentKeyRef.current = contentKey;
99
+ const url = getOrCreateBlobUrl(contentKey, buffer, mimeType || 'image/png');
100
+ setSrc(url);
101
+ return;
102
+ }
103
+
104
+ // Handle ArrayBuffer with cached blob URL
105
+ const contentKey = generateContentKey(content);
106
+ contentKeyRef.current = contentKey;
107
+ const url = getOrCreateBlobUrl(contentKey, content, mimeType || 'image/png');
108
+ setSrc(url);
109
+
110
+ return () => {
111
+ if (contentKeyRef.current) {
112
+ releaseBlobUrl(contentKeyRef.current);
113
+ contentKeyRef.current = null;
114
+ }
115
+ };
116
+ }, [content, mimeType, hasContent, size, getOrCreateBlobUrl, releaseBlobUrl]);
117
+
118
+ // Create LQIP for progressive loading
119
+ useEffect(() => {
120
+ if (!src || !useProgressiveLoading) {
121
+ setLqip(null);
122
+ setIsFullyLoaded(true);
123
+ return;
124
+ }
125
+
126
+ setIsFullyLoaded(false);
127
+
128
+ // Create low-quality placeholder
129
+ createLQIP(src).then((placeholder) => {
130
+ if (placeholder) {
131
+ setLqip(placeholder);
132
+ }
133
+ });
134
+
135
+ // Pre-load full image
136
+ const img = new Image();
137
+ img.onload = () => {
138
+ setIsFullyLoaded(true);
139
+ };
140
+ img.src = src;
141
+ }, [src, useProgressiveLoading]);
142
+
143
+ return {
144
+ src,
145
+ lqip,
146
+ isFullyLoaded,
147
+ useProgressiveLoading,
148
+ error,
149
+ contentKey: contentKeyRef.current,
150
+ size,
151
+ hasContent,
152
+ };
153
+ }
@@ -0,0 +1,101 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useImageTransform - Manages image rotation and flip state
5
+ */
6
+
7
+ import { useState, useCallback, useEffect, useMemo } from 'react';
8
+ import type { ImageTransform } from '../types';
9
+ import { DEFAULT_TRANSFORM } from '../utils';
10
+
11
+ // =============================================================================
12
+ // TYPES
13
+ // =============================================================================
14
+
15
+ export interface UseImageTransformOptions {
16
+ /** Reset transform when this key changes */
17
+ resetKey?: string;
18
+ }
19
+
20
+ export interface UseImageTransformReturn {
21
+ /** Current transform state */
22
+ transform: ImageTransform;
23
+ /** Rotate 90 degrees clockwise */
24
+ rotate: () => void;
25
+ /** Toggle horizontal flip */
26
+ flipH: () => void;
27
+ /** Toggle vertical flip */
28
+ flipV: () => void;
29
+ /** Reset all transforms */
30
+ reset: () => void;
31
+ /** CSS transform string for applying to image */
32
+ transformStyle: string;
33
+ }
34
+
35
+ // =============================================================================
36
+ // HOOK
37
+ // =============================================================================
38
+
39
+ export function useImageTransform(
40
+ options: UseImageTransformOptions = {}
41
+ ): UseImageTransformReturn {
42
+ const { resetKey } = options;
43
+
44
+ const [transform, setTransform] = useState<ImageTransform>(DEFAULT_TRANSFORM);
45
+
46
+ // Reset transform when key changes
47
+ useEffect(() => {
48
+ setTransform(DEFAULT_TRANSFORM);
49
+ }, [resetKey]);
50
+
51
+ const rotate = useCallback(() => {
52
+ setTransform((prev) => ({
53
+ ...prev,
54
+ rotation: (prev.rotation + 90) % 360,
55
+ }));
56
+ }, []);
57
+
58
+ const flipH = useCallback(() => {
59
+ setTransform((prev) => ({
60
+ ...prev,
61
+ flipH: !prev.flipH,
62
+ }));
63
+ }, []);
64
+
65
+ const flipV = useCallback(() => {
66
+ setTransform((prev) => ({
67
+ ...prev,
68
+ flipV: !prev.flipV,
69
+ }));
70
+ }, []);
71
+
72
+ const reset = useCallback(() => {
73
+ setTransform(DEFAULT_TRANSFORM);
74
+ }, []);
75
+
76
+ // Build CSS transform string
77
+ const transformStyle = useMemo(() => {
78
+ const transforms: string[] = [];
79
+
80
+ if (transform.rotation !== 0) {
81
+ transforms.push(`rotate(${transform.rotation}deg)`);
82
+ }
83
+ if (transform.flipH) {
84
+ transforms.push('scaleX(-1)');
85
+ }
86
+ if (transform.flipV) {
87
+ transforms.push('scaleY(-1)');
88
+ }
89
+
90
+ return transforms.join(' ') || 'none';
91
+ }, [transform]);
92
+
93
+ return {
94
+ transform,
95
+ rotate,
96
+ flipH,
97
+ flipV,
98
+ reset,
99
+ transformStyle,
100
+ };
101
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * ImageViewer - Image viewer with zoom, pan, rotate, flip
3
+ *
4
+ * Features:
5
+ * - Zoom with mouse wheel and presets (25%, 50%, 100%, 200%, 400%)
6
+ * - Pan with drag
7
+ * - Rotate 90°
8
+ * - Flip horizontal/vertical
9
+ * - Fullscreen dialog mode
10
+ * - Keyboard shortcuts (+/-, 0 for reset, r for rotate)
11
+ * - Progressive loading with LQIP for large images
12
+ * - Checkerboard background for transparency
13
+ * - Image dimensions display with caching
14
+ */
15
+
16
+ // =============================================================================
17
+ // COMPONENTS
18
+ // =============================================================================
19
+
20
+ export { ImageViewer, ImageToolbar, ImageInfo } from './components';
21
+
22
+ // =============================================================================
23
+ // HOOKS
24
+ // =============================================================================
25
+
26
+ export { useImageTransform, useImageLoading } from './hooks';
27
+ export type {
28
+ UseImageTransformOptions,
29
+ UseImageTransformReturn,
30
+ UseImageLoadingOptions,
31
+ UseImageLoadingReturn,
32
+ } from './hooks';
33
+
34
+ // =============================================================================
35
+ // TYPES
36
+ // =============================================================================
37
+
38
+ export type {
39
+ ImageFile,
40
+ ImageViewerProps,
41
+ ImageToolbarProps,
42
+ ImageInfoProps,
43
+ ImageTransform,
44
+ ZoomPreset,
45
+ } from './types';
46
+
47
+ // =============================================================================
48
+ // UTILS
49
+ // =============================================================================
50
+
51
+ export {
52
+ createLQIP,
53
+ MAX_IMAGE_SIZE,
54
+ WARNING_IMAGE_SIZE,
55
+ PROGRESSIVE_LOADING_THRESHOLD,
56
+ MIN_ZOOM,
57
+ MAX_ZOOM,
58
+ ZOOM_PRESETS,
59
+ DEFAULT_TRANSFORM,
60
+ } from './utils';
@@ -0,0 +1,75 @@
1
+ /**
2
+ * ImageViewer type definitions
3
+ */
4
+
5
+ // =============================================================================
6
+ // FILE TYPES
7
+ // =============================================================================
8
+
9
+ export interface ImageFile {
10
+ /** Display name for the image */
11
+ name: string;
12
+ /** File path used for change detection and caching */
13
+ path: string;
14
+ /** Optional MIME type */
15
+ mimeType?: string;
16
+ }
17
+
18
+ // =============================================================================
19
+ // COMPONENT PROPS
20
+ // =============================================================================
21
+
22
+ export interface ImageViewerProps {
23
+ /** File info (name, path, mimeType) */
24
+ file: ImageFile;
25
+ /** Image content as string (data URL or base64) or ArrayBuffer */
26
+ content: string | ArrayBuffer;
27
+ /** Hide expand button when already in dialog */
28
+ inDialog?: boolean;
29
+ }
30
+
31
+ export interface ImageToolbarProps {
32
+ /** Current zoom scale */
33
+ scale: number;
34
+ /** Current transform state */
35
+ transform: ImageTransform;
36
+ /** Rotate image callback */
37
+ onRotate: () => void;
38
+ /** Flip horizontal callback */
39
+ onFlipH: () => void;
40
+ /** Flip vertical callback */
41
+ onFlipV: () => void;
42
+ /** Zoom preset selection callback */
43
+ onZoomPreset: (value: number | 'fit') => void;
44
+ /** Expand to fullscreen callback (undefined hides button) */
45
+ onExpand?: () => void;
46
+ }
47
+
48
+ export interface ImageInfoProps {
49
+ /** Image source URL */
50
+ src: string;
51
+ }
52
+
53
+ // =============================================================================
54
+ // STATE TYPES
55
+ // =============================================================================
56
+
57
+ export interface ImageTransform {
58
+ /** Rotation angle: 0, 90, 180, or 270 degrees */
59
+ rotation: number;
60
+ /** Horizontal flip state */
61
+ flipH: boolean;
62
+ /** Vertical flip state */
63
+ flipV: boolean;
64
+ }
65
+
66
+ // =============================================================================
67
+ // UI TYPES
68
+ // =============================================================================
69
+
70
+ export interface ZoomPreset {
71
+ /** Display label (e.g., "100%", "Fit") */
72
+ label: string;
73
+ /** Zoom value or 'fit' for fit-to-view */
74
+ value: number | 'fit';
75
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * ImageViewer constants
3
+ */
4
+
5
+ import type { ZoomPreset, ImageTransform } from '../types';
6
+
7
+ // =============================================================================
8
+ // SIZE LIMITS
9
+ // =============================================================================
10
+
11
+ /** Maximum image size before blocking (50MB) */
12
+ export const MAX_IMAGE_SIZE = 50 * 1024 * 1024;
13
+
14
+ /** Image size threshold for warning (10MB) */
15
+ export const WARNING_IMAGE_SIZE = 10 * 1024 * 1024;
16
+
17
+ /** Progressive loading threshold - use LQIP for images > 500KB */
18
+ export const PROGRESSIVE_LOADING_THRESHOLD = 500 * 1024;
19
+
20
+ // =============================================================================
21
+ // LQIP CONFIGURATION
22
+ // =============================================================================
23
+
24
+ /** Low-quality placeholder size (32x32) */
25
+ export const LQIP_SIZE = 32;
26
+
27
+ /** LQIP JPEG quality */
28
+ export const LQIP_QUALITY = 0.5;
29
+
30
+ // =============================================================================
31
+ // ZOOM CONFIGURATION
32
+ // =============================================================================
33
+
34
+ /** Minimum zoom level */
35
+ export const MIN_ZOOM = 0.1;
36
+
37
+ /** Maximum zoom level */
38
+ export const MAX_ZOOM = 8;
39
+
40
+ /** Available zoom presets */
41
+ export const ZOOM_PRESETS: readonly ZoomPreset[] = [
42
+ { label: 'Fit', value: 'fit' },
43
+ { label: '25%', value: 0.25 },
44
+ { label: '50%', value: 0.5 },
45
+ { label: '100%', value: 1 },
46
+ { label: '200%', value: 2 },
47
+ { label: '400%', value: 4 },
48
+ ] as const;
49
+
50
+ // =============================================================================
51
+ // DEFAULT VALUES
52
+ // =============================================================================
53
+
54
+ /** Default transform state */
55
+ export const DEFAULT_TRANSFORM: ImageTransform = {
56
+ rotation: 0,
57
+ flipH: false,
58
+ flipV: false,
59
+ };