fluxy-bot 0.8.9 → 0.9.0
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/README.md +53 -84
- package/bin/cli.js +1717 -6
- package/components.json +18 -18
- package/package.json +98 -109
- package/postcss.config.js +3 -3
- package/scripts/install.ps1 +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/postinstall.js +47 -58
- package/shared/ai.ts +118 -202
- package/shared/config.ts +37 -39
- package/shared/logger.ts +6 -14
- package/shared/paths.ts +10 -16
- package/shared/relay.ts +89 -98
- package/supervisor/backend.ts +77 -89
- package/supervisor/chat/ARCHITECTURE.md +13 -14
- package/supervisor/chat/OnboardWizard.tsx +2194 -3419
- package/supervisor/chat/fluxy-main.tsx +495 -633
- package/supervisor/chat/fluxy.html +24 -36
- package/supervisor/chat/onboard-main.tsx +8 -8
- package/supervisor/chat/onboard.html +10 -13
- package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
- package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
- package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
- package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
- package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
- package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
- package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
- package/supervisor/chat/src/hooks/useChat.ts +210 -259
- package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
- package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
- package/supervisor/chat/src/lib/auth.ts +20 -23
- package/supervisor/chat/src/lib/ws-client.ts +113 -125
- package/supervisor/chat/src/styles/globals.css +76 -85
- package/supervisor/file-saver.ts +39 -52
- package/supervisor/fluxy-agent.ts +213 -292
- package/supervisor/index.ts +895 -1263
- package/supervisor/scheduler.ts +237 -299
- package/supervisor/tunnel.ts +113 -167
- package/supervisor/vite-dev.ts +51 -61
- package/supervisor/widget.js +135 -147
- package/supervisor/worker.ts +45 -49
- package/tsconfig.json +18 -24
- package/vite.config.ts +48 -55
- package/vite.fluxy.config.ts +34 -34
- package/worker/claude-auth.ts +202 -265
- package/worker/codex-auth.ts +144 -181
- package/worker/db.ts +63 -191
- package/worker/index.ts +601 -842
- package/workspace/MYSELF.md +1 -1
- package/workspace/PULSE.json +6 -6
- package/workspace/backend/index.ts +16 -19
- package/workspace/client/index.html +47 -60
- package/workspace/client/public/manifest.json +21 -21
- package/workspace/client/public/sw.js +29 -31
- package/workspace/client/src/App.tsx +91 -136
- package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
- package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
- package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
- package/workspace/client/src/components/Layout/Footer.tsx +12 -12
- package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
- package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
- package/workspace/client/src/components/ui/avatar.tsx +85 -85
- package/workspace/client/src/components/ui/badge.tsx +40 -41
- package/workspace/client/src/components/ui/button.tsx +56 -57
- package/workspace/client/src/components/ui/card.tsx +75 -75
- package/workspace/client/src/components/ui/dialog.tsx +114 -117
- package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
- package/workspace/client/src/components/ui/input.tsx +17 -17
- package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
- package/workspace/client/src/components/ui/select.tsx +145 -151
- package/workspace/client/src/components/ui/separator.tsx +21 -21
- package/workspace/client/src/components/ui/sheet.tsx +102 -102
- package/workspace/client/src/components/ui/skeleton.tsx +10 -10
- package/workspace/client/src/components/ui/switch.tsx +26 -26
- package/workspace/client/src/components/ui/tabs.tsx +69 -69
- package/workspace/client/src/components/ui/textarea.tsx +14 -14
- package/workspace/client/src/components/ui/tooltip.tsx +37 -37
- package/workspace/client/src/lib/utils.ts +1 -1
- package/workspace/client/src/main.tsx +3 -3
- package/workspace/client/src/styles/globals.css +76 -85
- package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
- package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
- package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
- package/bin/cli.backup.js +0 -2138
- package/cli/commands/daemon.ts +0 -42
- package/cli/commands/init.ts +0 -32
- package/cli/commands/start.ts +0 -113
- package/cli/commands/tunnel.temp.ts +0 -206
- package/cli/commands/tunnel.ts +0 -227
- package/cli/commands/update.ts +0 -163
- package/cli/core/base-adapter.ts +0 -156
- package/cli/core/cloudflared.ts +0 -113
- package/cli/core/config.ts +0 -73
- package/cli/core/os-detector.ts +0 -43
- package/cli/core/server.ts +0 -109
- package/cli/core/types.ts +0 -15
- package/cli/index.ts +0 -72
- package/cli/platforms/darwin.ts +0 -146
- package/cli/platforms/index.ts +0 -21
- package/cli/platforms/linux.ts +0 -156
- package/cli/platforms/win32.ts +0 -34
- package/cli/utils/ui.ts +0 -37
|
@@ -1,38 +1,26 @@
|
|
|
1
|
-
<!
|
|
1
|
+
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
w.addEventListener('statechange', function () {
|
|
27
|
-
if (
|
|
28
|
-
w.state === 'installed' &&
|
|
29
|
-
navigator.serviceWorker.controller
|
|
30
|
-
)
|
|
31
|
-
w.postMessage({ type: 'SKIP_WAITING' });
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
});
|
|
35
|
-
}
|
|
36
|
-
</script>
|
|
37
|
-
</body>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
|
|
6
|
+
<title>Fluxy Chat</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body class="bg-background text-foreground">
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/fluxy-main.tsx"></script>
|
|
11
|
+
<script>
|
|
12
|
+
if('serviceWorker' in navigator){
|
|
13
|
+
navigator.serviceWorker.register('/fluxy/sw.js').then(function(r){
|
|
14
|
+
r.update();
|
|
15
|
+
if(r.waiting){r.waiting.postMessage({type:'SKIP_WAITING'})}
|
|
16
|
+
r.addEventListener('updatefound',function(){
|
|
17
|
+
var w=r.installing;
|
|
18
|
+
if(w)w.addEventListener('statechange',function(){
|
|
19
|
+
if(w.state==='installed'&&navigator.serviceWorker.controller)w.postMessage({type:'SKIP_WAITING'});
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
</script>
|
|
25
|
+
</body>
|
|
38
26
|
</html>
|
|
@@ -4,16 +4,16 @@ import OnboardWizard from './OnboardWizard';
|
|
|
4
4
|
import './src/styles/globals.css';
|
|
5
5
|
|
|
6
6
|
function App() {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const handleComplete = () => {
|
|
8
|
+
// Notify the parent (dashboard) that onboarding is done
|
|
9
|
+
window.parent?.postMessage({ type: 'fluxy:onboard-complete' }, '*');
|
|
10
|
+
};
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
return <OnboardWizard onComplete={handleComplete} isInitialSetup />;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
<React.StrictMode>
|
|
17
|
+
<App />
|
|
18
|
+
</React.StrictMode>,
|
|
19
19
|
);
|
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
<!
|
|
1
|
+
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
<div id="root"></div>
|
|
13
|
-
<script type="module" src="/onboard-main.tsx"></script>
|
|
14
|
-
</body>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
|
|
6
|
+
<title>Fluxy Setup</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body class="bg-background text-foreground">
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/onboard-main.tsx"></script>
|
|
11
|
+
</body>
|
|
15
12
|
</html>
|
|
@@ -2,130 +2,116 @@ import { useState, useRef, useCallback, useEffect } from 'react';
|
|
|
2
2
|
import { Play, Pause } from 'lucide-react';
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
|
-
|
|
5
|
+
audioData: string; // base64 data URL (data:audio/webm;base64,...)
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
function formatDuration(s: number): string {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
if (!isFinite(s) || isNaN(s)) return '0:00';
|
|
10
|
+
const mins = Math.floor(s / 60);
|
|
11
|
+
const secs = Math.floor(s % 60);
|
|
12
|
+
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export default function AudioBubble({ audioData }: Props) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
const [playing, setPlaying] = useState(false);
|
|
17
|
+
const [progress, setProgress] = useState(0);
|
|
18
|
+
const [duration, setDuration] = useState(0);
|
|
19
|
+
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
20
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
// Create Audio element once
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const audio = new Audio(audioData);
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
audio.addEventListener('durationchange', () => {
|
|
27
|
+
// webm often reports Infinity initially — skip until real
|
|
28
|
+
if (isFinite(audio.duration)) setDuration(audio.duration);
|
|
29
|
+
});
|
|
30
30
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
31
|
+
audio.addEventListener('timeupdate', () => {
|
|
32
|
+
if (isFinite(audio.duration) && audio.duration > 0) {
|
|
33
|
+
setProgress(audio.currentTime / audio.duration);
|
|
34
|
+
// Update duration if we didn't get it from durationchange
|
|
35
|
+
if (duration === 0) setDuration(audio.duration);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
audio.addEventListener('ended', () => {
|
|
40
|
+
setPlaying(false);
|
|
41
|
+
setProgress(0);
|
|
42
|
+
});
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
});
|
|
55
|
-
}
|
|
44
|
+
// Workaround for webm duration=Infinity: seek to large value to force browser to calculate duration
|
|
45
|
+
audio.addEventListener('loadedmetadata', () => {
|
|
46
|
+
if (!isFinite(audio.duration)) {
|
|
47
|
+
audio.currentTime = 1e10;
|
|
48
|
+
audio.addEventListener('timeupdate', function seekBack() {
|
|
49
|
+
if (isFinite(audio.duration)) {
|
|
50
|
+
setDuration(audio.duration);
|
|
51
|
+
audio.currentTime = 0;
|
|
52
|
+
audio.removeEventListener('timeupdate', seekBack);
|
|
53
|
+
}
|
|
56
54
|
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
audioRef.current = audio;
|
|
59
59
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
return () => {
|
|
61
|
+
audio.pause();
|
|
62
|
+
audio.src = '';
|
|
63
|
+
};
|
|
64
|
+
}, [audioData]);
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
66
|
+
const togglePlay = useCallback(() => {
|
|
67
|
+
const audio = audioRef.current;
|
|
68
|
+
if (!audio) return;
|
|
69
|
+
if (playing) {
|
|
70
|
+
audio.pause();
|
|
71
|
+
setPlaying(false);
|
|
72
|
+
} else {
|
|
73
|
+
audio.play();
|
|
74
|
+
setPlaying(true);
|
|
75
|
+
}
|
|
76
|
+
}, [playing]);
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
audio.currentTime = ratio * audio.duration;
|
|
88
|
-
setProgress(ratio);
|
|
89
|
-
}, []);
|
|
78
|
+
const handleSeek = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
79
|
+
const audio = audioRef.current;
|
|
80
|
+
const bar = barRef.current;
|
|
81
|
+
if (!audio || !bar || !isFinite(audio.duration)) return;
|
|
82
|
+
const rect = bar.getBoundingClientRect();
|
|
83
|
+
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
84
|
+
audio.currentTime = ratio * audio.duration;
|
|
85
|
+
setProgress(ratio);
|
|
86
|
+
}, []);
|
|
90
87
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
role="slider"
|
|
114
|
-
aria-valuenow={currentTime}
|
|
115
|
-
aria-valuemax={duration}
|
|
116
|
-
aria-valuemin={0}
|
|
117
|
-
tabIndex={0}
|
|
118
|
-
className="h-1 bg-white/20 rounded-full cursor-pointer relative"
|
|
119
|
-
>
|
|
120
|
-
<div
|
|
121
|
-
className="absolute inset-y-0 left-0 bg-white/70 rounded-full transition-[width] duration-100"
|
|
122
|
-
style={{ width: `${progress * 100}%` }}
|
|
123
|
-
/>
|
|
124
|
-
</div>
|
|
125
|
-
<span className="text-[10px] opacity-60 tabular-nums">
|
|
126
|
-
{formatDuration(playing ? progress * duration : duration)}
|
|
127
|
-
</span>
|
|
128
|
-
</div>
|
|
88
|
+
return (
|
|
89
|
+
<div className="flex items-center gap-2.5 min-w-[180px]">
|
|
90
|
+
<button
|
|
91
|
+
onClick={togglePlay}
|
|
92
|
+
className="flex items-center justify-center h-8 w-8 shrink-0 rounded-full bg-white/20 hover:bg-white/30 transition-colors"
|
|
93
|
+
>
|
|
94
|
+
{playing ? (
|
|
95
|
+
<Pause className="h-3.5 w-3.5 fill-current" />
|
|
96
|
+
) : (
|
|
97
|
+
<Play className="h-3.5 w-3.5 fill-current ml-0.5" />
|
|
98
|
+
)}
|
|
99
|
+
</button>
|
|
100
|
+
<div className="flex-1 flex flex-col gap-1">
|
|
101
|
+
<div
|
|
102
|
+
ref={barRef}
|
|
103
|
+
onClick={handleSeek}
|
|
104
|
+
className="h-1 bg-white/20 rounded-full cursor-pointer relative"
|
|
105
|
+
>
|
|
106
|
+
<div
|
|
107
|
+
className="absolute inset-y-0 left-0 bg-white/70 rounded-full transition-[width] duration-100"
|
|
108
|
+
style={{ width: `${progress * 100}%` }}
|
|
109
|
+
/>
|
|
129
110
|
</div>
|
|
130
|
-
|
|
111
|
+
<span className="text-[10px] opacity-60 tabular-nums">
|
|
112
|
+
{formatDuration(playing ? progress * duration : duration)}
|
|
113
|
+
</span>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
131
117
|
}
|
|
@@ -4,44 +4,22 @@ import MessageList from './MessageList';
|
|
|
4
4
|
import InputBar from './InputBar';
|
|
5
5
|
|
|
6
6
|
interface Props {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
ws: WsClient | null;
|
|
8
|
+
onClearContext?: () => void;
|
|
9
|
+
clearContextRef?: React.MutableRefObject<(() => void) | null>;
|
|
10
|
+
whisperEnabled?: boolean;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export default function ChatView({
|
|
14
|
-
|
|
15
|
-
clearContextRef,
|
|
16
|
-
whisperEnabled
|
|
17
|
-
}: Props) {
|
|
18
|
-
const {
|
|
19
|
-
messages,
|
|
20
|
-
streaming,
|
|
21
|
-
streamBuffer,
|
|
22
|
-
tools,
|
|
23
|
-
sendMessage,
|
|
24
|
-
stopStreaming,
|
|
25
|
-
clearContext
|
|
26
|
-
} = useChat(ws);
|
|
13
|
+
export default function ChatView({ ws, clearContextRef, whisperEnabled }: Props) {
|
|
14
|
+
const { messages, streaming, streamBuffer, tools, sendMessage, stopStreaming, clearContext } = useChat(ws);
|
|
27
15
|
|
|
28
|
-
|
|
29
|
-
|
|
16
|
+
// Expose clearContext to parent via ref
|
|
17
|
+
if (clearContextRef) clearContextRef.current = clearContext;
|
|
30
18
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
tools={tools}
|
|
38
|
-
/>
|
|
39
|
-
<InputBar
|
|
40
|
-
onSend={sendMessage}
|
|
41
|
-
onStop={stopStreaming}
|
|
42
|
-
streaming={streaming}
|
|
43
|
-
whisperEnabled={whisperEnabled}
|
|
44
|
-
/>
|
|
45
|
-
</div>
|
|
46
|
-
);
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex flex-col h-full overflow-hidden">
|
|
21
|
+
<MessageList messages={messages} streaming={streaming} streamBuffer={streamBuffer} tools={tools} />
|
|
22
|
+
<InputBar onSend={sendMessage} onStop={stopStreaming} streaming={streaming} whisperEnabled={whisperEnabled} />
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
47
25
|
}
|
|
@@ -3,102 +3,84 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|
|
3
3
|
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
|
|
4
4
|
|
|
5
5
|
interface Props {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
images: string[];
|
|
7
|
+
index: number;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onNavigate: (index: number) => void;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export default function ImageLightbox({
|
|
13
|
-
|
|
14
|
-
index
|
|
15
|
-
|
|
16
|
-
onNavigate
|
|
17
|
-
}: Props) {
|
|
18
|
-
const goPrev = useCallback(() => {
|
|
19
|
-
if (index > 0) onNavigate(index - 1);
|
|
20
|
-
}, [index, onNavigate]);
|
|
12
|
+
export default function ImageLightbox({ images, index, onClose, onNavigate }: Props) {
|
|
13
|
+
const goPrev = useCallback(() => {
|
|
14
|
+
if (index > 0) onNavigate(index - 1);
|
|
15
|
+
}, [index, onNavigate]);
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
const goNext = useCallback(() => {
|
|
18
|
+
if (index < images.length - 1) onNavigate(index + 1);
|
|
19
|
+
}, [index, images.length, onNavigate]);
|
|
25
20
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
23
|
+
if (e.key === 'Escape') onClose();
|
|
24
|
+
if (e.key === 'ArrowLeft') goPrev();
|
|
25
|
+
if (e.key === 'ArrowRight') goNext();
|
|
26
|
+
};
|
|
27
|
+
window.addEventListener('keydown', handleKey);
|
|
28
|
+
return () => window.removeEventListener('keydown', handleKey);
|
|
29
|
+
}, [onClose, goPrev, goNext]);
|
|
35
30
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
31
|
+
return (
|
|
32
|
+
<AnimatePresence>
|
|
33
|
+
<motion.div
|
|
34
|
+
initial={{ opacity: 0 }}
|
|
35
|
+
animate={{ opacity: 1 }}
|
|
36
|
+
exit={{ opacity: 0 }}
|
|
37
|
+
transition={{ duration: 0.15 }}
|
|
38
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
|
|
39
|
+
onClick={onClose}
|
|
40
|
+
>
|
|
41
|
+
{/* Close button */}
|
|
42
|
+
<button
|
|
43
|
+
onClick={onClose}
|
|
44
|
+
className="absolute top-4 right-4 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white"
|
|
45
|
+
>
|
|
46
|
+
<X className="h-5 w-5" />
|
|
47
|
+
</button>
|
|
53
48
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
49
|
+
{/* Image counter */}
|
|
50
|
+
{images.length > 1 && (
|
|
51
|
+
<div className="absolute top-4 left-1/2 -translate-x-1/2 text-white/70 text-sm tabular-nums">
|
|
52
|
+
{index + 1} / {images.length}
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
60
55
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
<ChevronLeft className="h-6 w-6" />
|
|
71
|
-
</button>
|
|
72
|
-
)}
|
|
56
|
+
{/* Left arrow */}
|
|
57
|
+
{index > 0 && (
|
|
58
|
+
<button
|
|
59
|
+
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
|
60
|
+
className="absolute left-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white"
|
|
61
|
+
>
|
|
62
|
+
<ChevronLeft className="h-6 w-6" />
|
|
63
|
+
</button>
|
|
64
|
+
)}
|
|
73
65
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
<ChevronRight className="h-6 w-6" />
|
|
84
|
-
</button>
|
|
85
|
-
)}
|
|
66
|
+
{/* Right arrow */}
|
|
67
|
+
{index < images.length - 1 && (
|
|
68
|
+
<button
|
|
69
|
+
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
|
70
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors text-white"
|
|
71
|
+
>
|
|
72
|
+
<ChevronRight className="h-6 w-6" />
|
|
73
|
+
</button>
|
|
74
|
+
)}
|
|
86
75
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
}}
|
|
99
|
-
onClick={e => e.stopPropagation()}
|
|
100
|
-
/>
|
|
101
|
-
</motion.div>
|
|
102
|
-
</AnimatePresence>
|
|
103
|
-
);
|
|
76
|
+
{/* Image */}
|
|
77
|
+
<img
|
|
78
|
+
src={images[index]}
|
|
79
|
+
alt=""
|
|
80
|
+
className="max-h-[85vh] max-w-[90vw] object-contain rounded-lg"
|
|
81
|
+
onClick={(e) => e.stopPropagation()}
|
|
82
|
+
/>
|
|
83
|
+
</motion.div>
|
|
84
|
+
</AnimatePresence>
|
|
85
|
+
);
|
|
104
86
|
}
|