create-fluxstack 1.21.1 → 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 (55) hide show
  1. package/app/client/src/App.tsx +9 -11
  2. package/app/client/src/components/AppLayout.tsx +291 -290
  3. package/app/client/src/components/BackButton.tsx +17 -16
  4. package/app/client/src/components/ColorWheel.tsx +1 -0
  5. package/app/client/src/components/DemoPage.tsx +136 -135
  6. package/app/client/src/components/ErrorBoundary.tsx +2 -1
  7. package/app/client/src/components/LiveErrorBoundary.tsx +2 -1
  8. package/app/client/src/components/ThemePicker.tsx +1 -0
  9. package/app/client/src/framework/ClientOnly.tsx +14 -0
  10. package/app/client/src/framework/LivePage.tsx +55 -0
  11. package/app/client/src/framework/RscHomePage.tsx +125 -0
  12. package/app/client/src/framework/RscLink.tsx +31 -0
  13. package/app/client/src/framework/RscNav.tsx +42 -0
  14. package/app/client/src/framework/RscRoot.tsx +54 -0
  15. package/app/client/src/framework/csrf.ts +22 -0
  16. package/app/client/src/framework/entry.browser.tsx +51 -0
  17. package/app/client/src/framework/entry.rsc.tsx +41 -0
  18. package/app/client/src/framework/entry.ssr.tsx +12 -0
  19. package/app/client/src/framework/navigation.ts +29 -0
  20. package/app/client/src/framework/params.tsx +22 -0
  21. package/app/client/src/framework/routes.ts +143 -0
  22. package/app/client/src/framework/vite-rsc.d.ts +21 -0
  23. package/app/client/src/hooks/useThemeClock.ts +16 -1
  24. package/app/client/src/live/AuthDemo.tsx +271 -270
  25. package/app/client/src/live/CounterDemo.tsx +152 -151
  26. package/app/client/src/live/FormDemo.tsx +141 -140
  27. package/app/client/src/live/PingPongDemo.tsx +181 -180
  28. package/app/client/src/live/RoomChatDemo.tsx +398 -397
  29. package/app/client/src/live/SharedCounterDemo.tsx +2 -1
  30. package/app/client/src/pages/ApiTestPage.tsx +1 -0
  31. package/app/client/src/pages/auth.tsx +13 -0
  32. package/app/client/src/pages/blog/[slug].tsx +23 -0
  33. package/app/client/src/pages/counter.tsx +15 -0
  34. package/app/client/src/pages/form.tsx +13 -0
  35. package/app/client/src/pages/index.tsx +9 -0
  36. package/app/client/src/pages/ping-pong.tsx +13 -0
  37. package/app/client/src/pages/room-chat.tsx +13 -0
  38. package/app/client/src/pages/shared-counter.tsx +13 -0
  39. package/app/client/src/pages/sobre.tsx +19 -0
  40. package/app/server/index.ts +5 -1
  41. package/app/server/live/rooms/index.ts +14 -0
  42. package/config/system/plugins.config.ts +12 -2
  43. package/core/cli/commands/dev.ts +9 -1
  44. package/core/plugins/built-in/rsc/index.ts +156 -0
  45. package/core/plugins/built-in/ssr/bun-asset-loader.ts +46 -0
  46. package/core/plugins/built-in/ssr/index.ts +143 -0
  47. package/core/plugins/built-in/ssr/registry.ts +38 -0
  48. package/core/plugins/built-in/vite/index.ts +7 -0
  49. package/core/server/live/websocket-plugin.ts +29 -10
  50. package/core/utils/version.ts +6 -6
  51. package/create-fluxstack.ts +64 -5
  52. package/package.json +112 -108
  53. package/playwright.config.ts +6 -2
  54. package/vite.config.ts +22 -0
  55. package/app/client/.live-stubs/LiveUpload.js +0 -15
@@ -1,397 +1,398 @@
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
- }
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
+ }