@djangocfg/ui-tools 2.1.91

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 (174) hide show
  1. package/dist/LottiePlayer.client-LBEC2JKY.mjs +161 -0
  2. package/dist/LottiePlayer.client-LBEC2JKY.mjs.map +1 -0
  3. package/dist/LottiePlayer.client-WFMG2OOW.cjs +168 -0
  4. package/dist/LottiePlayer.client-WFMG2OOW.cjs.map +1 -0
  5. package/dist/Mermaid.client-4TU2TSH3.mjs +477 -0
  6. package/dist/Mermaid.client-4TU2TSH3.mjs.map +1 -0
  7. package/dist/Mermaid.client-SBYY364Q.cjs +483 -0
  8. package/dist/Mermaid.client-SBYY364Q.cjs.map +1 -0
  9. package/dist/PlaygroundLayout-3YVSAEAF.cjs +1003 -0
  10. package/dist/PlaygroundLayout-3YVSAEAF.cjs.map +1 -0
  11. package/dist/PlaygroundLayout-4DYBORAS.mjs +996 -0
  12. package/dist/PlaygroundLayout-4DYBORAS.mjs.map +1 -0
  13. package/dist/PrettyCode.client-LCBPPTIX.mjs +152 -0
  14. package/dist/PrettyCode.client-LCBPPTIX.mjs.map +1 -0
  15. package/dist/PrettyCode.client-PNPLXRH6.cjs +154 -0
  16. package/dist/PrettyCode.client-PNPLXRH6.cjs.map +1 -0
  17. package/dist/chunk-37ZI6VD4.mjs +12 -0
  18. package/dist/chunk-37ZI6VD4.mjs.map +1 -0
  19. package/dist/chunk-3HK2OE62.cjs +81 -0
  20. package/dist/chunk-3HK2OE62.cjs.map +1 -0
  21. package/dist/chunk-7DGDQVQW.cjs +591 -0
  22. package/dist/chunk-7DGDQVQW.cjs.map +1 -0
  23. package/dist/chunk-M6P2FU7L.mjs +572 -0
  24. package/dist/chunk-M6P2FU7L.mjs.map +1 -0
  25. package/dist/chunk-UQ3XI5MY.cjs +15 -0
  26. package/dist/chunk-UQ3XI5MY.cjs.map +1 -0
  27. package/dist/chunk-YFRNE2IR.mjs +79 -0
  28. package/dist/chunk-YFRNE2IR.mjs.map +1 -0
  29. package/dist/index.cjs +5042 -0
  30. package/dist/index.cjs.map +1 -0
  31. package/dist/index.d.cts +1591 -0
  32. package/dist/index.d.ts +1591 -0
  33. package/dist/index.mjs +4941 -0
  34. package/dist/index.mjs.map +1 -0
  35. package/package.json +86 -0
  36. package/src/components/markdown/MarkdownMessage.tsx +340 -0
  37. package/src/components/markdown/index.ts +5 -0
  38. package/src/index.ts +26 -0
  39. package/src/stores/index.ts +9 -0
  40. package/src/stores/mediaCache.ts +534 -0
  41. package/src/tools/AudioPlayer/README.md +206 -0
  42. package/src/tools/AudioPlayer/components/HybridAudioPlayer.tsx +216 -0
  43. package/src/tools/AudioPlayer/components/HybridSimplePlayer.tsx +280 -0
  44. package/src/tools/AudioPlayer/components/HybridWaveform.tsx +279 -0
  45. package/src/tools/AudioPlayer/components/ReactiveCover/AudioReactiveCover.tsx +149 -0
  46. package/src/tools/AudioPlayer/components/ReactiveCover/effects/GlowEffect.tsx +110 -0
  47. package/src/tools/AudioPlayer/components/ReactiveCover/effects/MeshEffect.tsx +58 -0
  48. package/src/tools/AudioPlayer/components/ReactiveCover/effects/OrbsEffect.tsx +45 -0
  49. package/src/tools/AudioPlayer/components/ReactiveCover/effects/SpotlightEffect.tsx +82 -0
  50. package/src/tools/AudioPlayer/components/ReactiveCover/effects/index.ts +8 -0
  51. package/src/tools/AudioPlayer/components/ReactiveCover/index.ts +6 -0
  52. package/src/tools/AudioPlayer/components/index.ts +22 -0
  53. package/src/tools/AudioPlayer/context/HybridAudioProvider.tsx +158 -0
  54. package/src/tools/AudioPlayer/context/index.ts +16 -0
  55. package/src/tools/AudioPlayer/effects/index.ts +412 -0
  56. package/src/tools/AudioPlayer/hooks/index.ts +35 -0
  57. package/src/tools/AudioPlayer/hooks/useHybridAudio.ts +387 -0
  58. package/src/tools/AudioPlayer/hooks/useHybridAudioAnalysis.ts +95 -0
  59. package/src/tools/AudioPlayer/hooks/useVisualization.tsx +207 -0
  60. package/src/tools/AudioPlayer/index.ts +133 -0
  61. package/src/tools/AudioPlayer/types/effects.ts +73 -0
  62. package/src/tools/AudioPlayer/types/index.ts +27 -0
  63. package/src/tools/AudioPlayer/utils/debug.ts +14 -0
  64. package/src/tools/AudioPlayer/utils/formatTime.ts +10 -0
  65. package/src/tools/AudioPlayer/utils/index.ts +6 -0
  66. package/src/tools/ImageViewer/@refactoring/00-PLAN.md +71 -0
  67. package/src/tools/ImageViewer/@refactoring/01-TYPES.md +121 -0
  68. package/src/tools/ImageViewer/@refactoring/02-UTILS.md +143 -0
  69. package/src/tools/ImageViewer/@refactoring/03-HOOKS.md +261 -0
  70. package/src/tools/ImageViewer/@refactoring/04-COMPONENTS.md +427 -0
  71. package/src/tools/ImageViewer/@refactoring/05-EXECUTION-CHECKLIST.md +126 -0
  72. package/src/tools/ImageViewer/README.md +200 -0
  73. package/src/tools/ImageViewer/components/ImageInfo.tsx +44 -0
  74. package/src/tools/ImageViewer/components/ImageToolbar.tsx +145 -0
  75. package/src/tools/ImageViewer/components/ImageViewer.tsx +241 -0
  76. package/src/tools/ImageViewer/components/index.ts +7 -0
  77. package/src/tools/ImageViewer/hooks/index.ts +9 -0
  78. package/src/tools/ImageViewer/hooks/useImageLoading.ts +204 -0
  79. package/src/tools/ImageViewer/hooks/useImageTransform.ts +101 -0
  80. package/src/tools/ImageViewer/index.ts +60 -0
  81. package/src/tools/ImageViewer/types.ts +81 -0
  82. package/src/tools/ImageViewer/utils/constants.ts +59 -0
  83. package/src/tools/ImageViewer/utils/debug.ts +14 -0
  84. package/src/tools/ImageViewer/utils/index.ts +17 -0
  85. package/src/tools/ImageViewer/utils/lqip.ts +47 -0
  86. package/src/tools/JsonForm/JsonSchemaForm.tsx +197 -0
  87. package/src/tools/JsonForm/examples/BotConfigExample.tsx +249 -0
  88. package/src/tools/JsonForm/examples/RealBotConfigExample.tsx +161 -0
  89. package/src/tools/JsonForm/index.ts +46 -0
  90. package/src/tools/JsonForm/templates/ArrayFieldItemTemplate.tsx +47 -0
  91. package/src/tools/JsonForm/templates/ArrayFieldTemplate.tsx +74 -0
  92. package/src/tools/JsonForm/templates/BaseInputTemplate.tsx +107 -0
  93. package/src/tools/JsonForm/templates/ErrorListTemplate.tsx +35 -0
  94. package/src/tools/JsonForm/templates/FieldTemplate.tsx +62 -0
  95. package/src/tools/JsonForm/templates/ObjectFieldTemplate.tsx +116 -0
  96. package/src/tools/JsonForm/templates/index.ts +12 -0
  97. package/src/tools/JsonForm/types.ts +83 -0
  98. package/src/tools/JsonForm/utils.ts +213 -0
  99. package/src/tools/JsonForm/widgets/CheckboxWidget.tsx +37 -0
  100. package/src/tools/JsonForm/widgets/ColorWidget.tsx +219 -0
  101. package/src/tools/JsonForm/widgets/NumberWidget.tsx +89 -0
  102. package/src/tools/JsonForm/widgets/SelectWidget.tsx +97 -0
  103. package/src/tools/JsonForm/widgets/SliderWidget.tsx +148 -0
  104. package/src/tools/JsonForm/widgets/SwitchWidget.tsx +35 -0
  105. package/src/tools/JsonForm/widgets/TextWidget.tsx +96 -0
  106. package/src/tools/JsonForm/widgets/index.ts +14 -0
  107. package/src/tools/JsonTree/index.tsx +243 -0
  108. package/src/tools/LottiePlayer/LottiePlayer.client.tsx +213 -0
  109. package/src/tools/LottiePlayer/index.tsx +56 -0
  110. package/src/tools/LottiePlayer/types.ts +108 -0
  111. package/src/tools/LottiePlayer/useLottie.ts +164 -0
  112. package/src/tools/Mermaid/Mermaid.client.tsx +82 -0
  113. package/src/tools/Mermaid/components/MermaidCodeViewer.tsx +95 -0
  114. package/src/tools/Mermaid/components/MermaidFullscreenModal.tsx +103 -0
  115. package/src/tools/Mermaid/hooks/index.ts +4 -0
  116. package/src/tools/Mermaid/hooks/useMermaidCleanup.ts +73 -0
  117. package/src/tools/Mermaid/hooks/useMermaidFullscreen.ts +46 -0
  118. package/src/tools/Mermaid/hooks/useMermaidRenderer.ts +226 -0
  119. package/src/tools/Mermaid/hooks/useMermaidValidation.ts +29 -0
  120. package/src/tools/Mermaid/index.tsx +44 -0
  121. package/src/tools/Mermaid/utils/mermaid-helpers.ts +33 -0
  122. package/src/tools/OpenapiViewer/components/EndpointInfo.tsx +149 -0
  123. package/src/tools/OpenapiViewer/components/EndpointsLibrary.tsx +263 -0
  124. package/src/tools/OpenapiViewer/components/PlaygroundLayout.tsx +125 -0
  125. package/src/tools/OpenapiViewer/components/PlaygroundStepper.tsx +100 -0
  126. package/src/tools/OpenapiViewer/components/RequestBuilder.tsx +157 -0
  127. package/src/tools/OpenapiViewer/components/RequestParametersForm.tsx +253 -0
  128. package/src/tools/OpenapiViewer/components/ResponseViewer.tsx +173 -0
  129. package/src/tools/OpenapiViewer/components/VersionSelector.tsx +68 -0
  130. package/src/tools/OpenapiViewer/components/index.ts +14 -0
  131. package/src/tools/OpenapiViewer/constants.ts +39 -0
  132. package/src/tools/OpenapiViewer/context/PlaygroundContext.tsx +337 -0
  133. package/src/tools/OpenapiViewer/hooks/index.ts +8 -0
  134. package/src/tools/OpenapiViewer/hooks/useMobile.ts +10 -0
  135. package/src/tools/OpenapiViewer/hooks/useOpenApiSchema.ts +199 -0
  136. package/src/tools/OpenapiViewer/index.tsx +37 -0
  137. package/src/tools/OpenapiViewer/types.ts +151 -0
  138. package/src/tools/OpenapiViewer/utils/apiKeyManager.ts +149 -0
  139. package/src/tools/OpenapiViewer/utils/formatters.ts +71 -0
  140. package/src/tools/OpenapiViewer/utils/index.ts +9 -0
  141. package/src/tools/OpenapiViewer/utils/versionManager.ts +161 -0
  142. package/src/tools/PrettyCode/PrettyCode.client.tsx +208 -0
  143. package/src/tools/PrettyCode/index.tsx +47 -0
  144. package/src/tools/VideoPlayer/@refactoring/00-PLAN.md +91 -0
  145. package/src/tools/VideoPlayer/@refactoring/01-TYPES.md +284 -0
  146. package/src/tools/VideoPlayer/@refactoring/02-UTILS.md +141 -0
  147. package/src/tools/VideoPlayer/@refactoring/03-HOOKS.md +178 -0
  148. package/src/tools/VideoPlayer/@refactoring/04-COMPONENTS.md +95 -0
  149. package/src/tools/VideoPlayer/@refactoring/05-EXECUTION-CHECKLIST.md +139 -0
  150. package/src/tools/VideoPlayer/README.md +264 -0
  151. package/src/tools/VideoPlayer/components/VideoControls.tsx +138 -0
  152. package/src/tools/VideoPlayer/components/VideoErrorFallback.tsx +172 -0
  153. package/src/tools/VideoPlayer/components/VideoPlayer.tsx +201 -0
  154. package/src/tools/VideoPlayer/components/index.ts +14 -0
  155. package/src/tools/VideoPlayer/context/VideoPlayerContext.tsx +52 -0
  156. package/src/tools/VideoPlayer/context/index.ts +8 -0
  157. package/src/tools/VideoPlayer/hooks/index.ts +12 -0
  158. package/src/tools/VideoPlayer/hooks/useVideoPlayerSettings.ts +70 -0
  159. package/src/tools/VideoPlayer/hooks/useVideoPositionCache.ts +116 -0
  160. package/src/tools/VideoPlayer/index.ts +77 -0
  161. package/src/tools/VideoPlayer/providers/NativeProvider.tsx +284 -0
  162. package/src/tools/VideoPlayer/providers/StreamProvider.tsx +505 -0
  163. package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +400 -0
  164. package/src/tools/VideoPlayer/providers/index.ts +8 -0
  165. package/src/tools/VideoPlayer/types/index.ts +38 -0
  166. package/src/tools/VideoPlayer/types/player.ts +116 -0
  167. package/src/tools/VideoPlayer/types/provider.ts +93 -0
  168. package/src/tools/VideoPlayer/types/sources.ts +97 -0
  169. package/src/tools/VideoPlayer/utils/debug.ts +14 -0
  170. package/src/tools/VideoPlayer/utils/fileSource.ts +78 -0
  171. package/src/tools/VideoPlayer/utils/index.ts +12 -0
  172. package/src/tools/VideoPlayer/utils/resolvers.ts +75 -0
  173. package/src/tools/_shared.ts +29 -0
  174. package/src/tools/index.ts +172 -0
