create-fluxstack 1.20.1 → 1.21.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.
@@ -1,374 +1,397 @@
1
- // RoomChatDemo - Chat multi-salas with password-protected rooms
2
-
3
- import { useEffect, useRef, useMemo, useReducer } from 'react'
4
- import { Live } from '@/core/client'
5
- import { LiveRoomChat } from '@server/live/LiveRoomChat'
6
-
7
- const DEFAULT_ROOMS = [
8
- { id: 'geral', name: 'Geral' },
9
- { id: 'tech', name: 'Tecnologia' },
10
- { id: 'random', name: 'Random' },
11
- ]
12
-
13
- // Consolidated UI state to avoid fragmented useState calls and ensure
14
- // related modal states update atomically.
15
- interface ChatUIState {
16
- text: string
17
- error: string
18
- createModal: { open: boolean; name: string; password: string }
19
- passwordPrompt: { roomId: string; roomName: string; input: string } | null
20
- }
21
-
22
- type ChatUIAction =
23
- | { type: 'SET_TEXT'; text: string }
24
- | { type: 'SET_ERROR'; error: string }
25
- | { type: 'OPEN_CREATE_MODAL' }
26
- | { type: 'CLOSE_CREATE_MODAL' }
27
- | { type: 'UPDATE_CREATE_FORM'; name?: string; password?: string }
28
- | { type: 'OPEN_PASSWORD_PROMPT'; roomId: string; roomName: string }
29
- | { type: 'CLOSE_PASSWORD_PROMPT' }
30
- | { type: 'SET_PASSWORD_INPUT'; input: string }
31
-
32
- function chatUIReducer(state: ChatUIState, action: ChatUIAction): ChatUIState {
33
- switch (action.type) {
34
- case 'SET_TEXT':
35
- return { ...state, text: action.text }
36
- case 'SET_ERROR':
37
- return { ...state, error: action.error }
38
- case 'OPEN_CREATE_MODAL':
39
- return { ...state, createModal: { open: true, name: '', password: '' } }
40
- case 'CLOSE_CREATE_MODAL':
41
- return { ...state, createModal: { open: false, name: '', password: '' } }
42
- case 'UPDATE_CREATE_FORM':
43
- return { ...state, createModal: { ...state.createModal, name: action.name ?? state.createModal.name, password: action.password ?? state.createModal.password } }
44
- case 'OPEN_PASSWORD_PROMPT':
45
- return { ...state, passwordPrompt: { roomId: action.roomId, roomName: action.roomName, input: '' } }
46
- case 'CLOSE_PASSWORD_PROMPT':
47
- return { ...state, passwordPrompt: null }
48
- case 'SET_PASSWORD_INPUT':
49
- return state.passwordPrompt ? { ...state, passwordPrompt: { ...state.passwordPrompt, input: action.input } } : state
50
- default:
51
- return state
52
- }
53
- }
54
-
55
- const initialUIState: ChatUIState = {
56
- text: '',
57
- error: '',
58
- createModal: { open: false, name: '', password: '' },
59
- passwordPrompt: null,
60
- }
61
-
62
- export function RoomChatDemo() {
63
- const [ui, dispatch] = useReducer(chatUIReducer, initialUIState)
64
- const messagesEndRef = useRef<HTMLDivElement>(null)
65
-
66
- const defaultUsername = useMemo(() => {
67
- const adj = ['Happy', 'Cool', 'Fast', 'Smart', 'Brave'][Math.floor(Math.random() * 5)]
68
- const noun = ['Panda', 'Tiger', 'Eagle', 'Wolf', 'Bear'][Math.floor(Math.random() * 5)]
69
- return `${adj}${noun}${Math.floor(Math.random() * 100)}`
70
- }, [])
71
-
72
- const chat = Live.use(LiveRoomChat, {
73
- initialState: { ...LiveRoomChat.defaultState, username: defaultUsername }
74
- })
75
-
76
- const activeRoom = chat.$state.activeRoom
77
- const activeMessages = activeRoom ? (chat.$state.messages[activeRoom] || []) : []
78
-
79
- useEffect(() => {
80
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
81
- }, [activeMessages.length])
82
-
83
- useEffect(() => {
84
- if (ui.error) {
85
- const t = setTimeout(() => dispatch({ type: 'SET_ERROR', error: '' }), 3000)
86
- return () => clearTimeout(t)
87
- }
88
- }, [ui.error])
89
-
90
- const joinedRoomIds = chat.$state.rooms.map(r => r.id)
91
- const joinedRoomsMap = new Map(chat.$state.rooms.map(r => [r.id, r]))
92
-
93
- const handleJoinRoom = async (roomId: string, roomName: string, isPrivate?: boolean) => {
94
- if (joinedRoomIds.includes(roomId)) {
95
- await chat.switchRoom({ roomId })
96
- return
97
- }
98
-
99
- // If the room is known to be private, prompt for password
100
- if (isPrivate) {
101
- dispatch({ type: 'OPEN_PASSWORD_PROMPT', roomId, roomName })
102
- return
103
- }
104
-
105
- // Try joining without password
106
- const result = await chat.joinRoom({ roomId, roomName })
107
- if (result && !result.success) {
108
- // If rejected, might be password-protected — prompt
109
- dispatch({ type: 'OPEN_PASSWORD_PROMPT', roomId, roomName })
110
- }
111
- }
112
-
113
- const handlePasswordSubmit = async () => {
114
- if (!ui.passwordPrompt) return
115
- const result = await chat.joinRoom({
116
- roomId: ui.passwordPrompt.roomId,
117
- roomName: ui.passwordPrompt.roomName,
118
- password: ui.passwordPrompt.input
119
- })
120
- if (result && !result.success) {
121
- dispatch({ type: 'SET_ERROR', error: result.error || 'Senha incorreta' })
122
- } else {
123
- dispatch({ type: 'CLOSE_PASSWORD_PROMPT' })
124
- }
125
- }
126
-
127
- const handleCreateRoom = async () => {
128
- const name = ui.createModal.name.trim()
129
- if (!name) return
130
- const roomId = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
131
- if (!roomId) return
132
-
133
- const result = await chat.createRoom({
134
- roomId,
135
- roomName: name,
136
- password: ui.createModal.password || undefined
137
- })
138
- if (result && !result.success) {
139
- dispatch({ type: 'SET_ERROR', error: result.error || 'Falha ao criar sala' })
140
- } else {
141
- dispatch({ type: 'CLOSE_CREATE_MODAL' })
142
- }
143
- }
144
-
145
- const handleSendMessage = async () => {
146
- if (!ui.text.trim() || !activeRoom) return
147
- await chat.sendMessage({ text: ui.text })
148
- dispatch({ type: 'SET_TEXT', text: '' })
149
- }
150
-
151
- // Combine default rooms + custom rooms from shared directory (visible to all users)
152
- const customRooms = chat.$state.customRooms || []
153
- const allRooms = [
154
- ...DEFAULT_ROOMS.map(r => ({ ...r, isPrivate: joinedRoomsMap.get(r.id)?.isPrivate ?? false, createdBy: '' })),
155
- ...customRooms
156
- .filter(r => !DEFAULT_ROOMS.some(d => d.id === r.id))
157
- .map(r => ({ id: r.id, name: r.name, isPrivate: r.isPrivate, createdBy: r.createdBy }))
158
- ]
159
-
160
- return (
161
- <div className="flex flex-col md:flex-row h-[calc(100vh-200px)] md:h-[600px] w-full max-w-4xl mx-auto bg-gray-900 rounded-2xl overflow-hidden border border-white/10">
162
- {/* Sidebar */}
163
- <div className={`${activeRoom ? 'hidden md:flex' : 'flex'} w-full md:w-64 bg-gray-800/50 md:border-r border-white/10 flex-col ${!activeRoom ? 'flex-1 md:flex-initial' : ''}`}>
164
- <div className="p-4 border-b border-white/10">
165
- <h2 className="text-lg font-bold text-white mb-2">Room Chat</h2>
166
- <div className="flex items-center gap-2">
167
- <div className={`w-2 h-2 rounded-full ${chat.$connected ? 'bg-emerald-400' : 'bg-red-400'}`} />
168
- <span className="text-sm text-gray-400">{chat.$state.username}</span>
169
- </div>
170
- </div>
171
-
172
- <div className="flex-1 overflow-auto p-2">
173
- <div className="flex items-center justify-between px-2 py-1">
174
- <p className="text-xs text-gray-500">SALAS</p>
175
- <button
176
- onClick={() => dispatch({ type: 'OPEN_CREATE_MODAL' })}
177
- className="text-xs text-theme hover:text-theme"
178
- >+ Criar</button>
179
- </div>
180
- {allRooms.map(room => {
181
- const isJoined = joinedRoomIds.includes(room.id)
182
- const isActive = activeRoom === room.id
183
-
184
- return (
185
- <div
186
- key={room.id}
187
- onClick={() => handleJoinRoom(room.id, room.name, room.isPrivate && !isJoined ? true : undefined)}
188
- className={`
189
- flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer mb-1 transition-all group
190
- ${isActive ? 'bg-theme-muted text-theme' : isJoined ? 'bg-white/5 text-gray-300 hover:bg-white/10' : 'text-gray-500 hover:bg-white/5'}
191
- `}
192
- >
193
- <span className="flex items-center gap-2 min-w-0">
194
- {room.isPrivate && <span className="text-xs shrink-0">&#128274;</span>}
195
- <span className="truncate">
196
- {room.name}
197
- {room.createdBy && <span className="text-xs text-gray-600 ml-1">by {room.createdBy}</span>}
198
- </span>
199
- {isJoined && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 shrink-0" />}
200
- </span>
201
- {isJoined && !isActive && (
202
- <button
203
- onClick={(e) => { e.stopPropagation(); chat.leaveRoom({ roomId: room.id }) }}
204
- className="opacity-0 group-hover:opacity-100 text-red-400 hover:text-red-300 text-xs"
205
- >&#10005;</button>
206
- )}
207
- </div>
208
- )
209
- })}
210
- </div>
211
-
212
- <div className="p-3 border-t border-white/10">
213
- <p className="text-xs text-gray-500">Em {joinedRoomIds.length} sala(s)</p>
214
- </div>
215
- </div>
216
-
217
- {/* Chat Area */}
218
- <div className={`${!activeRoom ? 'hidden md:flex' : 'flex'} flex-1 flex-col`}>
219
- {activeRoom ? (
220
- <>
221
- <div className="px-4 py-3 border-b border-white/10 flex items-center justify-between">
222
- <div className="flex items-center gap-3">
223
- <button
224
- onClick={() => chat.switchRoom({ roomId: '' })}
225
- className="md:hidden px-2 py-1 text-sm text-gray-400 hover:text-white"
226
- >
227
- &#8592;
228
- </button>
229
- <div>
230
- <h3 className="text-white font-semibold flex items-center gap-2">
231
- {joinedRoomsMap.get(activeRoom)?.isPrivate && <span className="text-xs">&#128274;</span>}
232
- {joinedRoomsMap.get(activeRoom)?.name || activeRoom}
233
- </h3>
234
- <p className="text-xs text-gray-500">{activeMessages.length} mensagens</p>
235
- </div>
236
- </div>
237
- <button
238
- onClick={() => chat.leaveRoom({ roomId: activeRoom })}
239
- className="px-3 py-1 text-sm bg-red-500/20 text-red-300 rounded-lg hover:bg-red-500/30"
240
- >Sair</button>
241
- </div>
242
-
243
- <div className="flex-1 overflow-auto p-3 sm:p-4 space-y-3">
244
- {activeMessages.length === 0 ? (
245
- <div className="text-center text-gray-500 py-8">
246
- <p>Nenhuma mensagem ainda</p>
247
- <p className="text-sm">Seja o primeiro a enviar!</p>
248
- </div>
249
- ) : (
250
- activeMessages.map(msg => (
251
- <div key={msg.id} className={`flex flex-col ${msg.user === chat.$state.username ? 'items-end' : 'items-start'}`}>
252
- <div className={`max-w-[85%] sm:max-w-[80%] rounded-2xl px-3 sm:px-4 py-2 ${msg.user === chat.$state.username ? 'bg-theme-muted text-white' : 'bg-white/10 text-gray-200'}`}>
253
- <p className="text-xs text-gray-400 mb-1">{msg.user}</p>
254
- <p className="text-sm sm:text-base">{msg.text}</p>
255
- </div>
256
- <span className="text-xs text-gray-600 mt-1">{new Date(msg.timestamp).toLocaleTimeString()}</span>
257
- </div>
258
- ))
259
- )}
260
- <div ref={messagesEndRef} />
261
- </div>
262
-
263
- <div className="p-3 sm:p-4 border-t border-white/10">
264
- <div className="flex gap-2">
265
- <input
266
- value={ui.text}
267
- onChange={(e) => dispatch({ type: 'SET_TEXT', text: e.target.value })}
268
- onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSendMessage() } }}
269
- placeholder="Digite uma mensagem..."
270
- className="flex-1 px-3 sm:px-4 py-2 rounded-xl bg-white/10 border border-white/20 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[var(--theme-primary-glow)] text-sm sm:text-base"
271
- />
272
- <button
273
- onClick={handleSendMessage}
274
- disabled={!ui.text.trim()}
275
- className="px-4 sm:px-6 py-2 rounded-xl bg-theme-muted text-theme hover:bg-theme-muted disabled:opacity-50 text-sm sm:text-base"
276
- >Enviar</button>
277
- </div>
278
- </div>
279
- </>
280
- ) : (
281
- <div className="flex-1 flex items-center justify-center text-gray-500">
282
- <div className="text-center">
283
- <p className="text-4xl mb-4">&#8592;</p>
284
- <p>Selecione uma sala para começar</p>
285
- </div>
286
- </div>
287
- )}
288
- </div>
289
-
290
- {/* Error toast */}
291
- {ui.error && (
292
- <div className="absolute top-4 left-1/2 -translate-x-1/2 px-4 py-2 bg-red-500/90 text-white text-sm rounded-lg shadow-lg z-50">
293
- {ui.error}
294
- </div>
295
- )}
296
-
297
- {/* Create Room Modal */}
298
- {ui.createModal.open && (
299
- <div className="absolute inset-0 bg-black/60 flex items-center justify-center z-40" onClick={() => dispatch({ type: 'CLOSE_CREATE_MODAL' })}>
300
- <div className="bg-gray-800 rounded-2xl p-6 w-80 border border-white/10" onClick={e => e.stopPropagation()}>
301
- <h3 className="text-white font-bold text-lg mb-4">Criar Sala</h3>
302
- <div className="space-y-3">
303
- <div>
304
- <label className="text-xs text-gray-400 block mb-1">Nome da sala</label>
305
- <input
306
- value={ui.createModal.name}
307
- onChange={e => dispatch({ type: 'UPDATE_CREATE_FORM', name: e.target.value })}
308
- placeholder="Minha Sala"
309
- className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[var(--theme-primary-glow)] text-sm"
310
- autoFocus
311
- onKeyDown={e => { if (e.key === 'Enter') handleCreateRoom() }}
312
- />
313
- </div>
314
- <div>
315
- <label className="text-xs text-gray-400 block mb-1">Senha (opcional)</label>
316
- <input
317
- type="password"
318
- value={ui.createModal.password}
319
- onChange={e => dispatch({ type: 'UPDATE_CREATE_FORM', password: e.target.value })}
320
- placeholder="Deixe vazio para sala publica"
321
- className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[var(--theme-primary-glow)] text-sm"
322
- onKeyDown={e => { if (e.key === 'Enter') handleCreateRoom() }}
323
- />
324
- </div>
325
- <div className="flex gap-2 pt-2">
326
- <button
327
- onClick={() => dispatch({ type: 'CLOSE_CREATE_MODAL' })}
328
- className="flex-1 px-4 py-2 rounded-lg bg-white/10 text-gray-300 hover:bg-white/20 text-sm"
329
- >Cancelar</button>
330
- <button
331
- onClick={handleCreateRoom}
332
- disabled={!ui.createModal.name.trim()}
333
- className="flex-1 px-4 py-2 rounded-lg bg-theme-muted text-theme hover:bg-theme-muted disabled:opacity-50 text-sm"
334
- >Criar</button>
335
- </div>
336
- </div>
337
- </div>
338
- </div>
339
- )}
340
-
341
- {/* Password Prompt Modal */}
342
- {ui.passwordPrompt && (
343
- <div className="absolute inset-0 bg-black/60 flex items-center justify-center z-40" onClick={() => dispatch({ type: 'CLOSE_PASSWORD_PROMPT' })}>
344
- <div className="bg-gray-800 rounded-2xl p-6 w-80 border border-white/10" onClick={e => e.stopPropagation()}>
345
- <h3 className="text-white font-bold text-lg mb-1">Sala Protegida</h3>
346
- <p className="text-sm text-gray-400 mb-4">
347
- A sala "{ui.passwordPrompt.roomName}" requer senha.
348
- </p>
349
- <input
350
- type="password"
351
- value={ui.passwordPrompt.input}
352
- onChange={e => dispatch({ type: 'SET_PASSWORD_INPUT', input: e.target.value })}
353
- placeholder="Digite a senha..."
354
- className="w-full px-3 py-2 rounded-lg bg-white/10 border border-white/20 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-[var(--theme-primary-glow)] text-sm mb-3"
355
- autoFocus
356
- onKeyDown={e => { if (e.key === 'Enter') handlePasswordSubmit() }}
357
- />
358
- <div className="flex gap-2">
359
- <button
360
- onClick={() => dispatch({ type: 'CLOSE_PASSWORD_PROMPT' })}
361
- className="flex-1 px-4 py-2 rounded-lg bg-white/10 text-gray-300 hover:bg-white/20 text-sm"
362
- >Cancelar</button>
363
- <button
364
- onClick={handlePasswordSubmit}
365
- disabled={!ui.passwordPrompt.input}
366
- className="flex-1 px-4 py-2 rounded-lg bg-theme-muted text-theme hover:bg-theme-muted disabled:opacity-50 text-sm"
367
- >Entrar</button>
368
- </div>
369
- </div>
370
- </div>
371
- )}
372
- </div>
373
- )
374
- }
1
+ import { useEffect, useMemo, useReducer, useRef } from 'react'
2
+ import { Live } from '@/core/client'
3
+ import { LiveRoomChat } from '@server/live/LiveRoomChat'
4
+ import { FaArrowLeft, FaLock, FaPlus, FaRightFromBracket } from 'react-icons/fa6'
5
+
6
+ const DEFAULT_ROOMS = [
7
+ { id: 'general', name: 'General' },
8
+ { id: 'engineering', name: 'Engineering' },
9
+ { id: 'support', name: 'Support' },
10
+ ]
11
+
12
+ interface ChatUIState {
13
+ text: string
14
+ error: string
15
+ createModal: { open: boolean; name: string; password: string }
16
+ passwordPrompt: { roomId: string; roomName: string; input: string } | null
17
+ }
18
+
19
+ type ChatUIAction =
20
+ | { type: 'SET_TEXT'; text: string }
21
+ | { type: 'SET_ERROR'; error: string }
22
+ | { type: 'OPEN_CREATE_MODAL' }
23
+ | { type: 'CLOSE_CREATE_MODAL' }
24
+ | { type: 'UPDATE_CREATE_FORM'; name?: string; password?: string }
25
+ | { type: 'OPEN_PASSWORD_PROMPT'; roomId: string; roomName: string }
26
+ | { type: 'CLOSE_PASSWORD_PROMPT' }
27
+ | { type: 'SET_PASSWORD_INPUT'; input: string }
28
+
29
+ function chatUIReducer(state: ChatUIState, action: ChatUIAction): ChatUIState {
30
+ switch (action.type) {
31
+ case 'SET_TEXT':
32
+ return { ...state, text: action.text }
33
+ case 'SET_ERROR':
34
+ return { ...state, error: action.error }
35
+ case 'OPEN_CREATE_MODAL':
36
+ return { ...state, createModal: { open: true, name: '', password: '' } }
37
+ case 'CLOSE_CREATE_MODAL':
38
+ return { ...state, createModal: { open: false, name: '', password: '' } }
39
+ case 'UPDATE_CREATE_FORM':
40
+ return {
41
+ ...state,
42
+ createModal: {
43
+ ...state.createModal,
44
+ name: action.name ?? state.createModal.name,
45
+ password: action.password ?? state.createModal.password,
46
+ },
47
+ }
48
+ case 'OPEN_PASSWORD_PROMPT':
49
+ return { ...state, passwordPrompt: { roomId: action.roomId, roomName: action.roomName, input: '' } }
50
+ case 'CLOSE_PASSWORD_PROMPT':
51
+ return { ...state, passwordPrompt: null }
52
+ case 'SET_PASSWORD_INPUT':
53
+ return state.passwordPrompt
54
+ ? { ...state, passwordPrompt: { ...state.passwordPrompt, input: action.input } }
55
+ : state
56
+ default:
57
+ return state
58
+ }
59
+ }
60
+
61
+ const initialUIState: ChatUIState = {
62
+ text: '',
63
+ error: '',
64
+ createModal: { open: false, name: '', password: '' },
65
+ passwordPrompt: null,
66
+ }
67
+
68
+ export function RoomChatDemo() {
69
+ const [ui, dispatch] = useReducer(chatUIReducer, initialUIState)
70
+ const messagesEndRef = useRef<HTMLDivElement>(null)
71
+
72
+ const defaultUsername = useMemo(() => {
73
+ const prefix = ['Edge', 'Core', 'Live', 'Flux', 'Node'][Math.floor(Math.random() * 5)]
74
+ return `${prefix}-${Math.floor(Math.random() * 100)}`
75
+ }, [])
76
+
77
+ const chat = Live.use(LiveRoomChat, {
78
+ initialState: { ...LiveRoomChat.defaultState, username: defaultUsername },
79
+ })
80
+
81
+ const activeRoom = chat.$state.activeRoom
82
+ const activeMessages = activeRoom ? (chat.$state.messages[activeRoom] || []) : []
83
+ const joinedRoomIds = chat.$state.rooms.map(r => r.id)
84
+ const joinedRoomsMap = new Map(chat.$state.rooms.map(r => [r.id, r]))
85
+ const customRooms = chat.$state.customRooms || []
86
+ const allRooms = [
87
+ ...DEFAULT_ROOMS.map(r => ({ ...r, isPrivate: joinedRoomsMap.get(r.id)?.isPrivate ?? false, createdBy: '' })),
88
+ ...customRooms
89
+ .filter(r => !DEFAULT_ROOMS.some(d => d.id === r.id))
90
+ .map(r => ({ id: r.id, name: r.name, isPrivate: r.isPrivate, createdBy: r.createdBy })),
91
+ ]
92
+
93
+ useEffect(() => {
94
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
95
+ }, [activeMessages.length])
96
+
97
+ useEffect(() => {
98
+ if (!ui.error) return
99
+ const timeout = setTimeout(() => dispatch({ type: 'SET_ERROR', error: '' }), 3000)
100
+ return () => clearTimeout(timeout)
101
+ }, [ui.error])
102
+
103
+ const handleJoinRoom = async (roomId: string, roomName: string, isPrivate?: boolean) => {
104
+ if (joinedRoomIds.includes(roomId)) {
105
+ await chat.switchRoom({ roomId })
106
+ return
107
+ }
108
+
109
+ if (isPrivate) {
110
+ dispatch({ type: 'OPEN_PASSWORD_PROMPT', roomId, roomName })
111
+ return
112
+ }
113
+
114
+ const result = await chat.joinRoom({ roomId, roomName })
115
+ if (result && !result.success) {
116
+ dispatch({ type: 'OPEN_PASSWORD_PROMPT', roomId, roomName })
117
+ }
118
+ }
119
+
120
+ const handlePasswordSubmit = async () => {
121
+ if (!ui.passwordPrompt) return
122
+ const result = await chat.joinRoom({
123
+ roomId: ui.passwordPrompt.roomId,
124
+ roomName: ui.passwordPrompt.roomName,
125
+ password: ui.passwordPrompt.input,
126
+ })
127
+ if (result && !result.success) {
128
+ dispatch({ type: 'SET_ERROR', error: result.error || 'Invalid password' })
129
+ } else {
130
+ dispatch({ type: 'CLOSE_PASSWORD_PROMPT' })
131
+ }
132
+ }
133
+
134
+ const handleCreateRoom = async () => {
135
+ const name = ui.createModal.name.trim()
136
+ if (!name) return
137
+ const roomId = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
138
+ if (!roomId) return
139
+
140
+ const result = await chat.createRoom({
141
+ roomId,
142
+ roomName: name,
143
+ password: ui.createModal.password || undefined,
144
+ })
145
+ if (result && !result.success) {
146
+ dispatch({ type: 'SET_ERROR', error: result.error || 'Could not create room' })
147
+ } else {
148
+ dispatch({ type: 'CLOSE_CREATE_MODAL' })
149
+ }
150
+ }
151
+
152
+ const handleSendMessage = async () => {
153
+ if (!ui.text.trim() || !activeRoom) return
154
+ await chat.sendMessage({ text: ui.text })
155
+ dispatch({ type: 'SET_TEXT', text: '' })
156
+ }
157
+
158
+ return (
159
+ <div className="relative flex h-[720px] w-full max-w-5xl overflow-hidden rounded-lg border border-white/10 bg-[#07070b]/90 shadow-2xl shadow-black/20">
160
+ <aside className={`${activeRoom ? 'hidden md:flex' : 'flex'} w-full flex-col border-white/10 bg-black/25 md:w-72 md:border-r`}>
161
+ <div className="border-b border-white/10 p-4">
162
+ <div className="flex items-center justify-between gap-3">
163
+ <div>
164
+ <h2 className="text-lg font-semibold text-white">Rooms</h2>
165
+ <p className="mt-1 text-xs text-gray-500">{joinedRoomIds.length} joined rooms</p>
166
+ </div>
167
+ <span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${
168
+ chat.$connected
169
+ ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200'
170
+ : 'border-red-400/25 bg-red-400/10 text-red-200'
171
+ }`}>
172
+ <span className={`h-1.5 w-1.5 rounded-full ${chat.$connected ? 'bg-emerald-300' : 'bg-red-300'}`} />
173
+ Live
174
+ </span>
175
+ </div>
176
+
177
+ <div className="mt-4 rounded-lg border border-white/10 bg-white/[0.025] px-3 py-2">
178
+ <p className="text-xs text-gray-500">Current client</p>
179
+ <p className="mt-1 font-mono text-sm text-gray-200">{chat.$state.username}</p>
180
+ </div>
181
+ </div>
182
+
183
+ <div className="flex-1 overflow-auto p-3">
184
+ <button
185
+ onClick={() => dispatch({ type: 'OPEN_CREATE_MODAL' })}
186
+ className="mb-3 inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-theme-active bg-theme-muted text-sm font-semibold text-theme transition hover:shadow-theme"
187
+ >
188
+ <FaPlus className="h-3.5 w-3.5" />
189
+ Create room
190
+ </button>
191
+
192
+ <div className="space-y-1">
193
+ {allRooms.map(room => {
194
+ const isJoined = joinedRoomIds.includes(room.id)
195
+ const isActive = activeRoom === room.id
196
+
197
+ return (
198
+ <button
199
+ key={room.id}
200
+ onClick={() => handleJoinRoom(room.id, room.name, room.isPrivate && !isJoined ? true : undefined)}
201
+ className={`group flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left transition ${
202
+ isActive
203
+ ? 'bg-white text-black'
204
+ : isJoined
205
+ ? 'bg-white/[0.055] text-gray-200 hover:bg-white/[0.08]'
206
+ : 'text-gray-500 hover:bg-white/[0.04] hover:text-gray-300'
207
+ }`}
208
+ >
209
+ <span className="min-w-0">
210
+ <span className="flex items-center gap-2">
211
+ {room.isPrivate && <FaLock className="h-3 w-3 shrink-0" />}
212
+ <span className="truncate text-sm font-medium">{room.name}</span>
213
+ </span>
214
+ {room.createdBy && <span className="mt-0.5 block truncate text-xs opacity-60">by {room.createdBy}</span>}
215
+ </span>
216
+ {isJoined && !isActive && (
217
+ <span
218
+ onClick={(e) => { e.stopPropagation(); chat.leaveRoom({ roomId: room.id }) }}
219
+ className="opacity-0 transition group-hover:opacity-100"
220
+ >
221
+ <FaRightFromBracket className="h-3 w-3 text-red-300" />
222
+ </span>
223
+ )}
224
+ </button>
225
+ )
226
+ })}
227
+ </div>
228
+ </div>
229
+ </aside>
230
+
231
+ <section className={`${!activeRoom ? 'hidden md:flex' : 'flex'} min-w-0 flex-1 flex-col`}>
232
+ {activeRoom ? (
233
+ <>
234
+ <header className="flex items-center justify-between gap-4 border-b border-white/10 px-4 py-3">
235
+ <div className="flex min-w-0 items-center gap-3">
236
+ <button
237
+ onClick={() => chat.switchRoom({ roomId: '' })}
238
+ className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.03] text-gray-300 md:hidden"
239
+ aria-label="Back to rooms"
240
+ >
241
+ <FaArrowLeft className="h-3.5 w-3.5" />
242
+ </button>
243
+ <div className="min-w-0">
244
+ <h3 className="flex items-center gap-2 truncate text-sm font-semibold text-white">
245
+ {joinedRoomsMap.get(activeRoom)?.isPrivate && <FaLock className="h-3 w-3 text-theme" />}
246
+ {joinedRoomsMap.get(activeRoom)?.name || activeRoom}
247
+ </h3>
248
+ <p className="mt-0.5 text-xs text-gray-500">{activeMessages.length} messages</p>
249
+ </div>
250
+ </div>
251
+ <button
252
+ onClick={() => chat.leaveRoom({ roomId: activeRoom })}
253
+ className="rounded-lg border border-red-400/20 bg-red-400/10 px-3 py-2 text-sm font-medium text-red-200 transition hover:bg-red-400/15"
254
+ >
255
+ Leave
256
+ </button>
257
+ </header>
258
+
259
+ <div className="flex-1 overflow-auto p-4">
260
+ {activeMessages.length === 0 ? (
261
+ <div className="flex h-full items-center justify-center text-center">
262
+ <div>
263
+ <p className="text-lg font-medium text-white">No messages yet</p>
264
+ <p className="mt-2 text-sm text-gray-500">Start the room conversation from this client.</p>
265
+ </div>
266
+ </div>
267
+ ) : (
268
+ <div className="space-y-3">
269
+ {activeMessages.map(msg => {
270
+ const mine = msg.user === chat.$state.username
271
+ return (
272
+ <div key={msg.id} className={`flex flex-col ${mine ? 'items-end' : 'items-start'}`}>
273
+ <div className={`max-w-[85%] rounded-lg border px-4 py-2 ${
274
+ mine
275
+ ? 'border-theme-active bg-theme-muted text-white'
276
+ : 'border-white/10 bg-white/[0.055] text-gray-200'
277
+ }`}>
278
+ <p className="mb-1 text-xs text-gray-400">{msg.user}</p>
279
+ <p className="text-sm leading-6">{msg.text}</p>
280
+ </div>
281
+ <span className="mt-1 text-xs text-gray-600">{new Date(msg.timestamp).toLocaleTimeString()}</span>
282
+ </div>
283
+ )
284
+ })}
285
+ <div ref={messagesEndRef} />
286
+ </div>
287
+ )}
288
+ </div>
289
+
290
+ <footer className="border-t border-white/10 p-3">
291
+ <div className="flex gap-2">
292
+ <input
293
+ value={ui.text}
294
+ onChange={(e) => dispatch({ type: 'SET_TEXT', text: e.target.value })}
295
+ onKeyDown={(e) => {
296
+ if (e.key === 'Enter' && !e.shiftKey) {
297
+ e.preventDefault()
298
+ handleSendMessage()
299
+ }
300
+ }}
301
+ placeholder="Write a message..."
302
+ className="min-w-0 flex-1 input-theme"
303
+ />
304
+ <button
305
+ onClick={handleSendMessage}
306
+ disabled={!ui.text.trim()}
307
+ className="h-11 rounded-lg bg-white px-5 text-sm font-semibold text-black transition hover:bg-gray-200 disabled:opacity-50"
308
+ >
309
+ Send
310
+ </button>
311
+ </div>
312
+ </footer>
313
+ </>
314
+ ) : (
315
+ <div className="flex flex-1 items-center justify-center text-center">
316
+ <div>
317
+ <p className="text-lg font-medium text-white">Select a room</p>
318
+ <p className="mt-2 text-sm text-gray-500">Join a default room or create a password-protected one.</p>
319
+ </div>
320
+ </div>
321
+ )}
322
+ </section>
323
+
324
+ {ui.error && (
325
+ <div className="absolute left-1/2 top-4 z-50 -translate-x-1/2 rounded-lg border border-red-400/20 bg-red-500/90 px-4 py-2 text-sm text-white shadow-lg">
326
+ {ui.error}
327
+ </div>
328
+ )}
329
+
330
+ {ui.createModal.open && (
331
+ <div className="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-4" onClick={() => dispatch({ type: 'CLOSE_CREATE_MODAL' })}>
332
+ <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#0b0b10] p-5 shadow-2xl" onClick={e => e.stopPropagation()}>
333
+ <h3 className="text-lg font-semibold text-white">Create room</h3>
334
+ <div className="mt-4 space-y-3">
335
+ <label className="block">
336
+ <span className="mb-1 block text-xs text-gray-400">Room name</span>
337
+ <input
338
+ value={ui.createModal.name}
339
+ onChange={e => dispatch({ type: 'UPDATE_CREATE_FORM', name: e.target.value })}
340
+ placeholder="Product team"
341
+ className="w-full input-theme"
342
+ autoFocus
343
+ onKeyDown={e => { if (e.key === 'Enter') handleCreateRoom() }}
344
+ />
345
+ </label>
346
+ <label className="block">
347
+ <span className="mb-1 block text-xs text-gray-400">Password optional</span>
348
+ <input
349
+ type="password"
350
+ value={ui.createModal.password}
351
+ onChange={e => dispatch({ type: 'UPDATE_CREATE_FORM', password: e.target.value })}
352
+ placeholder="Leave empty for a public room"
353
+ className="w-full input-theme"
354
+ onKeyDown={e => { if (e.key === 'Enter') handleCreateRoom() }}
355
+ />
356
+ </label>
357
+ <div className="grid grid-cols-2 gap-2 pt-2">
358
+ <button onClick={() => dispatch({ type: 'CLOSE_CREATE_MODAL' })} className="h-10 rounded-lg border border-white/10 bg-white/[0.03] text-sm text-gray-300">
359
+ Cancel
360
+ </button>
361
+ <button onClick={handleCreateRoom} disabled={!ui.createModal.name.trim()} className="h-10 rounded-lg bg-white text-sm font-semibold text-black disabled:opacity-50">
362
+ Create
363
+ </button>
364
+ </div>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ )}
369
+
370
+ {ui.passwordPrompt && (
371
+ <div className="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-4" onClick={() => dispatch({ type: 'CLOSE_PASSWORD_PROMPT' })}>
372
+ <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#0b0b10] p-5 shadow-2xl" onClick={e => e.stopPropagation()}>
373
+ <h3 className="text-lg font-semibold text-white">Protected room</h3>
374
+ <p className="mt-1 text-sm text-gray-400">{ui.passwordPrompt.roomName} requires a password.</p>
375
+ <input
376
+ type="password"
377
+ value={ui.passwordPrompt.input}
378
+ onChange={e => dispatch({ type: 'SET_PASSWORD_INPUT', input: e.target.value })}
379
+ placeholder="Password"
380
+ className="mt-4 w-full input-theme"
381
+ autoFocus
382
+ onKeyDown={e => { if (e.key === 'Enter') handlePasswordSubmit() }}
383
+ />
384
+ <div className="mt-4 grid grid-cols-2 gap-2">
385
+ <button onClick={() => dispatch({ type: 'CLOSE_PASSWORD_PROMPT' })} className="h-10 rounded-lg border border-white/10 bg-white/[0.03] text-sm text-gray-300">
386
+ Cancel
387
+ </button>
388
+ <button onClick={handlePasswordSubmit} disabled={!ui.passwordPrompt.input} className="h-10 rounded-lg bg-white text-sm font-semibold text-black disabled:opacity-50">
389
+ Join
390
+ </button>
391
+ </div>
392
+ </div>
393
+ </div>
394
+ )}
395
+ </div>
396
+ )
397
+ }