@djangocfg/ui-nextjs 2.1.64 → 2.1.66

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 (30) hide show
  1. package/package.json +9 -6
  2. package/src/blocks/SplitHero/SplitHeroMedia.tsx +2 -1
  3. package/src/tools/AudioPlayer/AudioEqualizer.tsx +235 -0
  4. package/src/tools/AudioPlayer/AudioPlayer.tsx +223 -0
  5. package/src/tools/AudioPlayer/AudioReactiveCover.tsx +389 -0
  6. package/src/tools/AudioPlayer/AudioShortcutsPopover.tsx +95 -0
  7. package/src/tools/AudioPlayer/README.md +301 -0
  8. package/src/tools/AudioPlayer/SimpleAudioPlayer.tsx +275 -0
  9. package/src/tools/AudioPlayer/VisualizationToggle.tsx +68 -0
  10. package/src/tools/AudioPlayer/context.tsx +426 -0
  11. package/src/tools/AudioPlayer/effects/index.ts +412 -0
  12. package/src/tools/AudioPlayer/index.ts +84 -0
  13. package/src/tools/AudioPlayer/types.ts +162 -0
  14. package/src/tools/AudioPlayer/useAudioHotkeys.ts +142 -0
  15. package/src/tools/AudioPlayer/useAudioVisualization.tsx +195 -0
  16. package/src/tools/ImageViewer/ImageViewer.tsx +416 -0
  17. package/src/tools/ImageViewer/README.md +161 -0
  18. package/src/tools/ImageViewer/index.ts +16 -0
  19. package/src/tools/VideoPlayer/README.md +196 -187
  20. package/src/tools/VideoPlayer/VideoErrorFallback.tsx +174 -0
  21. package/src/tools/VideoPlayer/VideoPlayer.tsx +189 -218
  22. package/src/tools/VideoPlayer/VideoPlayerContext.tsx +125 -0
  23. package/src/tools/VideoPlayer/index.ts +59 -7
  24. package/src/tools/VideoPlayer/providers/NativeProvider.tsx +206 -0
  25. package/src/tools/VideoPlayer/providers/StreamProvider.tsx +311 -0
  26. package/src/tools/VideoPlayer/providers/VidstackProvider.tsx +254 -0
  27. package/src/tools/VideoPlayer/providers/index.ts +8 -0
  28. package/src/tools/VideoPlayer/types.ts +320 -71
  29. package/src/tools/index.ts +82 -4
  30. package/src/tools/VideoPlayer/NativePlayer.tsx +0 -141
