@clidey/dory 0.29.0 → 0.30.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/bin/dist/dory.js +115 -29
- package/bin/dist/dory.js.map +1 -1
- package/package.json +12 -4
- package/.dockerignore +0 -4
- package/CLAUDE.md +0 -178
- package/CODE_OF_CONDUCT.md +0 -128
- package/CONTRIBUTING.md +0 -91
- package/SECURITY.md +0 -11
- package/bin/dory.ts +0 -433
- package/dist/favicon.svg +0 -75
- package/dist/frontmatter.json +0 -77
- package/dist/index.html +0 -55
- package/dist/llms.txt +0 -561
- package/dist/logo/logo.svg +0 -18
- package/docs/api-playground-demo.mdx +0 -119
- package/docs/api-reference/asyncapi.json +0 -324
- package/docs/api-reference/openapi.json +0 -366
- package/docs/api-reference/operations/create.mdx +0 -5
- package/docs/api-reference/operations/delete.mdx +0 -5
- package/docs/api-reference/operations/get.mdx +0 -5
- package/docs/api-reference/operations/update.mdx +0 -5
- package/docs/api-reference/websocket-operations/receive-chat-message.mdx +0 -5
- package/docs/api-reference/websocket-operations/receive-notifications.mdx +0 -5
- package/docs/api-reference/websocket-operations/receive-presence-update.mdx +0 -5
- package/docs/api-reference/websocket-operations/send-chat-message.mdx +0 -5
- package/docs/api-reference/websocket-operations/send-presence-update.mdx +0 -5
- package/docs/dory.json +0 -64
- package/docs/favicon.svg +0 -75
- package/docs/features.mdx +0 -440
- package/docs/introduction.mdx +0 -44
- package/docs/logo/logo.svg +0 -18
- package/docs/websocket-playground-demo.mdx +0 -135
- package/index.html +0 -54
- package/scripts/generate-frontmatter-json.js +0 -154
- package/scripts/generate-llm-txt.js +0 -115
- package/src/app.css +0 -5
- package/src/app.tsx +0 -13
- package/src/components/dropdown.tsx +0 -53
- package/src/components/hooks.tsx +0 -28
- package/src/components/loading.tsx +0 -21
- package/src/components/mdx-provider.tsx +0 -13
- package/src/components/navigation.tsx +0 -58
- package/src/components/notification.tsx +0 -13
- package/src/components/routes.tsx +0 -92
- package/src/components/search-index.ts +0 -43
- package/src/components/search.tsx +0 -435
- package/src/components/store.ts +0 -216
- package/src/config/mdx.js +0 -12
- package/src/config/mdx.ts +0 -12
- package/src/main.tsx +0 -22
- package/src/mdx/accordion.tsx +0 -41
- package/src/mdx/api-playground.tsx +0 -659
- package/src/mdx/api.tsx +0 -354
- package/src/mdx/async-api.tsx +0 -353
- package/src/mdx/callout.tsx +0 -64
- package/src/mdx/card.tsx +0 -54
- package/src/mdx/code.tsx +0 -289
- package/src/mdx/expandable.tsx +0 -36
- package/src/mdx/fence.tsx +0 -74
- package/src/mdx/icon.tsx +0 -73
- package/src/mdx/latex.tsx +0 -26
- package/src/mdx/list.tsx +0 -7
- package/src/mdx/mdx.tsx +0 -47
- package/src/mdx/mermaid.tsx +0 -147
- package/src/mdx/open-api.tsx +0 -311
- package/src/mdx/processor.ts +0 -174
- package/src/mdx/source.tsx +0 -58
- package/src/mdx/step.tsx +0 -51
- package/src/mdx/table.tsx +0 -20
- package/src/mdx/tag.tsx +0 -53
- package/src/mdx/text.tsx +0 -99
- package/src/mdx/websocket-playground.tsx +0 -824
- package/src/plugins/docs-assets.ts +0 -79
- package/src/plugins/frontmatter-dev-server.ts +0 -158
- package/src/plugins/frontmatter-generator.ts +0 -157
- package/src/plugins/html-metadata-injector.ts +0 -76
- package/src/plugins/llm-txt-dev-server.ts +0 -183
- package/src/plugins/llm-txt-generator.ts +0 -186
- package/src/plugins/sanitize.ts +0 -163
- package/src/plugins/unified-mdx-plugin.ts +0 -35
- package/src/theme.scss +0 -72
- package/src/types/config.ts +0 -47
- package/src/ui/header.tsx +0 -91
- package/src/ui/layout.tsx +0 -226
- package/src/ui/mobile-navigation.tsx +0 -64
- package/src/ui/prev-next-link.tsx +0 -74
- package/src/ui/table-of-content.tsx +0 -166
- package/src/utils/colors.ts +0 -54
- package/src/utils/fonts.ts +0 -23
- package/src/utils/functions.ts +0 -12
- package/src/vite-env.d.ts +0 -1
- package/tsconfig.app.json +0 -33
- package/tsconfig.bin.json +0 -24
- package/tsconfig.json +0 -7
- package/tsconfig.node.json +0 -26
- package/vite.config.ts +0 -43
|
@@ -1,824 +0,0 @@
|
|
|
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
|
-
}
|