create-fluxstack 1.22.0 → 1.22.1

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 (38) hide show
  1. package/app/client/src/components/AppLayout.tsx +290 -290
  2. package/app/client/src/components/BackButton.tsx +16 -16
  3. package/app/client/src/components/DemoPage.tsx +135 -135
  4. package/app/client/src/framework/ClientOnly.tsx +14 -14
  5. package/app/client/src/framework/LivePage.tsx +55 -55
  6. package/app/client/src/framework/RscHomePage.tsx +125 -125
  7. package/app/client/src/framework/RscLink.tsx +31 -31
  8. package/app/client/src/framework/RscNav.tsx +42 -42
  9. package/app/client/src/framework/RscRoot.tsx +54 -54
  10. package/app/client/src/framework/csrf.ts +22 -22
  11. package/app/client/src/framework/entry.browser.tsx +51 -51
  12. package/app/client/src/framework/entry.rsc.tsx +41 -41
  13. package/app/client/src/framework/entry.ssr.tsx +12 -12
  14. package/app/client/src/framework/navigation.ts +29 -29
  15. package/app/client/src/framework/params.tsx +22 -22
  16. package/app/client/src/framework/routes.ts +143 -143
  17. package/app/client/src/framework/vite-rsc.d.ts +21 -21
  18. package/app/client/src/live/AuthDemo.tsx +270 -270
  19. package/app/client/src/live/CounterDemo.tsx +152 -152
  20. package/app/client/src/live/FormDemo.tsx +140 -140
  21. package/app/client/src/live/PingPongDemo.tsx +180 -180
  22. package/app/client/src/live/RoomChatDemo.tsx +397 -397
  23. package/app/client/src/pages/auth.tsx +13 -13
  24. package/app/client/src/pages/blog/[slug].tsx +23 -23
  25. package/app/client/src/pages/counter.tsx +15 -15
  26. package/app/client/src/pages/form.tsx +13 -13
  27. package/app/client/src/pages/index.tsx +9 -9
  28. package/app/client/src/pages/ping-pong.tsx +13 -13
  29. package/app/client/src/pages/room-chat.tsx +13 -13
  30. package/app/client/src/pages/shared-counter.tsx +13 -13
  31. package/app/client/src/pages/sobre.tsx +19 -19
  32. package/app/server/live/rooms/index.ts +14 -14
  33. package/core/plugins/built-in/rsc/index.ts +156 -156
  34. package/core/plugins/built-in/ssr/bun-asset-loader.ts +46 -46
  35. package/core/plugins/built-in/ssr/index.ts +143 -143
  36. package/core/plugins/built-in/ssr/registry.ts +38 -38
  37. package/core/utils/version.ts +1 -1
  38. package/package.json +1 -1
@@ -1,398 +1,398 @@
1
1
  'use client'