@@ -0,0 +1,200 @@
1
+ # ImageViewer
2
+
3
+ Image viewer with zoom, pan, rotate, and flip functionality.
4
+
5
+ ## Features
6
+
7
+ - Mouse wheel zoom
8
+ - Drag to pan
9
+ - Zoom presets (25%, 50%, 100%, 200%, 400%)
10
+ - Rotate 90°
11
+ - Flip horizontal/vertical
12
+ - Fullscreen dialog mode
13
+ - Keyboard shortcuts
14
+ - Checkerboard background for transparency
15
+ - Image dimensions display
16
+
17
+ ## Installation
18
+
19
+ ```tsx
20
+ import { ImageViewer } from '@djangocfg/ui-nextjs';
21
+ ```
22
+
23
+ ## Basic Usage
24
+
25
+ ```tsx
26
+ <ImageViewer
27
+ file={{
28
+ name: 'photo.jpg',
29
+ path: '/images/photo.jpg',
30
+ mimeType: 'image/jpeg',
31
+ }}
32
+ content={imageArrayBuffer}
33
+ />
34
+ ```
35
+
36
+ ## Props
37
+
38
+ | Prop | Type | Default | Description |
39
+ |------|------|---------|-------------|
40
+ | `file` | `ImageFile` | required | File info (name, path, mimeType) |
41
+ | `content` | `string \| ArrayBuffer` | required | Image data |
42
+ | `src` | `string` | - | Direct URL (bypasses content→blob conversion) |
43
+ | `inDialog` | `boolean` | `false` | Hide expand button (for nested usage) |
44
+
45
+ ## ImageFile Type
46
+
47
+ ```typescript
48
+ interface ImageFile {
49
+ name: string; // Display name
50
+ path: string; // File path (for state tracking)
51
+ mimeType?: string; // MIME type (e.g., 'image/png')
52
+ }
53
+ ```
54
+
55
+ ## Content Formats
56
+
57
+ The `content` prop accepts:
58
+
59
+ - **ArrayBuffer**: Binary image data (creates blob URL)
60
+ - **Data URL**: Base64 encoded string starting with `data:`
61
+ - **Base64 string**: Raw base64 (auto-prefixed with data URL)
62
+
63
+ ```tsx
64
+ // ArrayBuffer (from fetch or file read)
65
+ const response = await fetch('/image.png');
66
+ const buffer = await response.arrayBuffer();
67
+ <ImageViewer file={file} content={buffer} />
68
+
69
+ // Data URL
70
+ <ImageViewer file={file} content="data:image/png;base64,iVBORw0KGgo..." />
71
+
72
+ // Base64 string (auto-converted to data URL)
73
+ <ImageViewer file={file} content="iVBORw0KGgo..." />
74
+
75
+ // Direct URL (HTTP streaming for large files)
76
+ <ImageViewer
77
+ file={file}
78
+ content=""
79
+ src="https://api.example.com/images/large-photo.jpg"
80
+ />
81
+ ```
82
+
83
+ ## HTTP Streaming
84
+
85
+ For large images, use the `src` prop to stream directly from a URL instead of loading the entire file into memory:
86
+
87
+ ```tsx
88
+ // When src is provided, content is ignored
89
+ <ImageViewer
90
+ file={{ name: 'large-photo.jpg', path: '/photos/large.jpg' }}
91
+ content="" // Empty - not used when src is provided
92
+ src={streamingUrl}
93
+ />
94
+ ```
95
+
96
+ This is useful for:
97
+ - Large images that shouldn't be loaded into memory
98
+ - HTTP Range request streaming
99
+ - Pre-signed URLs with authentication tokens
100
+
101
+ ## Keyboard Shortcuts
102
+
103
+ | Key | Action |
104
+ |-----|--------|
105
+ | `+` / `=` | Zoom in |
106
+ | `-` | Zoom out |
107
+ | `0` | Reset to fit |
108
+ | `R` | Rotate 90° |
109
+
110
+ ## Toolbar Controls
111
+
112
+ The floating toolbar at the bottom provides:
113
+
114
+ - **Zoom out** button
115
+ - **Zoom level** dropdown with presets
116
+ - **Zoom in** button
117
+ - **Fit to view** button
118
+ - **Flip horizontal** toggle
119
+ - **Flip vertical** toggle
120
+ - **Rotate 90°** button
121
+ - **Expand** fullscreen button
122
+
123
+ ## Fullscreen Mode
124
+
125
+ Click the expand button to open the image in a fullscreen dialog. The dialog includes the same toolbar and supports all interactions.
126
+
127
+ ```tsx
128
+ // Fullscreen is automatically available unless inDialog is true
129
+ <ImageViewer file={file} content={content} />
130
+
131
+ // When embedding in your own dialog, disable the expand button
132
+ <Dialog>
133
+ <ImageViewer file={file} content={content} inDialog />
134
+ </Dialog>
135
+ ```
136
+
137
+ ## Styling
138
+
139
+ The component fills its container and displays a checkerboard pattern behind transparent images.
140
+
141
+ ```tsx
142
+ <div className="w-full h-[500px]">
143
+ <ImageViewer file={file} content={content} />
144
+ </div>
145
+ ```
146
+
147
+ ## Error State
148
+
149
+ When content is empty or invalid, displays an error placeholder:
150
+
151
+ ```tsx
152
+ // Shows "Failed to load image" with icon
153
+ <ImageViewer file={file} content="" />
154
+ ```
155
+
156
+ ## Example: File Browser Integration
157
+
158
+ ```tsx
159
+ function FilePreview({ file, content }: { file: OpenFile; content: ArrayBuffer }) {
160
+ const imageFile: ImageFile = {
161
+ name: file.name,
162
+ path: file.path,
163
+ mimeType: file.mimeType,
164
+ };
165
+
166
+ return (
167
+ <div className="h-full">
168
+ <ImageViewer file={imageFile} content={content} />
169
+ </div>
170
+ );
171
+ }
172
+ ```
173
+
174
+ ## Architecture
175
+
176
+ ```
177
+ ImageViewer/
178
+ ├── index.ts # Public API exports
179
+ ├── types.ts # Type definitions
180
+ ├── components/
181
+ │ ├── index.ts # Component exports
182
+ │ ├── ImageViewer.tsx # Main viewer component
183
+ │ ├── ImageToolbar.tsx # Zoom/rotate/flip controls
184
+ │ └── ImageInfo.tsx # Dimensions display
185
+ ├── hooks/
186
+ │ ├── index.ts
187
+ │ ├── useImageLoading.ts # Blob URL & LQIP management
188
+ │ └── useImageTransform.ts # Rotation/flip state
189
+ ├── utils/
190
+ │ ├── index.ts
191
+ │ ├── constants.ts # Size limits, zoom presets
192
+ │ └── lqip.ts # Low-Quality Image Placeholder
193
+ └── README.md
194
+ ```
195
+
196
+ ## Dependencies
197
+
198
+ - `react-zoom-pan-pinch` - Zoom and pan functionality
199
+ - `lucide-react` - Icons
200
+ - `@djangocfg/ui-core` - UI components (Button, Dialog, etc.)
@@ -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
+ }
@@ -0,0 +1,145 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * ImageToolbar - Floating toolbar for image controls
5
+ */
6
+
7
+ import { useMemo, useCallback } from 'react';
8
+ import { ZoomIn, ZoomOut, RotateCw, FlipHorizontal, FlipVertical, Maximize2, Expand } from 'lucide-react';
9
+ import { useControls } from 'react-zoom-pan-pinch';
10
+ import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@djangocfg/ui-core/components';
11
+ import { cn } from '@djangocfg/ui-core/lib';
12
+ import { ZOOM_PRESETS } from '../utils';
13
+ import type { ImageToolbarProps } from '../types';
14
+
15
+ // =============================================================================
16
+ // COMPONENT
17
+ // =============================================================================
18
+
19
+ export function ImageToolbar({
20
+ scale,
21
+ transform,
22
+ onRotate,
23
+ onFlipH,
24
+ onFlipV,
25
+ onZoomPreset,
26
+ onExpand,
27
+ }: ImageToolbarProps) {
28
+ const { zoomIn, zoomOut, resetTransform } = useControls();
29
+
30
+ const zoomLabel = useMemo(() => {
31
+ const percent = Math.round(scale * 100);
32
+ return `${percent}%`;
33
+ }, [scale]);
34
+
35
+ return (
36
+ <div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-0.5 bg-background/90 backdrop-blur-sm border rounded-lg p-1 shadow-lg">
37
+ {/* Zoom controls */}
38
+ <Button
39
+ variant="ghost"
40
+ size="icon"
41
+ className="h-7 w-7"
42
+ onClick={() => zoomOut()}
43
+ title="Zoom out"
44
+ >
45
+ <ZoomOut className="h-3.5 w-3.5" />
46
+ </Button>
47
+
48
+ <DropdownMenu>
49
+ <DropdownMenuTrigger asChild>
50
+ <Button variant="ghost" size="sm" className="h-7 px-2 min-w-[52px] font-mono text-xs">
51
+ {zoomLabel}
52
+ </Button>
53
+ </DropdownMenuTrigger>
54
+ <DropdownMenuContent align="center" className="min-w-[80px]">
55
+ {ZOOM_PRESETS.map((preset) => (
56
+ <DropdownMenuItem
57
+ key={preset.label}
58
+ onClick={() => onZoomPreset(preset.value)}
59
+ className="text-xs justify-center"
60
+ >
61
+ {preset.label}
62
+ </DropdownMenuItem>
63
+ ))}
64
+ </DropdownMenuContent>
65
+ </DropdownMenu>
66
+
67
+ <Button
68
+ variant="ghost"
69
+ size="icon"
70
+ className="h-7 w-7"
71
+ onClick={() => zoomIn()}
72
+ title="Zoom in"
73
+ >
74
+ <ZoomIn className="h-3.5 w-3.5" />
75
+ </Button>
76
+
77
+ <div className="w-px h-4 bg-border mx-1" />
78
+
79
+ {/* Fit to view */}
80
+ <Button
81
+ variant="ghost"
82
+ size="icon"
83
+ className="h-7 w-7"
84
+ onClick={() => resetTransform()}
85
+ title="Fit to view"
86
+ >
87
+ <Maximize2 className="h-3.5 w-3.5" />
88
+ </Button>
89
+
90
+ <div className="w-px h-4 bg-border mx-1" />
91
+
92
+ {/* Transform controls */}
93
+ <Button
94
+ variant="ghost"
95
+ size="icon"
96
+ className={cn('h-7 w-7', transform.flipH && 'bg-accent')}
97
+ onClick={onFlipH}
98
+ title="Flip horizontal"
99
+ >
100
+ <FlipHorizontal className="h-3.5 w-3.5" />
101
+ </Button>
102
+
103
+ <Button
104
+ variant="ghost"
105
+ size="icon"
106
+ className={cn('h-7 w-7', transform.flipV && 'bg-accent')}
107
+ onClick={onFlipV}
108
+ title="Flip vertical"
109
+ >
110
+ <FlipVertical className="h-3.5 w-3.5" />
111
+ </Button>
112
+
113
+ <Button
114
+ variant="ghost"
115
+ size="icon"
116
+ className="h-7 w-7"
117
+ onClick={onRotate}
118
+ title="Rotate 90°"
119
+ >
120
+ <RotateCw className="h-3.5 w-3.5" />
121
+ </Button>
122
+
123
+ {transform.rotation !== 0 && (
124
+ <span className="text-[10px] text-muted-foreground font-mono pl-1">
125
+ {transform.rotation}°
126
+ </span>
127
+ )}
128
+
129
+ {onExpand && (
130
+ <>
131
+ <div className="w-px h-4 bg-border mx-1" />
132
+ <Button
133
+ variant="ghost"
134
+ size="icon"
135
+ className="h-7 w-7"
136
+ onClick={onExpand}
137
+ title="Open in fullscreen"
138
+ >
139
+ <Expand className="h-3.5 w-3.5" />
140
+ </Button>
141
+ </>
142
+ )}
143
+ </div>
144
+ );
145
+ }
@@ -0,0 +1,241 @@
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, DialogTitle, 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, src: directSrc, inDialog = false }: ImageViewerProps) {
30
+ const [scale, setScale] = useState(1);
31
+ const [dialogOpen, setDialogOpen] = useState(false);
32
+ const [loadError, setLoadError] = useState(false);
33
+ const containerRef = useRef<HTMLDivElement>(null);
34
+ const controlsRef = useRef<ReturnType<typeof useControls> | null>(null);
35
+
36
+ // Loading state
37
+ const {
38
+ src,
39
+ lqip,
40
+ isFullyLoaded,
41
+ useProgressiveLoading,
42
+ error,
43
+ hasContent,
44
+ } = useImageLoading({ content, mimeType: file.mimeType, src: directSrc });
45
+
46
+ // Reset load error when src changes
47
+ useEffect(() => {
48
+ setLoadError(false);
49
+ }, [src]);
50
+
51
+ // Transform state
52
+ const { transform, rotate, flipH, flipV, transformStyle } = useImageTransform({
53
+ resetKey: file.path,
54
+ });
55
+
56
+ // Zoom preset handler
57
+ const handleZoomPreset = useCallback((value: number | 'fit') => {
58
+ if (!controlsRef.current) return;
59
+ if (value === 'fit') {
60
+ controlsRef.current.resetTransform();
61
+ } else {
62
+ controlsRef.current.setTransform(0, 0, value);
63
+ }
64
+ }, []);
65
+
66
+ // Expand to fullscreen
67
+ const handleExpand = useCallback(() => {
68
+ setDialogOpen(true);
69
+ }, []);
70
+
71
+ // Keyboard shortcuts
72
+ useEffect(() => {
73
+ const handleKeyDown = (e: KeyboardEvent) => {
74
+ if (!containerRef.current?.contains(document.activeElement) &&
75
+ document.activeElement !== containerRef.current) {
76
+ return;
77
+ }
78
+
79
+ const controls = controlsRef.current;
80
+ if (!controls) return;
81
+
82
+ switch (e.key) {
83
+ case '+':
84
+ case '=':
85
+ e.preventDefault();
86
+ controls.zoomIn();
87
+ break;
88
+ case '-':
89
+ e.preventDefault();
90
+ controls.zoomOut();
91
+ break;
92
+ case '0':
93
+ e.preventDefault();
94
+ controls.resetTransform();
95
+ break;
96
+ case 'r':
97
+ if (!e.metaKey && !e.ctrlKey) {
98
+ e.preventDefault();
99
+ rotate();
100
+ }
101
+ break;
102
+ }
103
+ };
104
+
105
+ window.addEventListener('keydown', handleKeyDown);
106
+ return () => window.removeEventListener('keydown', handleKeyDown);
107
+ }, [rotate]);
108
+
109
+ // Show error for oversized images or load errors
110
+ if (error || loadError) {
111
+ return (
112
+ <div className="flex-1 flex flex-col items-center justify-center gap-3 bg-muted/30 p-4">
113
+ <AlertCircle className="w-12 h-12 text-destructive/70" />
114
+ <Alert variant="destructive" className="max-w-md">
115
+ <AlertCircle className="h-4 w-4" />
116
+ <AlertDescription>{error || 'Failed to load image'}</AlertDescription>
117
+ </Alert>
118
+ </div>
119
+ );
120
+ }
121
+
122
+ // No content
123
+ if (!hasContent) {
124
+ return (
125
+ <div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
126
+ <ImageIcon className="w-12 h-12 text-muted-foreground/50" />
127
+ <p className="text-sm text-muted-foreground">No image content</p>
128
+ </div>
129
+ );
130
+ }
131
+
132
+ return (
133
+ <div
134
+ ref={containerRef}
135
+ tabIndex={0}
136
+ className={cn(
137
+ 'flex-1 h-full relative overflow-hidden outline-none',
138
+ 'bg-[length:16px_16px]',
139
+ '[background-color:hsl(var(--muted)/0.2)]',
140
+ '[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%)]',
141
+ '[background-position:0_0,0_8px,8px_-8px,-8px_0px]'
142
+ )}
143
+ >
144
+ {src && <ImageInfo src={src} />}
145
+
146
+ {/* Progressive loading indicator */}
147
+ {useProgressiveLoading && !isFullyLoaded && (
148
+ <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">
149
+ <div className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
150
+ Loading...
151
+ </div>
152
+ )}
153
+
154
+ <TransformWrapper
155
+ initialScale={1}
156
+ minScale={0.1}
157
+ maxScale={8}
158
+ centerOnInit
159
+ centerZoomedOut
160
+ onTransformed={(ref, state) => {
161
+ setScale(state.scale);
162
+ controlsRef.current = ref;
163
+ }}
164
+ onInit={(ref) => {
165
+ controlsRef.current = ref;
166
+ }}
167
+ wheel={{ step: 0.1 }}
168
+ doubleClick={{ mode: 'toggle', step: 2 }}
169
+ panning={{ velocityDisabled: false }}
170
+ >
171
+ <ImageToolbar
172
+ scale={scale}
173
+ transform={transform}
174
+ onRotate={rotate}
175
+ onFlipH={flipH}
176
+ onFlipV={flipV}
177
+ onZoomPreset={handleZoomPreset}
178
+ onExpand={!inDialog ? handleExpand : undefined}
179
+ />
180
+
181
+ <TransformComponent
182
+ wrapperClass="!w-full !h-full cursor-grab active:cursor-grabbing"
183
+ contentClass="!w-full !h-full flex items-center justify-center"
184
+ >
185
+ <div className="relative">
186
+ {/* LQIP Placeholder (blurred, shown while loading) */}
187
+ {useProgressiveLoading && lqip && !isFullyLoaded && (
188
+ <img
189
+ src={lqip}
190
+ alt=""
191
+ aria-hidden="true"
192
+ className="absolute inset-0 max-w-full max-h-full object-contain select-none"
193
+ style={{
194
+ transform: transformStyle,
195
+ filter: 'blur(20px)',
196
+ transition: 'opacity 0.3s ease-out',
197
+ opacity: isFullyLoaded ? 0 : 1,
198
+ }}
199
+ draggable={false}
200
+ />
201
+ )}
202
+
203
+ {/* Full Image */}
204
+ {src && (
205
+ <img
206
+ src={src}
207
+ alt={file.name}
208
+ className="max-w-full max-h-full object-contain select-none"
209
+ style={{
210
+ transform: transformStyle,
211
+ transition: useProgressiveLoading
212
+ ? 'transform 0.15s ease-out, opacity 0.3s ease-out'
213
+ : 'transform 0.15s ease-out',
214
+ opacity: useProgressiveLoading && !isFullyLoaded ? 0 : 1,
215
+ }}
216
+ draggable={false}
217
+ crossOrigin="anonymous"
218
+ onError={() => setLoadError(true)}
219
+ />
220
+ )}
221
+ </div>
222
+ </TransformComponent>
223
+ </TransformWrapper>
224
+
225
+ {/* Fullscreen dialog */}
226
+ {!inDialog && (
227
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
228
+ <DialogContent className="max-w-[95vw] max-h-[95vh] w-[95vw] h-[95vh] p-0 overflow-hidden [&>button]:hidden flex flex-col">
229
+ <DialogTitle className="sr-only">{file.name}</DialogTitle>
230
+ <div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
231
+ <span className="text-sm font-medium truncate">{file.name}</span>
232
+ </div>
233
+ <div className="flex-1 min-h-0">
234
+ <ImageViewer file={file} content={content} src={directSrc} inDialog />
235
+ </div>
236
+ </DialogContent>
237
+ </Dialog>
238
+ )}
239
+ </div>
240
+ );
241
+ }
@@ -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';