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.
- package/LLMD/resources/live-components.md +103 -57
- package/LLMD/resources/live-rooms.md +187 -88
- package/README.md +27 -25
- package/app/client/.live-stubs/LiveCounter.js +4 -4
- package/app/client/src/App.tsx +11 -12
- package/app/client/src/components/AppLayout.tsx +290 -252
- package/app/client/src/components/BackButton.tsx +16 -13
- package/app/client/src/components/DemoPage.tsx +135 -22
- package/app/client/src/index.css +21 -11
- package/app/client/src/live/AuthDemo.tsx +270 -333
- package/app/client/src/live/CounterDemo.tsx +151 -206
- package/app/client/src/live/FormDemo.tsx +140 -119
- package/app/client/src/live/PingPongDemo.tsx +180 -202
- package/app/client/src/live/RoomChatDemo.tsx +397 -374
- package/app/client/src/pages/HomePage.tsx +170 -104
- package/app/server/live/LiveCounter.ts +71 -68
- package/app/server/live/LiveSharedCounter.ts +18 -12
- package/app/server/live/auto-generated-components.ts +1 -3
- package/app/server/live/rooms/CounterRoom.ts +15 -10
- package/core/client/index.ts +0 -3
- package/core/client/state/createStore.ts +88 -88
- package/core/client/state/index.ts +5 -5
- package/core/server/live/auto-generated-components.ts +1 -3
- package/core/utils/version.ts +1 -1
- package/package.json +1 -1
- package/tsconfig.json +7 -6
- package/app/client/src/components/LiveUploadWidget.tsx +0 -200
- package/app/client/src/live/UploadDemo.tsx +0 -21
- package/app/server/live/LiveUpload.ts +0 -96
- package/core/client/hooks/useLiveUpload.ts +0 -70
|
@@ -1,374 +1,397 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
{ id: '
|
|
9
|
-
{ id: '
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
type
|
|
23
|
-
| { type: '
|
|
24
|
-
| { type: '
|
|
25
|
-
| { type: '
|
|
26
|
-
| { type: '
|
|
27
|
-
| { type: '
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
case '
|
|
49
|
-
return
|
|
50
|
-
|
|
51
|
-
return state
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
<
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
{
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
<
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
className="
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
+
}
|