2
- import { useEffect, useMemo, useReducer, useRef } from 'react'
3
- import { Live } from '@/core/client'
4
- import { LiveRoomChat } from '@server/live/LiveRoomChat'
5
- import { FaArrowLeft, FaLock, FaPlus, FaRightFromBracket } from 'react-icons/fa6'
6
-
7
- const DEFAULT_ROOMS = [
8
- { id: 'general', name: 'General' },
9
- { id: 'engineering', name: 'Engineering' },
10
- { id: 'support', name: 'Support' },
11
- ]
12
-
13
- interface ChatUIState {
14
- text: string
15
- error: string
16
- createModal: { open: boolean; name: string; password: string }
17
- passwordPrompt: { roomId: string; roomName: string; input: string } | null
18
- }
19
-
20
- type ChatUIAction =
21
- | { type: 'SET_TEXT'; text: string }
22
- | { type: 'SET_ERROR'; error: string }
23
- | { type: 'OPEN_CREATE_MODAL' }
24
- | { type: 'CLOSE_CREATE_MODAL' }
25
- | { type: 'UPDATE_CREATE_FORM'; name?: string; password?: string }
26
- | { type: 'OPEN_PASSWORD_PROMPT'; roomId: string; roomName: string }
27
- | { type: 'CLOSE_PASSWORD_PROMPT' }
28
- | { type: 'SET_PASSWORD_INPUT'; input: string }
29
-
30
- function chatUIReducer(state: ChatUIState, action: ChatUIAction): ChatUIState {
31
- switch (action.type) {
32
- case 'SET_TEXT':
33
- return { ...state, text: action.text }
34
- case 'SET_ERROR':
35
- return { ...state, error: action.error }
36
- case 'OPEN_CREATE_MODAL':
37
- return { ...state, createModal: { open: true, name: '', password: '' } }
38
- case 'CLOSE_CREATE_MODAL':
39
- return { ...state, createModal: { open: false, name: '', password: '' } }
40
- case 'UPDATE_CREATE_FORM':
41
- return {
42
- ...state,
43
- createModal: {
44
- ...state.createModal,
45
- name: action.name ?? state.createModal.name,
46
- password: action.password ?? state.createModal.password,
47
- },
48
- }
49
- case 'OPEN_PASSWORD_PROMPT':
50
- return { ...state, passwordPrompt: { roomId: action.roomId, roomName: action.roomName, input: '' } }
51
- case 'CLOSE_PASSWORD_PROMPT':
52
- return { ...state, passwordPrompt: null }
53
- case 'SET_PASSWORD_INPUT':
54
- return state.passwordPrompt
55
- ? { ...state, passwordPrompt: { ...state.passwordPrompt, input: action.input } }
56
- : state
57
- default:
58
- return state
59
- }
60
- }
61
-
62
- const initialUIState: ChatUIState = {
63
- text: '',
64
- error: '',
65
- createModal: { open: false, name: '', password: '' },
66
- passwordPrompt: null,
67
- }
68
-
69
- export function RoomChatDemo() {
70
- const [ui, dispatch] = useReducer(chatUIReducer, initialUIState)
71
- const messagesEndRef = useRef<HTMLDivElement>(null)
72
-
73
- const defaultUsername = useMemo(() => {
74
- const prefix = ['Edge', 'Core', 'Live', 'Flux', 'Node'][Math.floor(Math.random() * 5)]
75
- return `${prefix}-${Math.floor(Math.random() * 100)}`
76
- }, [])
77
-
78
- const chat = Live.use(LiveRoomChat, {
79
- initialState: { ...LiveRoomChat.defaultState, username: defaultUsername },
80
- })
81
-
82
- const activeRoom = chat.$state.activeRoom
83
- const activeMessages = activeRoom ? (chat.$state.messages[activeRoom] || []) : []
84
- const joinedRoomIds = chat.$state.rooms.map(r => r.id)
85
- const joinedRoomsMap = new Map(chat.$state.rooms.map(r => [r.id, r]))
86
- const customRooms = chat.$state.customRooms || []
87
- const allRooms = [
88
- ...DEFAULT_ROOMS.map(r => ({ ...r, isPrivate: joinedRoomsMap.get(r.id)?.isPrivate ?? false, createdBy: '' })),
89
- ...customRooms
90
- .filter(r => !DEFAULT_ROOMS.some(d => d.id === r.id))
91
- .map(r => ({ id: r.id, name: r.name, isPrivate: r.isPrivate, createdBy: r.createdBy })),
92
- ]
93
-
94
- useEffect(() => {
95
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
96
- }, [activeMessages.length])
97
-
98
- useEffect(() => {
99
- if (!ui.error) return
100
- const timeout = setTimeout(() => dispatch({ type: 'SET_ERROR', error: '' }), 3000)
101
- return () => clearTimeout(timeout)
102
- }, [ui.error])
103
-
104
- const handleJoinRoom = async (roomId: string, roomName: string, isPrivate?: boolean) => {
105
- if (joinedRoomIds.includes(roomId)) {
106
- await chat.switchRoom({ roomId })
107
- return
108
- }
109
-
110
- if (isPrivate) {
111
- dispatch({ type: 'OPEN_PASSWORD_PROMPT', roomId, roomName })
112
- return
113
- }
114
-
115
- const result = await chat.joinRoom({ roomId, roomName })
116
- if (result && !result.success) {
117
- dispatch({ type: 'OPEN_PASSWORD_PROMPT', roomId, roomName })
118
- }
119
- }
120
-
121
- const handlePasswordSubmit = async () => {
122
- if (!ui.passwordPrompt) return
123
- const result = await chat.joinRoom({
124
- roomId: ui.passwordPrompt.roomId,
125
- roomName: ui.passwordPrompt.roomName,
126
- password: ui.passwordPrompt.input,
127
- })
128
- if (result && !result.success) {
129
- dispatch({ type: 'SET_ERROR', error: result.error || 'Invalid password' })
130
- } else {
131
- dispatch({ type: 'CLOSE_PASSWORD_PROMPT' })
132
- }
133
- }
134
-
135
- const handleCreateRoom = async () => {
136
- const name = ui.createModal.name.trim()
137
- if (!name) return
138
- const roomId = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
139
- if (!roomId) return
140
-
141
- const result = await chat.createRoom({
142
- roomId,
143
- roomName: name,
144
- password: ui.createModal.password || undefined,
145
- })
146
- if (result && !result.success) {
147
- dispatch({ type: 'SET_ERROR', error: result.error || 'Could not create room' })
148
- } else {
149
- dispatch({ type: 'CLOSE_CREATE_MODAL' })
150
- }
151
- }
152
-
153
- const handleSendMessage = async () => {
154
- if (!ui.text.trim() || !activeRoom) return
155
- await chat.sendMessage({ text: ui.text })
156
- dispatch({ type: 'SET_TEXT', text: '' })
157
- }
158
-
159
- return (
160
- <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">
161
- <aside className={`${activeRoom ? 'hidden md:flex' : 'flex'} w-full flex-col border-white/10 bg-black/25 md:w-72 md:border-r`}>
162
- <div className="border-b border-white/10 p-4">
163
- <div className="flex items-center justify-between gap-3">
164
- <div>
165
- <h2 className="text-lg font-semibold text-white">Rooms</h2>
166
- <p className="mt-1 text-xs text-gray-500">{joinedRoomIds.length} joined rooms</p>
167
- </div>
168
- <span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${
169
- chat.$connected
170
- ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200'
171
- : 'border-red-400/25 bg-red-400/10 text-red-200'
172
- }`}>
173
- <span className={`h-1.5 w-1.5 rounded-full ${chat.$connected ? 'bg-emerald-300' : 'bg-red-300'}`} />
174
- Live
175
- </span>
176
- </div>
177
-
178
- <div className="mt-4 rounded-lg border border-white/10 bg-white/[0.025] px-3 py-2">
179
- <p className="text-xs text-gray-500">Current client</p>
180
- <p className="mt-1 font-mono text-sm text-gray-200">{chat.$state.username}</p>
181
- </div>
182
- </div>
183
-
184
- <div className="flex-1 overflow-auto p-3">
185
- <button
186
- onClick={() => dispatch({ type: 'OPEN_CREATE_MODAL' })}
187
- 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"
188
- >
189
- <FaPlus className="h-3.5 w-3.5" />
190
- Create room
191
- </button>
192
-
193
- <div className="space-y-1">
194
- {allRooms.map(room => {
195
- const isJoined = joinedRoomIds.includes(room.id)
196
- const isActive = activeRoom === room.id
197
-
198
- return (
199
- <button
200
- key={room.id}
201
- onClick={() => handleJoinRoom(room.id, room.name, room.isPrivate && !isJoined ? true : undefined)}
202
- className={`group flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left transition ${
203
- isActive
204
- ? 'bg-white text-black'
205
- : isJoined
206
- ? 'bg-white/[0.055] text-gray-200 hover:bg-white/[0.08]'
207
- : 'text-gray-500 hover:bg-white/[0.04] hover:text-gray-300'
208
- }`}
209
- >
210
- <span className="min-w-0">
211
- <span className="flex items-center gap-2">
212
- {room.isPrivate && <FaLock className="h-3 w-3 shrink-0" />}
213
- <span className="truncate text-sm font-medium">{room.name}</span>
214
- </span>
215
- {room.createdBy && <span className="mt-0.5 block truncate text-xs opacity-60">by {room.createdBy}</span>}
216
- </span>
217
- {isJoined && !isActive && (
218
- <span
219
- onClick={(e) => { e.stopPropagation(); chat.leaveRoom({ roomId: room.id }) }}
220
- className="opacity-0 transition group-hover:opacity-100"
221
- >
222
- <FaRightFromBracket className="h-3 w-3 text-red-300" />
223
- </span>
224
- )}
225
- </button>
226
- )
227
- })}
228
- </div>
229
- </div>
230
- </aside>
231
-
232
- <section className={`${!activeRoom ? 'hidden md:flex' : 'flex'} min-w-0 flex-1 flex-col`}>
233
- {activeRoom ? (
234
- <>
235
- <header className="flex items-center justify-between gap-4 border-b border-white/10 px-4 py-3">
236
- <div className="flex min-w-0 items-center gap-3">
237
- <button
238
- onClick={() => chat.switchRoom({ roomId: '' })}
239
- 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"
240
- aria-label="Back to rooms"
241
- >
242
- <FaArrowLeft className="h-3.5 w-3.5" />
243
- </button>
244
- <div className="min-w-0">
245
- <h3 className="flex items-center gap-2 truncate text-sm font-semibold text-white">
246
- {joinedRoomsMap.get(activeRoom)?.isPrivate && <FaLock className="h-3 w-3 text-theme" />}
247
- {joinedRoomsMap.get(activeRoom)?.name || activeRoom}
248
- </h3>
249
- <p className="mt-0.5 text-xs text-gray-500">{activeMessages.length} messages</p>
250
- </div>
251
- </div>
252
- <button
253
- onClick={() => chat.leaveRoom({ roomId: activeRoom })}
254
- 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"
255
- >
256
- Leave
257
- </button>
258
- </header>
259
-
260
- <div className="flex-1 overflow-auto p-4">
261
- {activeMessages.length === 0 ? (
262
- <div className="flex h-full items-center justify-center text-center">
263
- <div>
264
- <p className="text-lg font-medium text-white">No messages yet</p>
265
- <p className="mt-2 text-sm text-gray-500">Start the room conversation from this client.</p>
266
- </div>
267
- </div>
268
- ) : (
269
- <div className="space-y-3">
270
- {activeMessages.map(msg => {
271
- const mine = msg.user === chat.$state.username
272
- return (
273
- <div key={msg.id} className={`flex flex-col ${mine ? 'items-end' : 'items-start'}`}>
274
- <div className={`max-w-[85%] rounded-lg border px-4 py-2 ${
275
- mine
276
- ? 'border-theme-active bg-theme-muted text-white'
277
- : 'border-white/10 bg-white/[0.055] text-gray-200'
278
- }`}>
279
- <p className="mb-1 text-xs text-gray-400">{msg.user}</p>
280
- <p className="text-sm leading-6">{msg.text}</p>
281
- </div>
282
- <span className="mt-1 text-xs text-gray-600">{new Date(msg.timestamp).toLocaleTimeString()}</span>
283
- </div>
284
- )
285
- })}
286
- <div ref={messagesEndRef} />
287
- </div>
288
- )}
289
- </div>
290
-
291
- <footer className="border-t border-white/10 p-3">
292
- <div className="flex gap-2">
293
- <input
294
- value={ui.text}
295
- onChange={(e) => dispatch({ type: 'SET_TEXT', text: e.target.value })}
296
- onKeyDown={(e) => {
297
- if (e.key === 'Enter' && !e.shiftKey) {
298
- e.preventDefault()
299
- handleSendMessage()
300
- }
301
- }}
302
- placeholder="Write a message..."
303
- className="min-w-0 flex-1 input-theme"
304
- />
305
- <button
306
- onClick={handleSendMessage}
307
- disabled={!ui.text.trim()}
308
- className="h-11 rounded-lg bg-white px-5 text-sm font-semibold text-black transition hover:bg-gray-200 disabled:opacity-50"
309
- >
310
- Send
311
- </button>
312
- </div>
313
- </footer>
314
- </>
315
- ) : (
316
- <div className="flex flex-1 items-center justify-center text-center">
317
- <div>
318
- <p className="text-lg font-medium text-white">Select a room</p>
319
- <p className="mt-2 text-sm text-gray-500">Join a default room or create a password-protected one.</p>
320
- </div>
321
- </div>
322
- )}
323
- </section>
324
-
325
- {ui.error && (
326
- <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">
327
- {ui.error}
328
- </div>
329
- )}
330
-
331
- {ui.createModal.open && (
332
- <div className="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-4" onClick={() => dispatch({ type: 'CLOSE_CREATE_MODAL' })}>
333
- <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#0b0b10] p-5 shadow-2xl" onClick={e => e.stopPropagation()}>
334
- <h3 className="text-lg font-semibold text-white">Create room</h3>
335
- <div className="mt-4 space-y-3">
336
- <label className="block">
337
- <span className="mb-1 block text-xs text-gray-400">Room name</span>
338
- <input
339
- value={ui.createModal.name}
340
- onChange={e => dispatch({ type: 'UPDATE_CREATE_FORM', name: e.target.value })}
341
- placeholder="Product team"
342
- className="w-full input-theme"
343
- autoFocus
344
- onKeyDown={e => { if (e.key === 'Enter') handleCreateRoom() }}
345
- />
346
- </label>
347
- <label className="block">
348
- <span className="mb-1 block text-xs text-gray-400">Password optional</span>
349
- <input
350
- type="password"
351
- value={ui.createModal.password}
352
- onChange={e => dispatch({ type: 'UPDATE_CREATE_FORM', password: e.target.value })}
353
- placeholder="Leave empty for a public room"
354
- className="w-full input-theme"
355
- onKeyDown={e => { if (e.key === 'Enter') handleCreateRoom() }}
356
- />
357
- </label>
358
- <div className="grid grid-cols-2 gap-2 pt-2">
359
- <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">
360
- Cancel
361
- </button>
362
- <button onClick={handleCreateRoom} disabled={!ui.createModal.name.trim()} className="h-10 rounded-lg bg-white text-sm font-semibold text-black disabled:opacity-50">
363
- Create
364
- </button>
365
- </div>
366
- </div>
367
- </div>
368
- </div>
369
- )}
370
-
371
- {ui.passwordPrompt && (
372
- <div className="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-4" onClick={() => dispatch({ type: 'CLOSE_PASSWORD_PROMPT' })}>
373
- <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#0b0b10] p-5 shadow-2xl" onClick={e => e.stopPropagation()}>
374
- <h3 className="text-lg font-semibold text-white">Protected room</h3>
375
- <p className="mt-1 text-sm text-gray-400">{ui.passwordPrompt.roomName} requires a password.</p>
376
- <input
377
- type="password"
378
- value={ui.passwordPrompt.input}
379
- onChange={e => dispatch({ type: 'SET_PASSWORD_INPUT', input: e.target.value })}
380
- placeholder="Password"
381
- className="mt-4 w-full input-theme"
382
- autoFocus
383
- onKeyDown={e => { if (e.key === 'Enter') handlePasswordSubmit() }}
384
- />
385
- <div className="mt-4 grid grid-cols-2 gap-2">
386
- <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">
387
- Cancel
388
- </button>
389
- <button onClick={handlePasswordSubmit} disabled={!ui.passwordPrompt.input} className="h-10 rounded-lg bg-white text-sm font-semibold text-black disabled:opacity-50">
390
- Join
391
- </button>
392
- </div>
393
- </div>
394
- </div>
395
- )}
396
- </div>
397
- )
398
- }
2
+ import { useEffect, useMemo, useReducer, useRef } from 'react'
3
+ import { Live } from '@/core/client'
4
+ import { LiveRoomChat } from '@server/live/LiveRoomChat'
5
+ import { FaArrowLeft, FaLock, FaPlus, FaRightFromBracket } from 'react-icons/fa6'
6
+
7
+ const DEFAULT_ROOMS = [
8
+ { id: 'general', name: 'General' },
9
+ { id: 'engineering', name: 'Engineering' },
10
+ { id: 'support', name: 'Support' },
11
+ ]
12
+
13
+ interface ChatUIState {
14
+ text: string
15
+ error: string
16
+ createModal: { open: boolean; name: string; password: string }
17
+ passwordPrompt: { roomId: string; roomName: string; input: string } | null
18
+ }
19
+
20
+ type ChatUIAction =
21
+ | { type: 'SET_TEXT'; text: string }
22
+ | { type: 'SET_ERROR'; error: string }
23
+ | { type: 'OPEN_CREATE_MODAL' }
24
+ | { type: 'CLOSE_CREATE_MODAL' }
25
+ | { type: 'UPDATE_CREATE_FORM'; name?: string; password?: string }
26
+ | { type: 'OPEN_PASSWORD_PROMPT'; roomId: string; roomName: string }
27
+ | { type: 'CLOSE_PASSWORD_PROMPT' }
28
+ | { type: 'SET_PASSWORD_INPUT'; input: string }
29
+
30
+ function chatUIReducer(state: ChatUIState, action: ChatUIAction): ChatUIState {
31
+ switch (action.type) {
32
+ case 'SET_TEXT':
33
+ return { ...state, text: action.text }
34
+ case 'SET_ERROR':
35
+ return { ...state, error: action.error }
36
+ case 'OPEN_CREATE_MODAL':
37
+ return { ...state, createModal: { open: true, name: '', password: '' } }
38
+ case 'CLOSE_CREATE_MODAL':
39
+ return { ...state, createModal: { open: false, name: '', password: '' } }
40
+ case 'UPDATE_CREATE_FORM':
41
+ return {
42
+ ...state,
43
+ createModal: {
44
+ ...state.createModal,
45
+ name: action.name ?? state.createModal.name,
46
+ password: action.password ?? state.createModal.password,
47
+ },
48
+ }
49
+ case 'OPEN_PASSWORD_PROMPT':
50
+ return { ...state, passwordPrompt: { roomId: action.roomId, roomName: action.roomName, input: '' } }
51
+ case 'CLOSE_PASSWORD_PROMPT':
52
+ return { ...state, passwordPrompt: null }
53
+ case 'SET_PASSWORD_INPUT':
54
+ return state.passwordPrompt
55
+ ? { ...state, passwordPrompt: { ...state.passwordPrompt, input: action.input } }
56
+ : state
57
+ default:
58
+ return state
59
+ }
60
+ }
61
+
62
+ const initialUIState: ChatUIState = {
63
+ text: '',
64
+ error: '',
65
+ createModal: { open: false, name: '', password: '' },
66
+ passwordPrompt: null,
67
+ }
68
+
69
+ export function RoomChatDemo() {
70
+ const [ui, dispatch] = useReducer(chatUIReducer, initialUIState)
71
+ const messagesEndRef = useRef<HTMLDivElement>(null)
72
+
73
+ const defaultUsername = useMemo(() => {
74
+ const prefix = ['Edge', 'Core', 'Live', 'Flux', 'Node'][Math.floor(Math.random() * 5)]
75
+ return `${prefix}-${Math.floor(Math.random() * 100)}`
76
+ }, [])
77
+
78
+ const chat = Live.use(LiveRoomChat, {
79
+ initialState: { ...LiveRoomChat.defaultState, username: defaultUsername },
80
+ })
81
+
82
+ const activeRoom = chat.$state.activeRoom
83
+ const activeMessages = activeRoom ? (chat.$state.messages[activeRoom] || []) : []
84
+ const joinedRoomIds = chat.$state.rooms.map(r => r.id)
85
+ const joinedRoomsMap = new Map(chat.$state.rooms.map(r => [r.id, r]))
86
+ const customRooms = chat.$state.customRooms || []
87
+ const allRooms = [
88
+ ...DEFAULT_ROOMS.map(r => ({ ...r, isPrivate: joinedRoomsMap.get(r.id)?.isPrivate ?? false, createdBy: '' })),
89
+ ...customRooms
90
+ .filter(r => !DEFAULT_ROOMS.some(d => d.id === r.id))
91
+ .map(r => ({ id: r.id, name: r.name, isPrivate: r.isPrivate, createdBy: r.createdBy })),
92
+ ]
93
+
94
+ useEffect(() => {
95
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
96
+ }, [activeMessages.length])
97
+
98
+ useEffect(() => {
99
+ if (!ui.error) return
100
+ const timeout = setTimeout(() => dispatch({ type: 'SET_ERROR', error: '' }), 3000)
101
+ return () => clearTimeout(timeout)
102
+ }, [ui.error])
103
+
104
+ const handleJoinRoom = async (roomId: string, roomName: string, isPrivate?: boolean) => {
105
+ if (joinedRoomIds.includes(roomId)) {
106
+ await chat.switchRoom({ roomId })
107
+ return
108
+ }
109
+
110
+ if (isPrivate) {
111
+ dispatch({ type: 'OPEN_PASSWORD_PROMPT', roomId, roomName })
112
+ return
113
+ }
114
+
115
+ const result = await chat.joinRoom({ roomId, roomName })
116
+ if (result && !result.success) {
117
+ dispatch({ type: 'OPEN_PASSWORD_PROMPT', roomId, roomName })
118
+ }
119
+ }
120
+
121
+ const handlePasswordSubmit = async () => {
122
+ if (!ui.passwordPrompt) return
123
+ const result = await chat.joinRoom({
124
+ roomId: ui.passwordPrompt.roomId,
125
+ roomName: ui.passwordPrompt.roomName,
126
+ password: ui.passwordPrompt.input,
127
+ })
128
+ if (result && !result.success) {
129
+ dispatch({ type: 'SET_ERROR', error: result.error || 'Invalid password' })
130
+ } else {
131
+ dispatch({ type: 'CLOSE_PASSWORD_PROMPT' })
132
+ }
133
+ }
134
+
135
+ const handleCreateRoom = async () => {
136
+ const name = ui.createModal.name.trim()
137
+ if (!name) return
138
+ const roomId = name.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')
139
+ if (!roomId) return
140
+
141
+ const result = await chat.createRoom({
142
+ roomId,
143
+ roomName: name,
144
+ password: ui.createModal.password || undefined,
145
+ })
146
+ if (result && !result.success) {
147
+ dispatch({ type: 'SET_ERROR', error: result.error || 'Could not create room' })
148
+ } else {
149
+ dispatch({ type: 'CLOSE_CREATE_MODAL' })
150
+ }
151
+ }
152
+
153
+ const handleSendMessage = async () => {
154
+ if (!ui.text.trim() || !activeRoom) return
155
+ await chat.sendMessage({ text: ui.text })
156
+ dispatch({ type: 'SET_TEXT', text: '' })
157
+ }
158
+
159
+ return (
160
+ <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">
161
+ <aside className={`${activeRoom ? 'hidden md:flex' : 'flex'} w-full flex-col border-white/10 bg-black/25 md:w-72 md:border-r`}>
162
+ <div className="border-b border-white/10 p-4">
163
+ <div className="flex items-center justify-between gap-3">
164
+ <div>
165
+ <h2 className="text-lg font-semibold text-white">Rooms</h2>
166
+ <p className="mt-1 text-xs text-gray-500">{joinedRoomIds.length} joined rooms</p>
167
+ </div>
168
+ <span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${
169
+ chat.$connected
170
+ ? 'border-emerald-400/25 bg-emerald-400/10 text-emerald-200'
171
+ : 'border-red-400/25 bg-red-400/10 text-red-200'
172
+ }`}>
173
+ <span className={`h-1.5 w-1.5 rounded-full ${chat.$connected ? 'bg-emerald-300' : 'bg-red-300'}`} />
174
+ Live
175
+ </span>
176
+ </div>
177
+
178
+ <div className="mt-4 rounded-lg border border-white/10 bg-white/[0.025] px-3 py-2">
179
+ <p className="text-xs text-gray-500">Current client</p>
180
+ <p className="mt-1 font-mono text-sm text-gray-200">{chat.$state.username}</p>
181
+ </div>
182
+ </div>
183
+
184
+ <div className="flex-1 overflow-auto p-3">
185
+ <button
186
+ onClick={() => dispatch({ type: 'OPEN_CREATE_MODAL' })}
187
+ 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"
188
+ >
189
+ <FaPlus className="h-3.5 w-3.5" />
190
+ Create room
191
+ </button>
192
+
193
+ <div className="space-y-1">
194
+ {allRooms.map(room => {
195
+ const isJoined = joinedRoomIds.includes(room.id)
196
+ const isActive = activeRoom === room.id
197
+
198
+ return (
199
+ <button
200
+ key={room.id}
201
+ onClick={() => handleJoinRoom(room.id, room.name, room.isPrivate && !isJoined ? true : undefined)}
202
+ className={`group flex w-full items-center justify-between gap-3 rounded-lg px-3 py-2 text-left transition ${
203
+ isActive
204
+ ? 'bg-white text-black'
205
+ : isJoined
206
+ ? 'bg-white/[0.055] text-gray-200 hover:bg-white/[0.08]'
207
+ : 'text-gray-500 hover:bg-white/[0.04] hover:text-gray-300'
208
+ }`}
209
+ >
210
+ <span className="min-w-0">
211
+ <span className="flex items-center gap-2">
212
+ {room.isPrivate && <FaLock className="h-3 w-3 shrink-0" />}
213
+ <span className="truncate text-sm font-medium">{room.name}</span>
214
+ </span>
215
+ {room.createdBy && <span className="mt-0.5 block truncate text-xs opacity-60">by {room.createdBy}</span>}
216
+ </span>
217
+ {isJoined && !isActive && (
218
+ <span
219
+ onClick={(e) => { e.stopPropagation(); chat.leaveRoom({ roomId: room.id }) }}
220
+ className="opacity-0 transition group-hover:opacity-100"
221
+ >
222
+ <FaRightFromBracket className="h-3 w-3 text-red-300" />
223
+ </span>
224
+ )}
225
+ </button>
226
+ )
227
+ })}
228
+ </div>
229
+ </div>
230
+ </aside>
231
+
232
+ <section className={`${!activeRoom ? 'hidden md:flex' : 'flex'} min-w-0 flex-1 flex-col`}>
233
+ {activeRoom ? (
234
+ <>
235
+ <header className="flex items-center justify-between gap-4 border-b border-white/10 px-4 py-3">
236
+ <div className="flex min-w-0 items-center gap-3">
237
+ <button
238
+ onClick={() => chat.switchRoom({ roomId: '' })}
239
+ 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"
240
+ aria-label="Back to rooms"
241
+ >
242
+ <FaArrowLeft className="h-3.5 w-3.5" />
243
+ </button>
244
+ <div className="min-w-0">
245
+ <h3 className="flex items-center gap-2 truncate text-sm font-semibold text-white">
246
+ {joinedRoomsMap.get(activeRoom)?.isPrivate && <FaLock className="h-3 w-3 text-theme" />}
247
+ {joinedRoomsMap.get(activeRoom)?.name || activeRoom}
248
+ </h3>
249
+ <p className="mt-0.5 text-xs text-gray-500">{activeMessages.length} messages</p>
250
+ </div>
251
+ </div>
252
+ <button
253
+ onClick={() => chat.leaveRoom({ roomId: activeRoom })}
254
+ 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"
255
+ >
256
+ Leave
257
+ </button>
258
+ </header>
259
+
260
+ <div className="flex-1 overflow-auto p-4">
261
+ {activeMessages.length === 0 ? (
262
+ <div className="flex h-full items-center justify-center text-center">
263
+ <div>
264
+ <p className="text-lg font-medium text-white">No messages yet</p>
265
+ <p className="mt-2 text-sm text-gray-500">Start the room conversation from this client.</p>
266
+ </div>
267
+ </div>
268
+ ) : (
269
+ <div className="space-y-3">
270
+ {activeMessages.map(msg => {
271
+ const mine = msg.user === chat.$state.username
272
+ return (
273
+ <div key={msg.id} className={`flex flex-col ${mine ? 'items-end' : 'items-start'}`}>
274
+ <div className={`max-w-[85%] rounded-lg border px-4 py-2 ${
275
+ mine
276
+ ? 'border-theme-active bg-theme-muted text-white'
277
+ : 'border-white/10 bg-white/[0.055] text-gray-200'
278
+ }`}>
279
+ <p className="mb-1 text-xs text-gray-400">{msg.user}</p>
280
+ <p className="text-sm leading-6">{msg.text}</p>
281
+ </div>
282
+ <span className="mt-1 text-xs text-gray-600">{new Date(msg.timestamp).toLocaleTimeString()}</span>
283
+ </div>
284
+ )
285
+ })}
286
+ <div ref={messagesEndRef} />
287
+ </div>
288
+ )}
289
+ </div>
290
+
291
+ <footer className="border-t border-white/10 p-3">
292
+ <div className="flex gap-2">
293
+ <input
294
+ value={ui.text}
295
+ onChange={(e) => dispatch({ type: 'SET_TEXT', text: e.target.value })}
296
+ onKeyDown={(e) => {
297
+ if (e.key === 'Enter' && !e.shiftKey) {
298
+ e.preventDefault()
299
+ handleSendMessage()
300
+ }
301
+ }}
302
+ placeholder="Write a message..."
303
+ className="min-w-0 flex-1 input-theme"
304
+ />
305
+ <button
306
+ onClick={handleSendMessage}
307
+ disabled={!ui.text.trim()}
308
+ className="h-11 rounded-lg bg-white px-5 text-sm font-semibold text-black transition hover:bg-gray-200 disabled:opacity-50"
309
+ >
310
+ Send
311
+ </button>
312
+ </div>
313
+ </footer>
314
+ </>
315
+ ) : (
316
+ <div className="flex flex-1 items-center justify-center text-center">
317
+ <div>
318
+ <p className="text-lg font-medium text-white">Select a room</p>
319
+ <p className="mt-2 text-sm text-gray-500">Join a default room or create a password-protected one.</p>
320
+ </div>
321
+ </div>
322
+ )}
323
+ </section>
324
+
325
+ {ui.error && (
326
+ <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">
327
+ {ui.error}
328
+ </div>
329
+ )}
330
+
331
+ {ui.createModal.open && (
332
+ <div className="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-4" onClick={() => dispatch({ type: 'CLOSE_CREATE_MODAL' })}>
333
+ <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#0b0b10] p-5 shadow-2xl" onClick={e => e.stopPropagation()}>
334
+ <h3 className="text-lg font-semibold text-white">Create room</h3>
335
+ <div className="mt-4 space-y-3">
336
+ <label className="block">
337
+ <span className="mb-1 block text-xs text-gray-400">Room name</span>
338
+ <input
339
+ value={ui.createModal.name}
340
+ onChange={e => dispatch({ type: 'UPDATE_CREATE_FORM', name: e.target.value })}
341
+ placeholder="Product team"
342
+ className="w-full input-theme"
343
+ autoFocus
344
+ onKeyDown={e => { if (e.key === 'Enter') handleCreateRoom() }}
345
+ />
346
+ </label>
347
+ <label className="block">
348
+ <span className="mb-1 block text-xs text-gray-400">Password optional</span>
349
+ <input
350
+ type="password"
351
+ value={ui.createModal.password}
352
+ onChange={e => dispatch({ type: 'UPDATE_CREATE_FORM', password: e.target.value })}
353
+ placeholder="Leave empty for a public room"
354
+ className="w-full input-theme"
355
+ onKeyDown={e => { if (e.key === 'Enter') handleCreateRoom() }}
356
+ />
357
+ </label>
358
+ <div className="grid grid-cols-2 gap-2 pt-2">
359
+ <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">
360
+ Cancel
361
+ </button>
362
+ <button onClick={handleCreateRoom} disabled={!ui.createModal.name.trim()} className="h-10 rounded-lg bg-white text-sm font-semibold text-black disabled:opacity-50">
363
+ Create
364
+ </button>
365
+ </div>
366
+ </div>
367
+ </div>
368
+ </div>
369
+ )}
370
+
371
+ {ui.passwordPrompt && (
372
+ <div className="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-4" onClick={() => dispatch({ type: 'CLOSE_PASSWORD_PROMPT' })}>
373
+ <div className="w-full max-w-sm rounded-lg border border-white/10 bg-[#0b0b10] p-5 shadow-2xl" onClick={e => e.stopPropagation()}>
374
+ <h3 className="text-lg font-semibold text-white">Protected room</h3>
375
+ <p className="mt-1 text-sm text-gray-400">{ui.passwordPrompt.roomName} requires a password.</p>
376
+ <input
377
+ type="password"
378
+ value={ui.passwordPrompt.input}
379
+ onChange={e => dispatch({ type: 'SET_PASSWORD_INPUT', input: e.target.value })}
380
+ placeholder="Password"
381
+ className="mt-4 w-full input-theme"
382
+ autoFocus
383
+ onKeyDown={e => { if (e.key === 'Enter') handlePasswordSubmit() }}
384
+ />
385
+ <div className="mt-4 grid grid-cols-2 gap-2">
386
+ <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">
387
+ Cancel
388
+ </button>
389
+ <button onClick={handlePasswordSubmit} disabled={!ui.passwordPrompt.input} className="h-10 rounded-lg bg-white text-sm font-semibold text-black disabled:opacity-50">
390
+ Join
391
+ </button>
392
+ </div>
393
+ </div>
394
+ </div>
395
+ )}
396
+ </div>
397
+ )
398
+ }