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