@djangocfg/ui-nextjs 2.1.66 → 2.1.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -6
- package/src/stores/index.ts +8 -0
- package/src/stores/mediaCache.ts +464 -0
- package/src/tools/AudioPlayer/@refactoring/00-PLAN.md +148 -0
- package/src/tools/AudioPlayer/@refactoring/01-TYPES.md +301 -0
- package/src/tools/AudioPlayer/@refactoring/02-HOOKS.md +281 -0
- package/src/tools/AudioPlayer/@refactoring/03-CONTEXT.md +328 -0
- package/src/tools/AudioPlayer/@refactoring/04-COMPONENTS.md +251 -0
- package/src/tools/AudioPlayer/@refactoring/05-EFFECTS.md +427 -0
- package/src/tools/AudioPlayer/@refactoring/06-UTILS-AND-INDEX.md +193 -0
- package/src/tools/AudioPlayer/@refactoring/07-EXECUTION-CHECKLIST.md +146 -0
- package/src/tools/AudioPlayer/README.md +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,150 @@
|
|
|
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, cn } from '@djangocfg/ui-core';
|
|
11
|
+
import {
|
|
12
|
+
DropdownMenu,
|
|
13
|
+
DropdownMenuContent,
|
|
14
|
+
DropdownMenuItem,
|
|
15
|
+
DropdownMenuTrigger,
|
|
16
|
+
} from '../../../components/dropdown-menu';
|
|
17
|
+
import { ZOOM_PRESETS } from '../utils';
|
|
18
|
+
import type { ImageToolbarProps } from '../types';
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// COMPONENT
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
export function ImageToolbar({
|
|
25
|
+
scale,
|
|
26
|
+
transform,
|
|
27
|
+
onRotate,
|
|
28
|
+
onFlipH,
|
|
29
|
+
onFlipV,
|
|
30
|
+
onZoomPreset,
|
|
31
|
+
onExpand,
|
|
32
|
+
}: ImageToolbarProps) {
|
|
33
|
+
const { zoomIn, zoomOut, resetTransform } = useControls();
|
|
34
|
+
|
|
35
|
+
const zoomLabel = useMemo(() => {
|
|
36
|
+
const percent = Math.round(scale * 100);
|
|
37
|
+
return `${percent}%`;
|
|
38
|
+
}, [scale]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<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">
|
|
42
|
+
{/* Zoom controls */}
|
|
43
|
+
<Button
|
|
44
|
+
variant="ghost"
|
|
45
|
+
size="icon"
|
|
46
|
+
className="h-7 w-7"
|
|
47
|
+
onClick={() => zoomOut()}
|
|
48
|
+
title="Zoom out"
|
|
49
|
+
>
|
|
50
|
+
<ZoomOut className="h-3.5 w-3.5" />
|
|
51
|
+
</Button>
|
|
52
|
+
|
|
53
|
+
<DropdownMenu>
|
|
54
|
+
<DropdownMenuTrigger asChild>
|
|
55
|
+
<Button variant="ghost" size="sm" className="h-7 px-2 min-w-[52px] font-mono text-xs">
|
|
56
|
+
{zoomLabel}
|
|
57
|
+
</Button>
|
|
58
|
+
</DropdownMenuTrigger>
|
|
59
|
+
<DropdownMenuContent align="center" className="min-w-[80px]">
|
|
60
|
+
{ZOOM_PRESETS.map((preset) => (
|
|
61
|
+
<DropdownMenuItem
|
|
62
|
+
key={preset.label}
|
|
63
|
+
onClick={() => onZoomPreset(preset.value)}
|
|
64
|
+
className="text-xs justify-center"
|
|
65
|
+
>
|
|
66
|
+
{preset.label}
|
|
67
|
+
</DropdownMenuItem>
|
|
68
|
+
))}
|
|
69
|
+
</DropdownMenuContent>
|
|
70
|
+
</DropdownMenu>
|
|
71
|
+
|
|
72
|
+
<Button
|
|
73
|
+
variant="ghost"
|
|
74
|
+
size="icon"
|
|
75
|
+
className="h-7 w-7"
|
|
76
|
+
onClick={() => zoomIn()}
|
|
77
|
+
title="Zoom in"
|
|
78
|
+
>
|
|
79
|
+
<ZoomIn className="h-3.5 w-3.5" />
|
|
80
|
+
</Button>
|
|
81
|
+
|
|
82
|
+
<div className="w-px h-4 bg-border mx-1" />
|
|
83
|
+
|
|
84
|
+
{/* Fit to view */}
|
|
85
|
+
<Button
|
|
86
|
+
variant="ghost"
|
|
87
|
+
size="icon"
|
|
88
|
+
className="h-7 w-7"
|
|
89
|
+
onClick={() => resetTransform()}
|
|
90
|
+
title="Fit to view"
|
|
91
|
+
>
|
|
92
|
+
<Maximize2 className="h-3.5 w-3.5" />
|
|
93
|
+
</Button>
|
|
94
|
+
|
|
95
|
+
<div className="w-px h-4 bg-border mx-1" />
|
|
96
|
+
|
|
97
|
+
{/* Transform controls */}
|
|
98
|
+
<Button
|
|
99
|
+
variant="ghost"
|
|
100
|
+
size="icon"
|
|
101
|
+
className={cn('h-7 w-7', transform.flipH && 'bg-accent')}
|
|
102
|
+
onClick={onFlipH}
|
|
103
|
+
title="Flip horizontal"
|
|
104
|
+
>
|
|
105
|
+
<FlipHorizontal className="h-3.5 w-3.5" />
|
|
106
|
+
</Button>
|
|
107
|
+
|
|
108
|
+
<Button
|
|
109
|
+
variant="ghost"
|
|
110
|
+
size="icon"
|
|
111
|
+
className={cn('h-7 w-7', transform.flipV && 'bg-accent')}
|
|
112
|
+
onClick={onFlipV}
|
|
113
|
+
title="Flip vertical"
|
|
114
|
+
>
|
|
115
|
+
<FlipVertical className="h-3.5 w-3.5" />
|
|
116
|
+
</Button>
|
|
117
|
+
|
|
118
|
+
<Button
|
|
119
|
+
variant="ghost"
|
|
120
|
+
size="icon"
|
|
121
|
+
className="h-7 w-7"
|
|
122
|
+
onClick={onRotate}
|
|
123
|
+
title="Rotate 90°"
|
|
124
|
+
>
|
|
125
|
+
<RotateCw className="h-3.5 w-3.5" />
|
|
126
|
+
</Button>
|
|
127
|
+
|
|
128
|
+
{transform.rotation !== 0 && (
|
|
129
|
+
<span className="text-[10px] text-muted-foreground font-mono pl-1">
|
|
130
|
+
{transform.rotation}°
|
|
131
|
+
</span>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{onExpand && (
|
|
135
|
+
<>
|
|
136
|
+
<div className="w-px h-4 bg-border mx-1" />
|
|
137
|
+
<Button
|
|
138
|
+
variant="ghost"
|
|
139
|
+
size="icon"
|
|
140
|
+
className="h-7 w-7"
|
|
141
|
+
onClick={onExpand}
|
|
142
|
+
title="Open in fullscreen"
|
|
143
|
+
>
|
|
144
|
+
<Expand className="h-3.5 w-3.5" />
|
|
145
|
+
</Button>
|
|
146
|
+
</>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -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,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
|
+
}
|