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