@@ -0,0 +1,416 @@
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, useMemo } from 'react';
16
+ import { ImageIcon, ZoomIn, ZoomOut, RotateCw, FlipHorizontal, FlipVertical, Maximize2, Expand } from 'lucide-react';
17
+ import { TransformWrapper, TransformComponent, useControls } from 'react-zoom-pan-pinch';
18
+ import { Button, cn, Dialog, DialogContent } from '@djangocfg/ui-core';
19
+ import {
20
+ DropdownMenu,
21
+ DropdownMenuContent,
22
+ DropdownMenuItem,
23
+ DropdownMenuTrigger,
24
+ } from '../../components/dropdown-menu';
25
+
26
+ // =============================================================================
27
+ // TYPES
28
+ // =============================================================================
29
+
30
+ export interface ImageFile {
31
+ name: string;
32
+ path: string;
33
+ mimeType?: string;
34
+ }
35
+
36
+ // Preset zoom levels
37
+ const ZOOM_PRESETS = [
38
+ { label: 'Fit', value: 'fit' },
39
+ { label: '25%', value: 0.25 },
40
+ { label: '50%', value: 0.5 },
41
+ { label: '100%', value: 1 },
42
+ { label: '200%', value: 2 },
43
+ { label: '400%', value: 4 },
44
+ ] as const;
45
+
46
+ export interface ImageViewerProps {
47
+ /** File info (name, path, mimeType) */
48
+ file: ImageFile;
49
+ /** Image content as string (data URL or base64) or ArrayBuffer */
50
+ content: string | ArrayBuffer;
51
+ /** Hide expand button when already in dialog */
52
+ inDialog?: boolean;
53
+ }
54
+
55
+ interface ImageTransform {
56
+ rotation: number;
57
+ flipH: boolean;
58
+ flipV: boolean;
59
+ }
60
+
61
+ // Toolbar component
62
+ function ImageToolbar({
63
+ scale,
64
+ transform,
65
+ onRotate,
66
+ onFlipH,
67
+ onFlipV,
68
+ onZoomPreset,
69
+ onExpand,
70
+ }: {
71
+ scale: number;
72
+ transform: ImageTransform;
73
+ onRotate: () => void;
74
+ onFlipH: () => void;
75
+ onFlipV: () => void;
76
+ onZoomPreset: (value: number | 'fit') => void;
77
+ onExpand?: () => void;
78
+ }) {
79
+ const { zoomIn, zoomOut, resetTransform, centerView } = useControls();
80
+
81
+ const zoomLabel = useMemo(() => {
82
+ const percent = Math.round(scale * 100);
83
+ return `${percent}%`;
84
+ }, [scale]);
85
+
86
+ return (
87
+ <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">
88
+ {/* Zoom controls */}
89
+ <Button
90
+ variant="ghost"
91
+ size="icon"
92
+ className="h-7 w-7"
93
+ onClick={() => zoomOut()}
94
+ >
95
+ <ZoomOut className="h-3.5 w-3.5" />
96
+ </Button>
97
+
98
+ <DropdownMenu>
99
+ <DropdownMenuTrigger asChild>
100
+ <Button variant="ghost" size="sm" className="h-7 px-2 min-w-[52px] font-mono text-xs">
101
+ {zoomLabel}
102
+ </Button>
103
+ </DropdownMenuTrigger>
104
+ <DropdownMenuContent align="center" className="min-w-[80px]">
105
+ {ZOOM_PRESETS.map((preset) => (
106
+ <DropdownMenuItem
107
+ key={preset.label}
108
+ onClick={() => onZoomPreset(preset.value)}
109
+ className="text-xs justify-center"
110
+ >
111
+ {preset.label}
112
+ </DropdownMenuItem>
113
+ ))}
114
+ </DropdownMenuContent>
115
+ </DropdownMenu>
116
+
117
+ <Button
118
+ variant="ghost"
119
+ size="icon"
120
+ className="h-7 w-7"
121
+ onClick={() => zoomIn()}
122
+ >
123
+ <ZoomIn className="h-3.5 w-3.5" />
124
+ </Button>
125
+
126
+ <div className="w-px h-4 bg-border mx-1" />
127
+
128
+ {/* Fit to view */}
129
+ <Button
130
+ variant="ghost"
131
+ size="icon"
132
+ className="h-7 w-7"
133
+ onClick={() => resetTransform()}
134
+ title="Fit to view"
135
+ >
136
+ <Maximize2 className="h-3.5 w-3.5" />
137
+ </Button>
138
+
139
+ <div className="w-px h-4 bg-border mx-1" />
140
+
141
+ {/* Transform controls */}
142
+ <Button
143
+ variant="ghost"
144
+ size="icon"
145
+ className={cn("h-7 w-7", transform.flipH && "bg-accent")}
146
+ onClick={onFlipH}
147
+ title="Flip horizontal"
148
+ >
149
+ <FlipHorizontal className="h-3.5 w-3.5" />
150
+ </Button>
151
+
152
+ <Button
153
+ variant="ghost"
154
+ size="icon"
155
+ className={cn("h-7 w-7", transform.flipV && "bg-accent")}
156
+ onClick={onFlipV}
157
+ title="Flip vertical"
158
+ >
159
+ <FlipVertical className="h-3.5 w-3.5" />
160
+ </Button>
161
+
162
+ <Button
163
+ variant="ghost"
164
+ size="icon"
165
+ className="h-7 w-7"
166
+ onClick={onRotate}
167
+ title="Rotate 90°"
168
+ >
169
+ <RotateCw className="h-3.5 w-3.5" />
170
+ </Button>
171
+
172
+ {transform.rotation !== 0 && (
173
+ <span className="text-[10px] text-muted-foreground font-mono pl-1">
174
+ {transform.rotation}°
175
+ </span>
176
+ )}
177
+
178
+ {onExpand && (
179
+ <>
180
+ <div className="w-px h-4 bg-border mx-1" />
181
+ <Button
182
+ variant="ghost"
183
+ size="icon"
184
+ className="h-7 w-7"
185
+ onClick={onExpand}
186
+ title="Open in fullscreen"
187
+ >
188
+ <Expand className="h-3.5 w-3.5" />
189
+ </Button>
190
+ </>
191
+ )}
192
+ </div>
193
+ );
194
+ }
195
+
196
+ // Image dimensions badge
197
+ function ImageInfo({ src }: { src: string }) {
198
+ const [dimensions, setDimensions] = useState<{ w: number; h: number } | null>(null);
199
+
200
+ useEffect(() => {
201
+ const img = new Image();
202
+ img.onload = () => setDimensions({ w: img.naturalWidth, h: img.naturalHeight });
203
+ img.src = src;
204
+ }, [src]);
205
+
206
+ if (!dimensions) return null;
207
+
208
+ return (
209
+ <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">
210
+ {dimensions.w} × {dimensions.h}
211
+ </div>
212
+ );
213
+ }
214
+
215
+ export function ImageViewer({ file, content, inDialog = false }: ImageViewerProps) {
216
+ const contentSize = content ? (typeof content === 'string' ? content.length : content.byteLength) : 0;
217
+ const hasContent = contentSize > 0;
218
+
219
+ const [transform, setTransform] = useState<ImageTransform>({
220
+ rotation: 0,
221
+ flipH: false,
222
+ flipV: false,
223
+ });
224
+ const [scale, setScale] = useState(1);
225
+ const [dialogOpen, setDialogOpen] = useState(false);
226
+ const blobUrlRef = useRef<string | null>(null);
227
+ const [src, setSrc] = useState<string | null>(null);
228
+ const containerRef = useRef<HTMLDivElement>(null);
229
+ const controlsRef = useRef<ReturnType<typeof useControls> | null>(null);
230
+
231
+ // Create blob URL
232
+ useEffect(() => {
233
+ if (!hasContent) {
234
+ setSrc(null);
235
+ return;
236
+ }
237
+
238
+ if (typeof content === 'string') {
239
+ const url = content.startsWith('data:') ? content : `data:${file.mimeType};base64,${btoa(content)}`;
240
+ setSrc(url);
241
+ return;
242
+ }
243
+
244
+ const blob = new Blob([content], { type: file.mimeType || 'image/png' });
245
+ const url = URL.createObjectURL(blob);
246
+ blobUrlRef.current = url;
247
+ setSrc(url);
248
+
249
+ return () => {
250
+ if (blobUrlRef.current) {
251
+ URL.revokeObjectURL(blobUrlRef.current);
252
+ blobUrlRef.current = null;
253
+ }
254
+ };
255
+ }, [content, file.mimeType, hasContent]);
256
+
257
+ // Reset transform when file changes
258
+ useEffect(() => {
259
+ setTransform({ rotation: 0, flipH: false, flipV: false });
260
+ }, [file.path]);
261
+
262
+ const handleRotate = useCallback(() => {
263
+ setTransform((prev) => ({ ...prev, rotation: (prev.rotation + 90) % 360 }));
264
+ }, []);
265
+
266
+ const handleFlipH = useCallback(() => {
267
+ setTransform((prev) => ({ ...prev, flipH: !prev.flipH }));
268
+ }, []);
269
+
270
+ const handleFlipV = useCallback(() => {
271
+ setTransform((prev) => ({ ...prev, flipV: !prev.flipV }));
272
+ }, []);
273
+
274
+ const handleZoomPreset = useCallback((value: number | 'fit') => {
275
+ if (!controlsRef.current) return;
276
+ if (value === 'fit') {
277
+ controlsRef.current.resetTransform();
278
+ } else {
279
+ controlsRef.current.setTransform(0, 0, value);
280
+ }
281
+ }, []);
282
+
283
+ const handleExpand = useCallback(() => {
284
+ setDialogOpen(true);
285
+ }, []);
286
+
287
+ // CSS transform for rotation and flip
288
+ const imageTransform = useMemo(() => {
289
+ const transforms: string[] = [];
290
+ if (transform.rotation) transforms.push(`rotate(${transform.rotation}deg)`);
291
+ if (transform.flipH) transforms.push('scaleX(-1)');
292
+ if (transform.flipV) transforms.push('scaleY(-1)');
293
+ return transforms.join(' ') || 'none';
294
+ }, [transform]);
295
+
296
+ // Keyboard shortcuts
297
+ useEffect(() => {
298
+ const handleKeyDown = (e: KeyboardEvent) => {
299
+ if (!containerRef.current?.contains(document.activeElement) &&
300
+ document.activeElement !== containerRef.current) {
301
+ return;
302
+ }
303
+
304
+ const controls = controlsRef.current;
305
+ if (!controls) return;
306
+
307
+ switch (e.key) {
308
+ case '+':
309
+ case '=':
310
+ e.preventDefault();
311
+ controls.zoomIn();
312
+ break;
313
+ case '-':
314
+ e.preventDefault();
315
+ controls.zoomOut();
316
+ break;
317
+ case '0':
318
+ e.preventDefault();
319
+ controls.resetTransform();
320
+ break;
321
+ case 'r':
322
+ if (!e.metaKey && !e.ctrlKey) {
323
+ e.preventDefault();
324
+ handleRotate();
325
+ }
326
+ break;
327
+ }
328
+ };
329
+
330
+ window.addEventListener('keydown', handleKeyDown);
331
+ return () => window.removeEventListener('keydown', handleKeyDown);
332
+ }, [handleRotate]);
333
+
334
+ if (!hasContent) {
335
+ return (
336
+ <div className="flex-1 flex flex-col items-center justify-center gap-2 bg-muted/30">
337
+ <ImageIcon className="w-12 h-12 text-muted-foreground/50" />
338
+ <p className="text-sm text-muted-foreground">Failed to load image</p>
339
+ </div>
340
+ );
341
+ }
342
+
343
+ return (
344
+ <div
345
+ ref={containerRef}
346
+ tabIndex={0}
347
+ className={cn(
348
+ "flex-1 h-full relative overflow-hidden outline-none",
349
+ "bg-[length:16px_16px]",
350
+ "[background-color:hsl(var(--muted)/0.2)]",
351
+ "[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%)]",
352
+ "[background-position:0_0,0_8px,8px_-8px,-8px_0px]"
353
+ )}
354
+ >
355
+ {src && <ImageInfo src={src} />}
356
+
357
+ <TransformWrapper
358
+ initialScale={1}
359
+ minScale={0.1}
360
+ maxScale={8}
361
+ centerOnInit
362
+ onTransformed={(ref, state) => {
363
+ setScale(state.scale);
364
+ controlsRef.current = ref;
365
+ }}
366
+ onInit={(ref) => {
367
+ controlsRef.current = ref;
368
+ }}
369
+ wheel={{ step: 0.1 }}
370
+ doubleClick={{ mode: 'toggle', step: 2 }}
371
+ panning={{ velocityDisabled: false }}
372
+ >
373
+ <ImageToolbar
374
+ scale={scale}
375
+ transform={transform}
376
+ onRotate={handleRotate}
377
+ onFlipH={handleFlipH}
378
+ onFlipV={handleFlipV}
379
+ onZoomPreset={handleZoomPreset}
380
+ onExpand={!inDialog ? handleExpand : undefined}
381
+ />
382
+
383
+ <TransformComponent
384
+ wrapperClass="!w-full !h-full cursor-grab active:cursor-grabbing"
385
+ contentClass="!w-full !h-full flex items-center justify-center"
386
+ >
387
+ <img
388
+ src={src!}
389
+ alt={file.name}
390
+ className="max-w-full max-h-full object-contain select-none"
391
+ style={{ transform: imageTransform, transition: 'transform 0.15s ease-out' }}
392
+ draggable={false}
393
+ />
394
+ </TransformComponent>
395
+ </TransformWrapper>
396
+
397
+ {/* Fullscreen dialog */}
398
+ {!inDialog && (
399
+ <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
400
+ <DialogContent className="max-w-[95vw] max-h-[95vh] w-[95vw] h-[95vh] p-0 overflow-hidden [&>button]:hidden">
401
+ <div className="h-full flex flex-col">
402
+ <div className="flex items-center justify-between px-4 py-2 border-b shrink-0">
403
+ <span className="text-sm font-medium truncate">{file.name}</span>
404
+ </div>
405
+ <div className="flex-1 min-h-0 relative">
406
+ <div className="absolute inset-0">
407
+ <ImageViewer file={file} content={content} inDialog />
408
+ </div>
409
+ </div>
410
+ </div>
411
+ </DialogContent>
412
+ </Dialog>
413
+ )}
414
+ </div>
415
+ );
416
+ }
@@ -0,0 +1,161 @@
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
+ | `inDialog` | `boolean` | `false` | Hide expand button (for nested usage) |
43
+
44
+ ## ImageFile Type
45
+
46
+ ```typescript
47
+ interface ImageFile {
48
+ name: string; // Display name
49
+ path: string; // File path (for state tracking)
50
+ mimeType?: string; // MIME type (e.g., 'image/png')
51
+ }
52
+ ```
53
+
54
+ ## Content Formats
55
+
56
+ The `content` prop accepts:
57
+
58
+ - **ArrayBuffer**: Binary image data (creates blob URL)
59
+ - **Data URL**: Base64 encoded string starting with `data:`
60
+ - **Base64 string**: Raw base64 (auto-prefixed with data URL)
61
+
62
+ ```tsx
63
+ // ArrayBuffer (from fetch or file read)
64
+ const response = await fetch('/image.png');
65
+ const buffer = await response.arrayBuffer();
66
+ <ImageViewer file={file} content={buffer} />
67
+
68
+ // Data URL
69
+ <ImageViewer file={file} content="data:image/png;base64,iVBORw0KGgo..." />
70
+
71
+ // Base64 string (auto-converted to data URL)
72
+ <ImageViewer file={file} content="iVBORw0KGgo..." />
73
+ ```
74
+
75
+ ## Keyboard Shortcuts
76
+
77
+ | Key | Action |
78
+ |-----|--------|
79
+ | `+` / `=` | Zoom in |
80
+ | `-` | Zoom out |
81
+ | `0` | Reset to fit |
82
+ | `R` | Rotate 90° |
83
+
84
+ ## Toolbar Controls
85
+
86
+ The floating toolbar at the bottom provides:
87
+
88
+ - **Zoom out** button
89
+ - **Zoom level** dropdown with presets
90
+ - **Zoom in** button
91
+ - **Fit to view** button
92
+ - **Flip horizontal** toggle
93
+ - **Flip vertical** toggle
94
+ - **Rotate 90°** button
95
+ - **Expand** fullscreen button
96
+
97
+ ## Fullscreen Mode
98
+
99
+ Click the expand button to open the image in a fullscreen dialog. The dialog includes the same toolbar and supports all interactions.
100
+
101
+ ```tsx
102
+ // Fullscreen is automatically available unless inDialog is true
103
+ <ImageViewer file={file} content={content} />
104
+
105
+ // When embedding in your own dialog, disable the expand button
106
+ <Dialog>
107
+ <ImageViewer file={file} content={content} inDialog />
108
+ </Dialog>
109
+ ```
110
+
111
+ ## Styling
112
+
113
+ The component fills its container and displays a checkerboard pattern behind transparent images.
114
+
115
+ ```tsx
116
+ <div className="w-full h-[500px]">
117
+ <ImageViewer file={file} content={content} />
118
+ </div>
119
+ ```
120
+
121
+ ## Error State
122
+
123
+ When content is empty or invalid, displays an error placeholder:
124
+
125
+ ```tsx
126
+ // Shows "Failed to load image" with icon
127
+ <ImageViewer file={file} content="" />
128
+ ```
129
+
130
+ ## Example: File Browser Integration
131
+
132
+ ```tsx
133
+ function FilePreview({ file, content }: { file: OpenFile; content: ArrayBuffer }) {
134
+ const imageFile: ImageFile = {
135
+ name: file.name,
136
+ path: file.path,
137
+ mimeType: file.mimeType,
138
+ };
139
+
140
+ return (
141
+ <div className="h-full">
142
+ <ImageViewer file={imageFile} content={content} />
143
+ </div>
144
+ );
145
+ }
146
+ ```
147
+
148
+ ## Architecture
149
+
150
+ ```
151
+ ImageViewer/
152
+ ├── index.ts # Exports
153
+ ├── ImageViewer.tsx # Main component with all functionality
154
+ └── README.md # This file
155
+ ```
156
+
157
+ ## Dependencies
158
+
159
+ - `react-zoom-pan-pinch` - Zoom and pan functionality
160
+ - `lucide-react` - Icons
161
+ - `@djangocfg/ui-core` - UI components (Button, Dialog, etc.)
@@ -0,0 +1,16 @@
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
+ * - Checkerboard background for transparency
12
+ * - Image dimensions display
13
+ */
14
+
15
+ export { ImageViewer } from './ImageViewer';
16
+ export type { ImageViewerProps, ImageFile } from './ImageViewer';