@clidey/dory 0.9.0 → 0.10.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.
@@ -0,0 +1,755 @@
1
+ import { Input, Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'
2
+ import { CheckIcon } from '@heroicons/react/24/outline'
3
+ import classNames from 'classnames'
4
+ import { PencilIcon, TrashIcon, XIcon } from 'lucide-react'
5
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
6
+ import Dropdown from '../components/dropdown'
7
+ import { Tag } from './tag'
8
+ import { PlayButton } from './api-playground'
9
+
10
+ export interface WebSocketServer {
11
+ url: string;
12
+ description?: string;
13
+ protocol?: string;
14
+ }
15
+
16
+ interface WebSocketPlaygroundProps {
17
+ url: string
18
+ title?: string
19
+ description?: string
20
+ subprotocols?: string[]
21
+ headers?: Record<string, string>
22
+ servers?: WebSocketServer[]
23
+ authType?: 'bearer' | 'apiKey' | 'basic' | 'none'
24
+ }
25
+
26
+ interface WebSocketConfig {
27
+ url: string
28
+ headers: Record<string, string>
29
+ subprotocols: string[]
30
+ }
31
+
32
+ interface WebSocketMessage {
33
+ id: string
34
+ type: 'sent' | 'received' | 'error' | 'system'
35
+ data: string
36
+ timestamp: number
37
+ binary?: boolean
38
+ }
39
+
40
+ export function DisconnectButton(props: React.ComponentPropsWithoutRef<'svg'>) {
41
+ return (
42
+ <svg viewBox="0 0 20 20" aria-hidden="true" {...props}>
43
+ <path
44
+ fill="currentColor"
45
+ d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"
46
+ />
47
+ </svg>
48
+ )
49
+ }
50
+
51
+ function LoadingSpinner(props: React.ComponentPropsWithoutRef<'svg'>) {
52
+ return (
53
+ <svg viewBox="0 0 20 20" aria-hidden="true" className="animate-spin" {...props}>
54
+ <path
55
+ fill="currentColor"
56
+ d="M10 2a8 8 0 0 1 8 8c0 .3-.02.59-.06.88l-1.99-.22A6 6 0 1 0 4 10c0-.34.03-.67.09-1L2.1 8.78A8 8 0 0 1 10 2Z"
57
+ />
58
+ </svg>
59
+ )
60
+ }
61
+
62
+ interface WebSocketConfig {
63
+ headers: Record<string, string>
64
+ subprotocols: string[]
65
+ }
66
+
67
+ interface HeadersTabProps {
68
+ wsConfig: WebSocketConfig
69
+ setWsConfig: React.Dispatch<React.SetStateAction<WebSocketConfig>>
70
+ }
71
+
72
+ interface HeaderItem {
73
+ key: string
74
+ value: string
75
+ isEditing: boolean
76
+ isNew?: boolean
77
+ }
78
+
79
+ export const HeadersTab = ({ wsConfig, setWsConfig }: HeadersTabProps) => {
80
+ const [headersList, setHeadersList] = useState<HeaderItem[]>([])
81
+ const hasInitialized = useRef(false)
82
+
83
+ useEffect(() => {
84
+ if (!hasInitialized.current) {
85
+ const initialHeaders = Object.entries(wsConfig.headers ?? {}).map(([key, value]) => ({
86
+ key,
87
+ value,
88
+ isEditing: false
89
+ }))
90
+ setHeadersList(initialHeaders)
91
+ hasInitialized.current = true
92
+ }
93
+ }, [wsConfig.headers])
94
+
95
+ useEffect(() => {
96
+ const newHeaders: Record<string, string> = {}
97
+ headersList.forEach(h => {
98
+ if (h.key.trim()) newHeaders[h.key] = h.value
99
+ })
100
+ setWsConfig(prev => ({ ...prev, headers: newHeaders }))
101
+ }, [headersList, setWsConfig])
102
+
103
+ const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
104
+ setHeadersList(list =>
105
+ list.map((h, i) => (i === index ? { ...h, [field]: value } : h))
106
+ )
107
+ }
108
+
109
+ const saveHeader = (index: number) => {
110
+ setHeadersList(list =>
111
+ list.map((h, i) => (i === index ? { ...h, isEditing: false, isNew: false } : h))
112
+ )
113
+ }
114
+
115
+ const cancelEdit = (index: number) => {
116
+ setHeadersList(list =>
117
+ list.reduce<HeaderItem[]>((acc, h, i) => {
118
+ if (i === index) {
119
+ if (h.isNew) return acc // remove new row if cancelled
120
+ return [...acc, { ...h, isEditing: false }]
121
+ }
122
+ return [...acc, h]
123
+ }, [])
124
+ )
125
+ }
126
+
127
+ const editHeader = (index: number) => {
128
+ setHeadersList(list =>
129
+ list.map((h, i) => (i === index ? { ...h, isEditing: true } : h))
130
+ )
131
+ }
132
+
133
+ const removeHeader = (index: number) => {
134
+ setHeadersList(list => list.filter((_, i) => i !== index))
135
+ }
136
+
137
+ const addEmptyHeaderRow = () => {
138
+ setHeadersList(list => [
139
+ ...list,
140
+ { key: '', value: '', isEditing: true, isNew: true }
141
+ ])
142
+ }
143
+
144
+ return (
145
+ <div className="flex flex-col gap-2 px-2 py-4">
146
+ <div className="flex items-center gap-2 px-2">
147
+ <span className="text-sm font-medium text-zinc-400 w-32">Key</span>
148
+ <span className="text-sm font-medium text-zinc-400 flex-1">Value</span>
149
+ </div>
150
+ {headersList.map((hdr, idx) => (
151
+ <div key={idx} className="flex items-center gap-2">
152
+ {hdr.isEditing ? (
153
+ <>
154
+ <Input
155
+ type="text"
156
+ value={hdr.key}
157
+ onChange={e => updateHeader(idx, 'key', (e.target as HTMLInputElement).value)}
158
+ placeholder="Header name"
159
+ className="w-32 rounded-lg bg-zinc-800 border border-zinc-600 px-2 py-1 text-sm text-white"
160
+ />
161
+ <Input
162
+ type="text"
163
+ value={hdr.value}
164
+ onChange={e => updateHeader(idx, 'value', (e.target as HTMLInputElement).value)}
165
+ placeholder="Header value"
166
+ className="flex-1 rounded-lg bg-zinc-800 border border-zinc-600 px-2 py-1 text-sm text-white"
167
+ />
168
+ <button onClick={() => saveHeader(idx)} className="text-white">
169
+ <CheckIcon className="w-4 h-4" />
170
+ </button>
171
+ <button onClick={() => cancelEdit(idx)} className="text-white">
172
+ <XIcon className="w-4 h-4" />
173
+ </button>
174
+ </>
175
+ ) : (
176
+ <>
177
+ <span className="text-sm text-white w-32 truncate">{hdr.key}</span>
178
+ <span className="text-sm text-white flex-1 truncate">{hdr.value}</span>
179
+ <button onClick={() => editHeader(idx)} className="text-white">
180
+ <PencilIcon className="w-4 h-4" />
181
+ </button>
182
+ <button onClick={() => removeHeader(idx)} className="text-red-400">
183
+ <TrashIcon className="w-4 h-4" />
184
+ </button>
185
+ </>
186
+ )}
187
+ </div>
188
+ ))}
189
+ <button onClick={addEmptyHeaderRow} className="text-sm text-sky-400 hover:text-sky-300 mt-2 w-fit">
190
+ + Add Header
191
+ </button>
192
+ </div>
193
+ )
194
+ }
195
+
196
+ interface SubprotocolItem {
197
+ value: string
198
+ isEditing: boolean
199
+ isNew?: boolean
200
+ }
201
+
202
+ interface SubprotocolsTabProps {
203
+ subprotocols: string[]
204
+ setWsConfig: React.Dispatch<React.SetStateAction<WebSocketConfig>>
205
+ }
206
+
207
+ export const SubprotocolsTab = ({ subprotocols, setWsConfig }: SubprotocolsTabProps) => {
208
+ const [subprotocolsList, setSubprotocolsList] = useState<SubprotocolItem[]>([])
209
+ const hasInitialized = useRef(false)
210
+
211
+ useEffect(() => {
212
+ if (!hasInitialized.current) {
213
+ const initial = subprotocols.map(value => ({
214
+ value,
215
+ isEditing: false
216
+ }))
217
+ setSubprotocolsList(initial)
218
+ hasInitialized.current = true
219
+ }
220
+ }, [subprotocols])
221
+
222
+ useEffect(() => {
223
+ const newSubprotocols = subprotocolsList
224
+ .filter(s => s.value.trim())
225
+ .map(s => s.value)
226
+ setWsConfig(prev => ({ ...prev, subprotocols: newSubprotocols }))
227
+ }, [subprotocolsList, setWsConfig])
228
+
229
+ const updateSubprotocol = (idx: number, val: string) =>
230
+ setSubprotocolsList(list => list.map((s, i) =>
231
+ i === idx ? { ...s, value: val } : s
232
+ ))
233
+
234
+ const saveSubprotocol = (idx: number) =>
235
+ setSubprotocolsList(list => list.map((s, i) =>
236
+ i === idx ? { ...s, isEditing: false, isNew: false } : s
237
+ ))
238
+
239
+ const cancelEdit = (idx: number) =>
240
+ setSubprotocolsList(list =>
241
+ list.reduce<SubprotocolItem[]>((acc, s, i) => {
242
+ if (i === idx) {
243
+ if (s.isNew) return acc
244
+ return [...acc, { ...s, isEditing: false }]
245
+ }
246
+ return [...acc, s]
247
+ }, [])
248
+ )
249
+
250
+ const editSubprotocol = (idx: number) =>
251
+ setSubprotocolsList(list =>
252
+ list.map((s, i) => (i === idx ? { ...s, isEditing: true } : s))
253
+ )
254
+
255
+ const removeSubprotocol = (idx: number) =>
256
+ setSubprotocolsList(list => list.filter((_, i) => i !== idx))
257
+
258
+ const addSubprotocolRow = () =>
259
+ setSubprotocolsList(list => [
260
+ ...list, { value: '', isEditing: true, isNew: true }
261
+ ])
262
+
263
+ return (
264
+ <div className="flex flex-col gap-2 px-2 py-4">
265
+ <div className="flex items-center gap-2">
266
+ <span className="text-sm font-medium text-zinc-400 flex-1">Subprotocol</span>
267
+ </div>
268
+ {subprotocolsList.map((s, i) => (
269
+ <div key={i} className="flex items-center gap-2">
270
+ {s.isEditing ? (
271
+ <>
272
+ <Input
273
+ value={s.value}
274
+ onChange={e => updateSubprotocol(i, (e.target as HTMLInputElement).value)}
275
+ placeholder="Subprotocol name"
276
+ className="flex-1 rounded-lg bg-zinc-800 border border-zinc-600 px-2 py-1 text-sm text-white"
277
+ />
278
+ <button onClick={() => saveSubprotocol(i)} className="text-white">
279
+ <CheckIcon className="w-4 h-4" />
280
+ </button>
281
+ <button onClick={() => cancelEdit(i)} className="text-white">
282
+ <XIcon className="w-4 h-4" />
283
+ </button>
284
+ </>
285
+ ) : (
286
+ <>
287
+ <span className="flex-1 truncate text-sm text-white">{s.value}</span>
288
+ <button onClick={() => editSubprotocol(i)} className="text-white">
289
+ <PencilIcon className="w-4 h-4" />
290
+ </button>
291
+ <button onClick={() => removeSubprotocol(i)} className="text-red-400">
292
+ <TrashIcon className="w-4 h-4" />
293
+ </button>
294
+ </>
295
+ )}
296
+ </div>
297
+ ))}
298
+ <button onClick={addSubprotocolRow} className="mt-2 text-sm text-sky-400 hover:text-sky-300 self-start">
299
+ + Add Subprotocol
300
+ </button>
301
+ </div>
302
+ )
303
+ }
304
+
305
+ type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'
306
+
307
+ export function WebSocketPlayground({
308
+ url,
309
+ title,
310
+ description,
311
+ subprotocols: defaultSubprotocols = [],
312
+ headers: defaultHeaders = {},
313
+ servers = [],
314
+ authType = 'none'
315
+ }: WebSocketPlaygroundProps) {
316
+ const [selectedTab, setSelectedTab] = useState(0)
317
+ const [connectionState, setConnectionState] = useState<ConnectionState>('disconnected')
318
+ const [messages, setMessages] = useState<WebSocketMessage[]>([])
319
+ const [messageInput, setMessageInput] = useState('')
320
+ const [error, setError] = useState<string | null>(null)
321
+ const [selectedServer, setSelectedServer] = useState<WebSocketServer | null>(servers?.[0] || null)
322
+ const wsRef = useRef<WebSocket | null>(null)
323
+ const messageIdRef = useRef(0)
324
+
325
+ // WebSocket configuration state
326
+ const [wsConfig, setWsConfig] = useState<WebSocketConfig>({
327
+ url: (selectedServer?.url || '') + url,
328
+ headers: { ...defaultHeaders },
329
+ subprotocols: [...defaultSubprotocols]
330
+ })
331
+
332
+ // Auth state
333
+ const [auth, setAuth] = useState({
334
+ type: authType,
335
+ token: '',
336
+ apiKey: '',
337
+ username: '',
338
+ password: ''
339
+ })
340
+
341
+ // Update URL when server changes
342
+ useEffect(() => {
343
+ if (selectedServer) {
344
+ setWsConfig(prev => ({ ...prev, url: selectedServer.url + url }))
345
+ }
346
+ }, [selectedServer, url])
347
+
348
+ const addMessage = useCallback((type: WebSocketMessage['type'], data: string, binary = false) => {
349
+ const message: WebSocketMessage = {
350
+ id: (++messageIdRef.current).toString(),
351
+ type,
352
+ data,
353
+ timestamp: Date.now(),
354
+ binary
355
+ }
356
+ setMessages(prev => [...prev, message])
357
+ }, [])
358
+
359
+ const connectWebSocket = useCallback(() => {
360
+ if (wsRef.current) {
361
+ wsRef.current.close()
362
+ }
363
+
364
+ setConnectionState('connecting')
365
+ setError(null)
366
+
367
+ try {
368
+ // Prepare headers based on auth type
369
+ const headers = { ...wsConfig.headers }
370
+
371
+ switch (auth.type) {
372
+ case 'bearer':
373
+ if (auth.token) {
374
+ headers['Authorization'] = `Bearer ${auth.token}`
375
+ }
376
+ break
377
+ case 'apiKey':
378
+ if (auth.apiKey) {
379
+ headers['X-API-Key'] = auth.apiKey
380
+ }
381
+ break
382
+ case 'basic':
383
+ if (auth.username && auth.password) {
384
+ headers['Authorization'] = `Basic ${btoa(`${auth.username}:${auth.password}`)}`
385
+ }
386
+ break
387
+ }
388
+
389
+ const ws = new WebSocket(wsConfig.url, wsConfig.subprotocols)
390
+
391
+ ws.onopen = () => {
392
+ setConnectionState('connected')
393
+ addMessage('system', 'Connected to WebSocket server')
394
+ }
395
+
396
+ ws.onmessage = (event) => {
397
+ const isBinary = event.data instanceof ArrayBuffer || event.data instanceof Blob
398
+ const data = isBinary ?
399
+ `[Binary data: ${event.data instanceof Blob ? event.data.size : event.data.byteLength} bytes]` :
400
+ event.data
401
+ addMessage('received', data, isBinary)
402
+ }
403
+
404
+ ws.onerror = () => {
405
+ setConnectionState('error')
406
+ setError('WebSocket connection error')
407
+ addMessage('error', 'Connection error occurred')
408
+ }
409
+
410
+ ws.onclose = (event) => {
411
+ setConnectionState('disconnected')
412
+ const reason = event.reason || 'Connection closed'
413
+ addMessage('system', `Disconnected: ${reason} (Code: ${event.code})`)
414
+ }
415
+
416
+ wsRef.current = ws
417
+ } catch (err) {
418
+ setConnectionState('error')
419
+ setError(err instanceof Error ? err.message : 'Failed to connect')
420
+ }
421
+ }, [wsConfig, auth, addMessage])
422
+
423
+ const disconnectWebSocket = useCallback(() => {
424
+ if (wsRef.current) {
425
+ wsRef.current.close()
426
+ wsRef.current = null
427
+ }
428
+ }, [])
429
+
430
+ const sendMessage = useCallback(() => {
431
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && messageInput.trim()) {
432
+ try {
433
+ wsRef.current.send(messageInput)
434
+ addMessage('sent', messageInput)
435
+ setMessageInput('')
436
+ } catch (err) {
437
+ addMessage('error', `Failed to send message: ${err}`)
438
+ }
439
+ }
440
+ }, [messageInput, addMessage])
441
+
442
+ const clearMessages = useCallback(() => {
443
+ setMessages([])
444
+ }, [])
445
+
446
+ const tabs = [
447
+ { name: 'Headers', key: 'headers' },
448
+ { name: 'Subprotocols', key: 'subprotocols' },
449
+ { name: 'Auth', key: 'auth' }
450
+ ]
451
+
452
+ const getConnectionStatusColor = () => {
453
+ switch (connectionState) {
454
+ case 'connected':
455
+ return 'text-green-400'
456
+ case 'connecting':
457
+ return 'text-yellow-400'
458
+ case 'error':
459
+ return 'text-red-400'
460
+ default:
461
+ return 'text-zinc-400'
462
+ }
463
+ }
464
+
465
+ const getConnectionStatusText = () => {
466
+ switch (connectionState) {
467
+ case 'connected':
468
+ return 'Connected'
469
+ case 'connecting':
470
+ return 'Connecting...'
471
+ case 'error':
472
+ return 'Error'
473
+ default:
474
+ return 'Disconnected'
475
+ }
476
+ }
477
+
478
+ return (
479
+ <div className="my-6 overflow-hidden rounded-2xl">
480
+ {/* Header */}
481
+ <div className="flex min-h-[calc(--spacing(12)+1px)] flex-wrap items-start gap-x-4 border-b border-zinc-700 bg-zinc-800 px-4 dark:border-zinc-800 dark:bg-transparent pb-4">
482
+ <div className="flex items-center gap-3 pt-3">
483
+ <Tag variant="small">WS</Tag>
484
+ <span className="font-mono text-sm text-white">{title || url}</span>
485
+ <div className="flex items-center gap-2">
486
+ <div className={`w-2 h-2 rounded-full ${connectionState === 'connected' ? 'bg-green-400' : connectionState === 'connecting' ? 'bg-yellow-400' : connectionState === 'error' ? 'bg-red-400' : 'bg-zinc-400'}`} />
487
+ <span className={`text-xs ${getConnectionStatusColor()}`}>
488
+ {getConnectionStatusText()}
489
+ </span>
490
+ </div>
491
+ </div>
492
+ <div className="flex gap-2 ml-auto pt-3">
493
+ <button
494
+ onClick={connectWebSocket}
495
+ disabled={connectionState === 'connecting' || connectionState === 'connected'}
496
+ className={classNames(
497
+ 'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition',
498
+ connectionState === 'connecting' || connectionState === 'connected'
499
+ ? 'bg-zinc-600 text-zinc-400 cursor-not-allowed'
500
+ : 'bg-green-600 text-white hover:bg-green-700'
501
+ )}
502
+ >
503
+ {connectionState === 'connecting' ? (
504
+ <LoadingSpinner className="h-4 w-4" />
505
+ ) : (
506
+ <PlayButton className="h-4 w-4" />
507
+ )}
508
+ {connectionState === 'connecting' ? 'Connecting...' : 'Connect'}
509
+ </button>
510
+ <button
511
+ onClick={disconnectWebSocket}
512
+ disabled={connectionState === 'disconnected'}
513
+ className={classNames(
514
+ 'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition',
515
+ connectionState === 'disconnected'
516
+ ? 'bg-zinc-600 text-zinc-400 cursor-not-allowed'
517
+ : 'bg-red-600 text-white hover:bg-red-700'
518
+ )}
519
+ >
520
+ <DisconnectButton className="h-4 w-4" />
521
+ Disconnect
522
+ </button>
523
+ </div>
524
+ </div>
525
+
526
+ {description && (
527
+ <div className="px-4 py-3 border-b border-zinc-700 dark:border-zinc-800">
528
+ <p className="text-sm text-zinc-300">{description}</p>
529
+ </div>
530
+ )}
531
+
532
+ <div className="flex flex-col lg:flex-row gap-4">
533
+ {/* Configuration */}
534
+ <div className="flex-1 min-w-0">
535
+ <div className="flex flex-col gap-2 p-4">
536
+ <label className="text-sm font-medium text-white">Server</label>
537
+ <Dropdown
538
+ buttonLabel={selectedServer?.description || selectedServer?.url || 'Select Server'}
539
+ items={servers.map(server => ({
540
+ label: server.description || server.url,
541
+ onClick: () => setSelectedServer(server)
542
+ }))}
543
+ className="w-full"
544
+ />
545
+ </div>
546
+
547
+ <div className="border-t border-zinc-700 dark:border-zinc-800">
548
+ <TabGroup selectedIndex={selectedTab} onChange={setSelectedTab}>
549
+ <TabList className="flex border-b border-zinc-700 dark:border-zinc-800">
550
+ {tabs.map((tab) => (
551
+ <Tab
552
+ key={tab.key}
553
+ className={({ selected }) =>
554
+ classNames(
555
+ 'px-4 py-3 text-sm font-medium transition border-b-2',
556
+ selected
557
+ ? 'border-sky-500 text-sky-400'
558
+ : 'border-transparent text-zinc-400 hover:text-zinc-300'
559
+ )
560
+ }
561
+ >
562
+ {tab.name}
563
+ </Tab>
564
+ ))}
565
+ </TabList>
566
+
567
+ <TabPanels className="min-h-[250px]">
568
+ <TabPanel className="p-4">
569
+ <HeadersTab
570
+ wsConfig={wsConfig}
571
+ setWsConfig={setWsConfig}
572
+ />
573
+ </TabPanel>
574
+
575
+ <TabPanel className="p-4">
576
+ <SubprotocolsTab
577
+ subprotocols={wsConfig.subprotocols}
578
+ setWsConfig={setWsConfig}
579
+ />
580
+ </TabPanel>
581
+
582
+ {/* Auth Tab */}
583
+ <TabPanel className="p-4 space-y-4">
584
+ <div className="flex flex-col space-y-2">
585
+ <label className="text-sm font-medium text-white">Authentication Type</label>
586
+ <Dropdown
587
+ buttonLabel={auth.type === 'none' ? 'Select Auth Type' : auth.type.charAt(0).toUpperCase() + auth.type.slice(1)}
588
+ items={[
589
+ { label: 'None', onClick: () => setAuth(prev => ({ ...prev, type: 'none' })) },
590
+ { label: 'Bearer Token', onClick: () => setAuth(prev => ({ ...prev, type: 'bearer' })) },
591
+ { label: 'API Key', onClick: () => setAuth(prev => ({ ...prev, type: 'apiKey' })) },
592
+ { label: 'Basic Auth', onClick: () => setAuth(prev => ({ ...prev, type: 'basic' })) }
593
+ ]}
594
+ className="w-full"
595
+ />
596
+ </div>
597
+
598
+ {auth.type === 'bearer' && (
599
+ <div className="space-y-2">
600
+ <label className="text-sm font-medium text-white">Bearer Token</label>
601
+ <input
602
+ type="password"
603
+ value={auth.token}
604
+ onChange={(e) => setAuth(prev => ({ ...prev, token: (e.target as HTMLInputElement).value }))}
605
+ placeholder="Enter bearer token..."
606
+ className="w-full rounded-lg bg-zinc-800 border border-zinc-600 px-3 py-2 text-sm text-white placeholder-zinc-400 focus:border-sky-500 focus:outline-none"
607
+ />
608
+ </div>
609
+ )}
610
+
611
+ {auth.type === 'apiKey' && (
612
+ <div className="space-y-2">
613
+ <label className="text-sm font-medium text-white">API Key</label>
614
+ <input
615
+ type="password"
616
+ value={auth.apiKey}
617
+ onChange={(e) => setAuth(prev => ({ ...prev, apiKey: (e.target as HTMLInputElement).value }))}
618
+ placeholder="Enter API key..."
619
+ className="w-full rounded-lg bg-zinc-800 border border-zinc-600 px-3 py-2 text-sm text-white placeholder-zinc-400 focus:border-sky-500 focus:outline-none"
620
+ />
621
+ </div>
622
+ )}
623
+
624
+ {auth.type === 'basic' && (
625
+ <div className="space-y-4">
626
+ <div className="space-y-2">
627
+ <label className="text-sm font-medium text-white">Username</label>
628
+ <input
629
+ type="text"
630
+ value={auth.username}
631
+ onChange={(e) => setAuth(prev => ({ ...prev, username: (e.target as HTMLInputElement).value }))}
632
+ placeholder="Enter username..."
633
+ className="w-full rounded-lg bg-zinc-800 border border-zinc-600 px-3 py-2 text-sm text-white placeholder-zinc-400 focus:border-sky-500 focus:outline-none"
634
+ />
635
+ </div>
636
+ <div className="space-y-2">
637
+ <label className="text-sm font-medium text-white">Password</label>
638
+ <input
639
+ type="password"
640
+ value={auth.password}
641
+ onChange={(e) => setAuth(prev => ({ ...prev, password: (e.target as HTMLInputElement).value }))}
642
+ placeholder="Enter password..."
643
+ className="w-full rounded-lg bg-zinc-800 border border-zinc-600 px-3 py-2 text-sm text-white placeholder-zinc-400 focus:border-sky-500 focus:outline-none"
644
+ />
645
+ </div>
646
+ </div>
647
+ )}
648
+ </TabPanel>
649
+ </TabPanels>
650
+ </TabGroup>
651
+ </div>
652
+ </div>
653
+
654
+ {/* Messages */}
655
+ <div className="flex-1 min-w-0">
656
+ <div className="border-t lg:border-t-0 lg:border-l border-zinc-700 dark:border-zinc-800">
657
+ <div className="flex items-center justify-between px-4 py-3 border-b border-zinc-700 dark:border-zinc-800">
658
+ <h3 className="text-sm font-semibold text-white">Messages</h3>
659
+ <button
660
+ onClick={clearMessages}
661
+ className="text-xs text-zinc-400 hover:text-zinc-300"
662
+ >
663
+ Clear
664
+ </button>
665
+ </div>
666
+
667
+ <div className="flex flex-col h-64 overflow-y-auto p-4 space-y-2">
668
+ {messages.length === 0 ? (
669
+ <div className="text-center py-8">
670
+ <div className="text-zinc-400 text-sm">
671
+ Connect to start receiving messages
672
+ </div>
673
+ </div>
674
+ ) : (
675
+ messages.map((msg) => (
676
+ <div
677
+ key={msg.id}
678
+ className={classNames(
679
+ 'flex flex-col gap-1 p-3 rounded-lg text-sm',
680
+ msg.type === 'sent' && 'bg-sky-500/10 border border-sky-500/20 ml-8',
681
+ msg.type === 'received' && 'bg-green-500/10 border border-green-500/20 mr-8',
682
+ msg.type === 'error' && 'bg-red-500/10 border border-red-500/20',
683
+ msg.type === 'system' && 'bg-zinc-500/10 border border-zinc-500/20'
684
+ )}
685
+ >
686
+ <div className="flex items-center justify-between">
687
+ <span className={classNames(
688
+ 'text-xs font-medium',
689
+ msg.type === 'sent' && 'text-sky-400',
690
+ msg.type === 'received' && 'text-green-400',
691
+ msg.type === 'error' && 'text-red-400',
692
+ msg.type === 'system' && 'text-zinc-400'
693
+ )}>
694
+ {msg.type === 'sent' ? 'Sent' :
695
+ msg.type === 'received' ? 'Received' :
696
+ msg.type === 'error' ? 'Error' : 'System'}
697
+ {msg.binary && ' (Binary)'}
698
+ </span>
699
+ <span className="text-xs text-zinc-500">
700
+ {new Date(msg.timestamp).toLocaleTimeString()}
701
+ </span>
702
+ </div>
703
+ <div className="text-white break-words font-mono text-xs">
704
+ {msg.data}
705
+ </div>
706
+ </div>
707
+ ))
708
+ )}
709
+ </div>
710
+
711
+ {/* Send Message */}
712
+ <div className="border-t border-zinc-700 dark:border-zinc-800 p-4">
713
+ <div className="flex gap-2">
714
+ <input
715
+ type="text"
716
+ value={messageInput}
717
+ onChange={(e) => setMessageInput((e.target as HTMLInputElement).value)}
718
+ onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
719
+ placeholder="Enter message to send..."
720
+ disabled={connectionState !== 'connected'}
721
+ className="flex-1 rounded-lg bg-zinc-800 border border-zinc-600 px-3 py-2 text-sm text-white placeholder-zinc-400 focus:border-sky-500 focus:outline-none disabled:bg-zinc-900 disabled:text-zinc-500"
722
+ />
723
+ <button
724
+ onClick={sendMessage}
725
+ disabled={connectionState !== 'connected' || !messageInput.trim()}
726
+ className={classNames(
727
+ 'px-4 py-2 rounded-lg text-sm font-medium transition',
728
+ connectionState === 'connected' && messageInput.trim()
729
+ ? 'bg-sky-600 text-white hover:bg-sky-700'
730
+ : 'bg-zinc-600 text-zinc-400 cursor-not-allowed'
731
+ )}
732
+ >
733
+ Send
734
+ </button>
735
+ </div>
736
+ </div>
737
+ </div>
738
+ </div>
739
+ </div>
740
+
741
+ {error && (
742
+ <div className="border-t border-zinc-700 dark:border-zinc-800 p-4">
743
+ <div className="flex items-center gap-2">
744
+ <span className="inline-flex items-center rounded-full bg-rose-500/10 px-2 py-1 text-xs font-medium text-rose-400 ring-1 ring-rose-500/20">
745
+ Error
746
+ </span>
747
+ </div>
748
+ <pre className="text-sm text-rose-400 bg-rose-500/5 p-3 rounded-lg border border-rose-500/20 mt-2">
749
+ {error}
750
+ </pre>
751
+ </div>
752
+ )}
753
+ </div>
754
+ )
755
+ }