@northlight/ui 2.41.1 → 2.42.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/package.json +5 -4
- package/sandbox/lib/types.ts +3 -2
- package/sandbox/lib/viewer/sandbox-viewer.css +758 -132
- package/sandbox/lib/viewer/sandbox-viewer.tsx +772 -185
- package/sandbox/package.json +4 -0
|
@@ -21,59 +21,9 @@ type PlayError = {
|
|
|
21
21
|
error: Error
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
type
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
playingScenario: string | null
|
|
28
|
-
playError: PlayError | null
|
|
29
|
-
playKey: number
|
|
30
|
-
onPlay: (
|
|
31
|
-
scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
|
|
32
|
-
getContainer: () => HTMLElement | null,
|
|
33
|
-
) => void
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function InlineScenario (
|
|
37
|
-
{ scenario, component, playingScenario, playError, playKey, onPlay }: InlineScenarioProps
|
|
38
|
-
) {
|
|
39
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
40
|
-
const isPlaying = playingScenario === scenario.name
|
|
41
|
-
const hasError = playError?.scenarioName === scenario.name
|
|
42
|
-
|
|
43
|
-
return (
|
|
44
|
-
<div className="sandbox-inline-scenario">
|
|
45
|
-
<div className="sandbox-inline-label">{ scenario.name }</div>
|
|
46
|
-
<div className="sandbox-inline-preview" ref={ ref }>
|
|
47
|
-
<ErrorBoundary fallback={ RenderErrorFallback }>
|
|
48
|
-
<div key={ `${scenario.name}-${playKey}` }>
|
|
49
|
-
{ createElement(
|
|
50
|
-
scenario.component ?? component,
|
|
51
|
-
scenario.props as Record<string, unknown>
|
|
52
|
-
) }
|
|
53
|
-
</div>
|
|
54
|
-
</ErrorBoundary>
|
|
55
|
-
{ hasError && (
|
|
56
|
-
<div className="sandbox-play-error">
|
|
57
|
-
<div className="sandbox-play-error-title">Play Error</div>
|
|
58
|
-
<pre className="sandbox-play-error-message">{ playError.error.message }</pre>
|
|
59
|
-
</div>
|
|
60
|
-
) }
|
|
61
|
-
</div>
|
|
62
|
-
<div className="sandbox-play-slot">
|
|
63
|
-
{ scenario.play && (
|
|
64
|
-
<button
|
|
65
|
-
type="button"
|
|
66
|
-
className={ `sandbox-play-inline ${hasError ? 'error' : ''}` }
|
|
67
|
-
onClick={ () => onPlay(scenario, () => ref.current) }
|
|
68
|
-
disabled={ isPlaying }
|
|
69
|
-
>
|
|
70
|
-
{ isPlaying ? '...' : hasError ? '!' : '▶' }
|
|
71
|
-
</button>
|
|
72
|
-
) }
|
|
73
|
-
</div>
|
|
74
|
-
</div>
|
|
75
|
-
)
|
|
76
|
-
}
|
|
24
|
+
type ViewState =
|
|
25
|
+
| { type: 'grid' }
|
|
26
|
+
| { type: 'detail', componentSlug: string }
|
|
77
27
|
|
|
78
28
|
type Selection = {
|
|
79
29
|
componentSlug: string
|
|
@@ -82,6 +32,12 @@ type Selection = {
|
|
|
82
32
|
|
|
83
33
|
type SandboxViewerProps = {
|
|
84
34
|
scenarios: ComponentScenarios[]
|
|
35
|
+
/** Custom title for the landing page */
|
|
36
|
+
title?: string
|
|
37
|
+
/** Custom description for the landing page */
|
|
38
|
+
description?: string
|
|
39
|
+
/** Whether to show the hero section (default: true) */
|
|
40
|
+
showHero?: boolean
|
|
85
41
|
}
|
|
86
42
|
|
|
87
43
|
function toSlug (name: string): string {
|
|
@@ -102,14 +58,280 @@ function toPath (componentSlug: string, scenarioSlug: string): string {
|
|
|
102
58
|
return `/${componentSlug}/${scenarioSlug}`
|
|
103
59
|
}
|
|
104
60
|
|
|
105
|
-
function
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
61
|
+
function CodeIcon () {
|
|
62
|
+
return (
|
|
63
|
+
<svg
|
|
64
|
+
width="16"
|
|
65
|
+
height="16"
|
|
66
|
+
viewBox="0 0 24 24"
|
|
67
|
+
fill="none"
|
|
68
|
+
stroke="currentColor"
|
|
69
|
+
strokeWidth="2"
|
|
70
|
+
strokeLinecap="round"
|
|
71
|
+
strokeLinejoin="round"
|
|
72
|
+
aria-hidden="true"
|
|
73
|
+
>
|
|
74
|
+
<path d="m18 16 4-4-4-4" />
|
|
75
|
+
<path d="m6 8-4 4 4 4" />
|
|
76
|
+
<path d="m14.5 4-5 16" />
|
|
77
|
+
</svg>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function EyeIcon () {
|
|
82
|
+
return (
|
|
83
|
+
<svg
|
|
84
|
+
width="14"
|
|
85
|
+
height="14"
|
|
86
|
+
viewBox="0 0 24 24"
|
|
87
|
+
fill="none"
|
|
88
|
+
stroke="currentColor"
|
|
89
|
+
strokeWidth="2"
|
|
90
|
+
strokeLinecap="round"
|
|
91
|
+
strokeLinejoin="round"
|
|
92
|
+
aria-hidden="true"
|
|
93
|
+
>
|
|
94
|
+
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z" />
|
|
95
|
+
<circle cx="12" cy="12" r="3" />
|
|
96
|
+
</svg>
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function Code2Icon () {
|
|
101
|
+
return (
|
|
102
|
+
<svg
|
|
103
|
+
width="14"
|
|
104
|
+
height="14"
|
|
105
|
+
viewBox="0 0 24 24"
|
|
106
|
+
fill="none"
|
|
107
|
+
stroke="currentColor"
|
|
108
|
+
strokeWidth="2"
|
|
109
|
+
strokeLinecap="round"
|
|
110
|
+
strokeLinejoin="round"
|
|
111
|
+
aria-hidden="true"
|
|
112
|
+
>
|
|
113
|
+
<path d="m18 16 4-4-4-4" />
|
|
114
|
+
<path d="m6 8-4 4 4 4" />
|
|
115
|
+
</svg>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function CopyIcon () {
|
|
120
|
+
return (
|
|
121
|
+
<svg
|
|
122
|
+
width="14"
|
|
123
|
+
height="14"
|
|
124
|
+
viewBox="0 0 24 24"
|
|
125
|
+
fill="none"
|
|
126
|
+
stroke="currentColor"
|
|
127
|
+
strokeWidth="2"
|
|
128
|
+
strokeLinecap="round"
|
|
129
|
+
strokeLinejoin="round"
|
|
130
|
+
aria-hidden="true"
|
|
131
|
+
>
|
|
132
|
+
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
|
|
133
|
+
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
|
|
134
|
+
</svg>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function CheckIcon () {
|
|
139
|
+
return (
|
|
140
|
+
<svg
|
|
141
|
+
width="14"
|
|
142
|
+
height="14"
|
|
143
|
+
viewBox="0 0 24 24"
|
|
144
|
+
fill="none"
|
|
145
|
+
stroke="currentColor"
|
|
146
|
+
strokeWidth="2"
|
|
147
|
+
strokeLinecap="round"
|
|
148
|
+
strokeLinejoin="round"
|
|
149
|
+
aria-hidden="true"
|
|
150
|
+
>
|
|
151
|
+
<path d="M20 6 9 17l-5-5" />
|
|
152
|
+
</svg>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function PlayIcon () {
|
|
157
|
+
return (
|
|
158
|
+
<svg
|
|
159
|
+
width="14"
|
|
160
|
+
height="14"
|
|
161
|
+
viewBox="0 0 24 24"
|
|
162
|
+
fill="currentColor"
|
|
163
|
+
aria-hidden="true"
|
|
164
|
+
>
|
|
165
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
166
|
+
</svg>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function ArrowLeftIcon () {
|
|
171
|
+
return (
|
|
172
|
+
<svg
|
|
173
|
+
width="16"
|
|
174
|
+
height="16"
|
|
175
|
+
viewBox="0 0 24 24"
|
|
176
|
+
fill="none"
|
|
177
|
+
stroke="currentColor"
|
|
178
|
+
strokeWidth="2"
|
|
179
|
+
strokeLinecap="round"
|
|
180
|
+
strokeLinejoin="round"
|
|
181
|
+
aria-hidden="true"
|
|
182
|
+
>
|
|
183
|
+
<path d="m12 19-7-7 7-7" />
|
|
184
|
+
<path d="M19 12H5" />
|
|
185
|
+
</svg>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function ExternalLinkIcon () {
|
|
190
|
+
return (
|
|
191
|
+
<svg
|
|
192
|
+
width="16"
|
|
193
|
+
height="16"
|
|
194
|
+
viewBox="0 0 24 24"
|
|
195
|
+
fill="none"
|
|
196
|
+
stroke="currentColor"
|
|
197
|
+
strokeWidth="2"
|
|
198
|
+
strokeLinecap="round"
|
|
199
|
+
strokeLinejoin="round"
|
|
200
|
+
aria-hidden="true"
|
|
201
|
+
>
|
|
202
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
203
|
+
<polyline points="15 3 21 3 21 9" />
|
|
204
|
+
<line x1="10" x2="21" y1="14" y2="3" />
|
|
205
|
+
</svg>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
type SidebarProps = {
|
|
210
|
+
scenarios: ComponentScenarios[]
|
|
211
|
+
search: string
|
|
212
|
+
onSearchChange: (value: string) => void
|
|
213
|
+
selectedComponent: ComponentScenarios | null
|
|
214
|
+
onSelectComponent: (component: ComponentScenarios) => void
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function Sidebar ({
|
|
218
|
+
scenarios,
|
|
219
|
+
search,
|
|
220
|
+
onSearchChange,
|
|
221
|
+
selectedComponent,
|
|
222
|
+
onSelectComponent,
|
|
223
|
+
}: SidebarProps): JSX.Element {
|
|
224
|
+
const filteredScenarios = search
|
|
225
|
+
? scenarios.filter((c) => {
|
|
226
|
+
const searchTerms = search.toLowerCase().split(/\s+/).filter(Boolean)
|
|
227
|
+
const nameWords = c.name.toLowerCase().split(/\s+/)
|
|
228
|
+
return searchTerms.every((term) =>
|
|
229
|
+
nameWords.some((word) => word.startsWith(term))
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
: scenarios
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<nav className="sandbox-sidebar">
|
|
236
|
+
<div className="sandbox-logo">
|
|
237
|
+
<div className="sandbox-logo-icon">
|
|
238
|
+
<CodeIcon />
|
|
239
|
+
</div>
|
|
240
|
+
<span className="sandbox-logo-text">Sandbox</span>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
<div className="sandbox-sidebar-search">
|
|
244
|
+
<input
|
|
245
|
+
type="text"
|
|
246
|
+
className="sandbox-sidebar-search-input"
|
|
247
|
+
placeholder="Search components..."
|
|
248
|
+
value={ search }
|
|
249
|
+
onChange={ (e) => onSearchChange(e.target.value) }
|
|
250
|
+
/>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div className="sandbox-nav">
|
|
254
|
+
<div className="sandbox-nav-section">
|
|
255
|
+
<div className="sandbox-nav-title">Components</div>
|
|
256
|
+
{ filteredScenarios.map((component) => (
|
|
257
|
+
<button
|
|
258
|
+
type="button"
|
|
259
|
+
key={ component.name }
|
|
260
|
+
className={ `sandbox-nav-item ${selectedComponent?.name === component.name ? 'selected' : ''}` }
|
|
261
|
+
onClick={ () => onSelectComponent(component) }
|
|
262
|
+
>
|
|
263
|
+
<span className="sandbox-nav-item-icon">{ '{ }' }</span>
|
|
264
|
+
{ component.name }
|
|
265
|
+
</button>
|
|
266
|
+
)) }
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</nav>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
112
272
|
|
|
273
|
+
type HeroProps = {
|
|
274
|
+
title: string
|
|
275
|
+
description: string
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function Hero ({ title, description }: HeroProps) {
|
|
279
|
+
return (
|
|
280
|
+
<div className="sandbox-hero">
|
|
281
|
+
<h1 className="sandbox-hero-title">{ title }</h1>
|
|
282
|
+
<p className="sandbox-hero-description">{ description }</p>
|
|
283
|
+
</div>
|
|
284
|
+
)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
type ComponentCardProps = {
|
|
288
|
+
component: ComponentScenarios
|
|
289
|
+
onSelect: () => void
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function ComponentCard ({ component, onSelect }: ComponentCardProps) {
|
|
293
|
+
const firstScenario = component.scenarios[0]
|
|
294
|
+
const scenarioCount = component.scenarios.length
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<div className="sandbox-card" onClick={ onSelect } onKeyDown={ onSelect } role="button" tabIndex={ 0 }>
|
|
298
|
+
<div className="sandbox-card-preview">
|
|
299
|
+
{ firstScenario && (
|
|
300
|
+
<ErrorBoundary fallback={ RenderErrorFallback }>
|
|
301
|
+
{ createElement(
|
|
302
|
+
firstScenario.component ?? component.component,
|
|
303
|
+
firstScenario.props as Record<string, unknown>
|
|
304
|
+
) }
|
|
305
|
+
</ErrorBoundary>
|
|
306
|
+
) }
|
|
307
|
+
<div className="sandbox-card-actions">
|
|
308
|
+
<button type="button" className="sandbox-card-btn sandbox-card-btn-primary" onClick={ onSelect }>
|
|
309
|
+
<ExternalLinkIcon />
|
|
310
|
+
Open
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
<div className="sandbox-card-info">
|
|
315
|
+
<h3 className="sandbox-card-title">{ component.name }</h3>
|
|
316
|
+
<p className="sandbox-card-description">
|
|
317
|
+
{ scenarioCount } { scenarioCount === 1 ? 'scenario' : 'scenarios' }
|
|
318
|
+
</p>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
type GridViewProps = {
|
|
325
|
+
scenarios: ComponentScenarios[]
|
|
326
|
+
search: string
|
|
327
|
+
onSelectComponent: (component: ComponentScenarios) => void
|
|
328
|
+
title: string
|
|
329
|
+
description: string
|
|
330
|
+
showHero: boolean
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function GridView
|
|
334
|
+
({ scenarios, search, onSelectComponent, title, description, showHero }: GridViewProps) {
|
|
113
335
|
const filteredScenarios = search
|
|
114
336
|
? scenarios.filter((c) => {
|
|
115
337
|
const searchTerms = search.toLowerCase().split(/\s+/).filter(Boolean)
|
|
@@ -120,55 +342,455 @@ function SandboxViewerContent ({ scenarios }: SandboxViewerProps) {
|
|
|
120
342
|
})
|
|
121
343
|
: scenarios
|
|
122
344
|
|
|
345
|
+
return (
|
|
346
|
+
<>
|
|
347
|
+
{ showHero && (
|
|
348
|
+
<Hero
|
|
349
|
+
title={ title }
|
|
350
|
+
description={ description }
|
|
351
|
+
/>
|
|
352
|
+
) }
|
|
353
|
+
<div className="sandbox-grid">
|
|
354
|
+
{ filteredScenarios.map((component) => (
|
|
355
|
+
<ComponentCard
|
|
356
|
+
key={ component.name }
|
|
357
|
+
component={ component }
|
|
358
|
+
onSelect={ () => onSelectComponent(component) }
|
|
359
|
+
/>
|
|
360
|
+
)) }
|
|
361
|
+
</div>
|
|
362
|
+
</>
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
type ScenarioCardProps = {
|
|
367
|
+
scenario: Scenario<unknown>
|
|
368
|
+
component: ComponentType<unknown>
|
|
369
|
+
playingScenario: string | null
|
|
370
|
+
playError: PlayError | null
|
|
371
|
+
playKeys: Record<string, number>
|
|
372
|
+
onPlay: (
|
|
373
|
+
scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
|
|
374
|
+
getContainer: () => HTMLElement | null
|
|
375
|
+
) => void
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function ScenarioCard ({
|
|
379
|
+
scenario,
|
|
380
|
+
component,
|
|
381
|
+
playingScenario,
|
|
382
|
+
playError,
|
|
383
|
+
playKeys,
|
|
384
|
+
onPlay,
|
|
385
|
+
}: ScenarioCardProps) {
|
|
386
|
+
const [ viewMode, setViewMode ] = useState<'preview' | 'code'>('preview')
|
|
387
|
+
const [ copied, setCopied ] = useState(false)
|
|
388
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
389
|
+
|
|
390
|
+
const isPlaying = playingScenario === scenario.name
|
|
391
|
+
const hasError = playError?.scenarioName === scenario.name
|
|
392
|
+
|
|
393
|
+
const getCodeDisplay = () => {
|
|
394
|
+
if (scenario.code) {
|
|
395
|
+
return scenario.code
|
|
396
|
+
}
|
|
397
|
+
const componentName = component.displayName || component.name || 'Component'
|
|
398
|
+
return `<${componentName} {...props} />`
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const handleCopy = () => {
|
|
402
|
+
navigator.clipboard.writeText(getCodeDisplay())
|
|
403
|
+
setCopied(true)
|
|
404
|
+
setTimeout(() => setCopied(false), 2000)
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return (
|
|
408
|
+
<div className="sandbox-scenario-card">
|
|
409
|
+
<div className="sandbox-scenario-header">
|
|
410
|
+
<div className="sandbox-scenario-info">
|
|
411
|
+
<h3>{ scenario.name }</h3>
|
|
412
|
+
</div>
|
|
413
|
+
<div className="sandbox-scenario-actions">
|
|
414
|
+
{ scenario.code && (
|
|
415
|
+
<button
|
|
416
|
+
type="button"
|
|
417
|
+
className={ `sandbox-scenario-btn sandbox-scenario-btn-toggle ${viewMode === 'code' ? 'active' : ''}` }
|
|
418
|
+
onClick={ () => setViewMode(viewMode === 'code' ? 'preview' : 'code') }
|
|
419
|
+
>
|
|
420
|
+
{ viewMode === 'code' ? (
|
|
421
|
+
<>
|
|
422
|
+
<EyeIcon />
|
|
423
|
+
Preview
|
|
424
|
+
</>
|
|
425
|
+
) : (
|
|
426
|
+
<>
|
|
427
|
+
<Code2Icon />
|
|
428
|
+
Code
|
|
429
|
+
</>
|
|
430
|
+
) }
|
|
431
|
+
</button>
|
|
432
|
+
) }
|
|
433
|
+
{ scenario.code && (
|
|
434
|
+
<button
|
|
435
|
+
type="button"
|
|
436
|
+
className="sandbox-scenario-btn sandbox-scenario-btn-copy"
|
|
437
|
+
onClick={ handleCopy }
|
|
438
|
+
>
|
|
439
|
+
{ copied ? (
|
|
440
|
+
<>
|
|
441
|
+
<CheckIcon />
|
|
442
|
+
Copied
|
|
443
|
+
</>
|
|
444
|
+
) : (
|
|
445
|
+
<>
|
|
446
|
+
<CopyIcon />
|
|
447
|
+
Copy
|
|
448
|
+
</>
|
|
449
|
+
) }
|
|
450
|
+
</button>
|
|
451
|
+
) }
|
|
452
|
+
{ scenario.play && (
|
|
453
|
+
<button
|
|
454
|
+
type="button"
|
|
455
|
+
className={ `sandbox-scenario-btn sandbox-scenario-btn-play ${hasError ? 'error' : ''}` }
|
|
456
|
+
onClick={ () => onPlay(scenario, () => containerRef.current) }
|
|
457
|
+
disabled={ isPlaying }
|
|
458
|
+
>
|
|
459
|
+
<PlayIcon />
|
|
460
|
+
{ isPlaying ? 'Playing...' : hasError ? 'Retry' : 'Play' }
|
|
461
|
+
</button>
|
|
462
|
+
) }
|
|
463
|
+
</div>
|
|
464
|
+
</div>
|
|
465
|
+
<div className="sandbox-scenario-content">
|
|
466
|
+
{ viewMode === 'code' && scenario.code ? (
|
|
467
|
+
<div className="sandbox-scenario-code">
|
|
468
|
+
<pre>{ scenario.code }</pre>
|
|
469
|
+
</div>
|
|
470
|
+
) : (
|
|
471
|
+
<div className="sandbox-scenario-preview" ref={ containerRef }>
|
|
472
|
+
<ErrorBoundary fallback={ RenderErrorFallback }>
|
|
473
|
+
<div key={ `${scenario.name}-${playKeys[scenario.name] ?? 0}` }>
|
|
474
|
+
{ createElement(
|
|
475
|
+
scenario.component ?? component,
|
|
476
|
+
scenario.props as Record<string, unknown>
|
|
477
|
+
) }
|
|
478
|
+
</div>
|
|
479
|
+
</ErrorBoundary>
|
|
480
|
+
</div>
|
|
481
|
+
) }
|
|
482
|
+
{ hasError && playError && (
|
|
483
|
+
<div className="sandbox-play-error">
|
|
484
|
+
<div className="sandbox-play-error-title">Play Error</div>
|
|
485
|
+
<pre className="sandbox-play-error-message">{ playError.error.message }</pre>
|
|
486
|
+
</div>
|
|
487
|
+
) }
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
type DetailViewProps = {
|
|
494
|
+
component: ComponentScenarios
|
|
495
|
+
onBack: () => void
|
|
496
|
+
playingScenario: string | null
|
|
497
|
+
playError: PlayError | null
|
|
498
|
+
playKeys: Record<string, number>
|
|
499
|
+
onPlay: (
|
|
500
|
+
scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
|
|
501
|
+
getContainer: () => HTMLElement | null
|
|
502
|
+
) => void
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function DetailView ({
|
|
506
|
+
component,
|
|
507
|
+
onBack,
|
|
508
|
+
playingScenario,
|
|
509
|
+
playError,
|
|
510
|
+
playKeys,
|
|
511
|
+
onPlay,
|
|
512
|
+
}: DetailViewProps) {
|
|
513
|
+
const scenarioCount = component.scenarios.length
|
|
514
|
+
|
|
515
|
+
return (
|
|
516
|
+
<div className="sandbox-detail">
|
|
517
|
+
<button type="button" className="sandbox-detail-back" onClick={ onBack }>
|
|
518
|
+
<ArrowLeftIcon />
|
|
519
|
+
Back to components
|
|
520
|
+
</button>
|
|
521
|
+
|
|
522
|
+
<div className="sandbox-detail-header">
|
|
523
|
+
<div>
|
|
524
|
+
<div className="sandbox-detail-title-row">
|
|
525
|
+
<h1 className="sandbox-detail-title">{ component.name }</h1>
|
|
526
|
+
<span className="sandbox-detail-badge">
|
|
527
|
+
{ scenarioCount } { scenarioCount === 1 ? 'scenario' : 'scenarios' }
|
|
528
|
+
</span>
|
|
529
|
+
</div>
|
|
530
|
+
<p className="sandbox-detail-description">
|
|
531
|
+
Component scenarios with interactive preview and code view.
|
|
532
|
+
</p>
|
|
533
|
+
</div>
|
|
534
|
+
</div>
|
|
535
|
+
|
|
536
|
+
<div className="sandbox-scenarios">
|
|
537
|
+
{ component.scenarios.map((scenario) => (
|
|
538
|
+
<ScenarioCard
|
|
539
|
+
key={ scenario.name }
|
|
540
|
+
scenario={ scenario }
|
|
541
|
+
component={ component.component }
|
|
542
|
+
playingScenario={ playingScenario }
|
|
543
|
+
playError={ playError }
|
|
544
|
+
playKeys={ playKeys }
|
|
545
|
+
onPlay={ onPlay }
|
|
546
|
+
/>
|
|
547
|
+
)) }
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
type TabbedDetailViewProps = {
|
|
554
|
+
component: ComponentScenarios
|
|
555
|
+
onBack: () => void
|
|
556
|
+
playingScenario: string | null
|
|
557
|
+
playError: PlayError | null
|
|
558
|
+
playKeys: Record<string, number>
|
|
559
|
+
onPlay: (
|
|
560
|
+
scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
|
|
561
|
+
getContainer: () => HTMLElement | null
|
|
562
|
+
) => void
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function TabbedDetailView ({
|
|
566
|
+
component,
|
|
567
|
+
onBack,
|
|
568
|
+
playingScenario,
|
|
569
|
+
playError,
|
|
570
|
+
playKeys,
|
|
571
|
+
onPlay,
|
|
572
|
+
}: TabbedDetailViewProps) {
|
|
573
|
+
const [ activeTab, setActiveTab ] = useState(0)
|
|
574
|
+
const [ viewMode, setViewMode ] = useState<'preview' | 'code'>('preview')
|
|
575
|
+
const [ copied, setCopied ] = useState(false)
|
|
576
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
577
|
+
|
|
578
|
+
const activeScenario = component.scenarios[activeTab]
|
|
579
|
+
const scenarioCount = component.scenarios.length
|
|
580
|
+
const isPlaying = playingScenario === activeScenario?.name
|
|
581
|
+
const hasError = playError?.scenarioName === activeScenario?.name
|
|
582
|
+
|
|
583
|
+
const handleCopy = () => {
|
|
584
|
+
if (activeScenario?.code) {
|
|
585
|
+
navigator.clipboard.writeText(activeScenario.code)
|
|
586
|
+
setCopied(true)
|
|
587
|
+
setTimeout(() => setCopied(false), 2000)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return (
|
|
592
|
+
<div className="sandbox-detail-tabbed">
|
|
593
|
+
<button type="button" className="sandbox-detail-back" onClick={ onBack }>
|
|
594
|
+
<ArrowLeftIcon />
|
|
595
|
+
Back to components
|
|
596
|
+
</button>
|
|
597
|
+
|
|
598
|
+
<div className="sandbox-detail-header">
|
|
599
|
+
<div>
|
|
600
|
+
<div className="sandbox-detail-title-row">
|
|
601
|
+
<h1 className="sandbox-detail-title">{ component.name }</h1>
|
|
602
|
+
<span className="sandbox-badge-advanced">Advanced</span>
|
|
603
|
+
</div>
|
|
604
|
+
<p className="sandbox-detail-description">
|
|
605
|
+
Component scenarios with interactive preview and code view.
|
|
606
|
+
</p>
|
|
607
|
+
</div>
|
|
608
|
+
</div>
|
|
609
|
+
|
|
610
|
+
<div className="sandbox-tabbed-card">
|
|
611
|
+
{ /* Tabs Bar */ }
|
|
612
|
+
<div className="sandbox-tabs-bar">
|
|
613
|
+
<div className="sandbox-tabs-list">
|
|
614
|
+
{ component.scenarios.map((scenario, index) => (
|
|
615
|
+
<button
|
|
616
|
+
type="button"
|
|
617
|
+
key={ scenario.name }
|
|
618
|
+
className={ `sandbox-tab-item ${activeTab === index ? 'active' : ''}` }
|
|
619
|
+
onClick={ () => setActiveTab(index) }
|
|
620
|
+
>
|
|
621
|
+
{ scenario.name }
|
|
622
|
+
</button>
|
|
623
|
+
)) }
|
|
624
|
+
</div>
|
|
625
|
+
|
|
626
|
+
<div className="sandbox-tabs-actions">
|
|
627
|
+
{ activeScenario?.code && (
|
|
628
|
+
<button
|
|
629
|
+
type="button"
|
|
630
|
+
className={ `sandbox-scenario-btn sandbox-scenario-btn-toggle ${viewMode === 'code' ? 'active' : ''}` }
|
|
631
|
+
onClick={ () => setViewMode(viewMode === 'code' ? 'preview' : 'code') }
|
|
632
|
+
>
|
|
633
|
+
{ viewMode === 'code' ? (
|
|
634
|
+
<>
|
|
635
|
+
<EyeIcon />
|
|
636
|
+
Preview
|
|
637
|
+
</>
|
|
638
|
+
) : (
|
|
639
|
+
<>
|
|
640
|
+
<Code2Icon />
|
|
641
|
+
Code
|
|
642
|
+
</>
|
|
643
|
+
) }
|
|
644
|
+
</button>
|
|
645
|
+
) }
|
|
646
|
+
{ activeScenario?.code && (
|
|
647
|
+
<button
|
|
648
|
+
type="button"
|
|
649
|
+
className="sandbox-scenario-btn sandbox-scenario-btn-copy"
|
|
650
|
+
onClick={ handleCopy }
|
|
651
|
+
>
|
|
652
|
+
{ copied ? (
|
|
653
|
+
<>
|
|
654
|
+
<CheckIcon />
|
|
655
|
+
Copied
|
|
656
|
+
</>
|
|
657
|
+
) : (
|
|
658
|
+
<>
|
|
659
|
+
<CopyIcon />
|
|
660
|
+
Copy
|
|
661
|
+
</>
|
|
662
|
+
) }
|
|
663
|
+
</button>
|
|
664
|
+
) }
|
|
665
|
+
{ activeScenario?.play && (
|
|
666
|
+
<button
|
|
667
|
+
type="button"
|
|
668
|
+
className={ `sandbox-scenario-btn sandbox-scenario-btn-play ${hasError ? 'error' : ''}` }
|
|
669
|
+
onClick={ () => onPlay(activeScenario, () => containerRef.current) }
|
|
670
|
+
disabled={ isPlaying }
|
|
671
|
+
>
|
|
672
|
+
<PlayIcon />
|
|
673
|
+
{ isPlaying ? 'Playing...' : hasError ? 'Retry' : 'Play' }
|
|
674
|
+
</button>
|
|
675
|
+
) }
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
|
|
679
|
+
{ /* Content */ }
|
|
680
|
+
<div className="sandbox-tab-content">
|
|
681
|
+
{ viewMode === 'code' && activeScenario?.code ? (
|
|
682
|
+
<div className="sandbox-tab-code">
|
|
683
|
+
<pre>{ activeScenario.code }</pre>
|
|
684
|
+
</div>
|
|
685
|
+
) : (
|
|
686
|
+
<div className="sandbox-tab-preview" ref={ containerRef }>
|
|
687
|
+
{ activeScenario && (
|
|
688
|
+
<ErrorBoundary fallback={ RenderErrorFallback }>
|
|
689
|
+
<div key={ `${activeScenario.name}-${playKeys[activeScenario.name] ?? 0}` }>
|
|
690
|
+
{ createElement(
|
|
691
|
+
activeScenario.component ?? component.component,
|
|
692
|
+
activeScenario.props as Record<string, unknown>
|
|
693
|
+
) }
|
|
694
|
+
</div>
|
|
695
|
+
</ErrorBoundary>
|
|
696
|
+
) }
|
|
697
|
+
</div>
|
|
698
|
+
) }
|
|
699
|
+
{ hasError && playError && (
|
|
700
|
+
<div className="sandbox-play-error">
|
|
701
|
+
<div className="sandbox-play-error-title">Play Error</div>
|
|
702
|
+
<pre className="sandbox-play-error-message">{ playError.error.message }</pre>
|
|
703
|
+
</div>
|
|
704
|
+
) }
|
|
705
|
+
</div>
|
|
706
|
+
</div>
|
|
707
|
+
|
|
708
|
+
{ /* Navigation Dots */ }
|
|
709
|
+
{ scenarioCount > 1 && (
|
|
710
|
+
<div className="sandbox-nav-dots">
|
|
711
|
+
{ component.scenarios.map((scenario, index) => (
|
|
712
|
+
<button
|
|
713
|
+
type="button"
|
|
714
|
+
key={ scenario.name }
|
|
715
|
+
className={ `sandbox-nav-dot ${activeTab === index ? 'active' : 'inactive'}` }
|
|
716
|
+
onClick={ () => setActiveTab(index) }
|
|
717
|
+
aria-label={ `Go to scenario ${index + 1}` }
|
|
718
|
+
/>
|
|
719
|
+
)) }
|
|
720
|
+
</div>
|
|
721
|
+
) }
|
|
722
|
+
</div>
|
|
723
|
+
)
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function SandboxViewerContent ({
|
|
727
|
+
scenarios,
|
|
728
|
+
title = 'Northlight Component Library',
|
|
729
|
+
description = 'Northligt component library sandbox for testing and development.',
|
|
730
|
+
showHero = true,
|
|
731
|
+
}: SandboxViewerProps) {
|
|
732
|
+
const [ view, setView ] = useState<ViewState>({ type: 'grid' })
|
|
733
|
+
const [ selectedComponent, setSelectedComponent ] = useState<ComponentScenarios | null>(null)
|
|
734
|
+
const [ search, setSearch ] = useState('')
|
|
735
|
+
const [ playingScenario, setPlayingScenario ] = useState<string | null>(null)
|
|
736
|
+
const [ playError, setPlayError ] = useState<PlayError | null>(null)
|
|
737
|
+
const [ playKeys, setPlayKeys ] = useState<Record<string, number>>({})
|
|
738
|
+
|
|
739
|
+
useEffect(() => {
|
|
740
|
+
const selection = parsePath()
|
|
741
|
+
if (selection) {
|
|
742
|
+
const component = scenarios.find((c) => toSlug(c.name) === selection.componentSlug)
|
|
743
|
+
if (component) {
|
|
744
|
+
setSelectedComponent(component)
|
|
745
|
+
setView({ type: 'detail', componentSlug: selection.componentSlug })
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}, [ scenarios ])
|
|
749
|
+
|
|
123
750
|
useEffect(() => {
|
|
124
|
-
const handlePopState = () =>
|
|
751
|
+
const handlePopState = () => {
|
|
752
|
+
const selection = parsePath()
|
|
753
|
+
if (selection) {
|
|
754
|
+
const component = scenarios.find((c) => toSlug(c.name) === selection.componentSlug)
|
|
755
|
+
if (component) {
|
|
756
|
+
setSelectedComponent(component)
|
|
757
|
+
setView({ type: 'detail', componentSlug: selection.componentSlug })
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
setSelectedComponent(null)
|
|
762
|
+
setView({ type: 'grid' })
|
|
763
|
+
}
|
|
125
764
|
window.addEventListener('popstate', handlePopState)
|
|
126
765
|
return () => window.removeEventListener('popstate', handlePopState)
|
|
127
|
-
}, [])
|
|
128
|
-
|
|
129
|
-
const navigate = (componentSlug: string, scenarioSlug: string) => {
|
|
130
|
-
const path = toPath(componentSlug, scenarioSlug)
|
|
131
|
-
window.history.pushState(null, '', path)
|
|
132
|
-
setSelection({ componentSlug, scenarioSlug })
|
|
133
|
-
}
|
|
766
|
+
}, [ scenarios ])
|
|
134
767
|
|
|
135
|
-
const handleSelectComponent = (component: ComponentScenarios
|
|
768
|
+
const handleSelectComponent = (component: ComponentScenarios) => {
|
|
136
769
|
const componentSlug = toSlug(component.name)
|
|
137
770
|
const firstScenario = component.scenarios[0]
|
|
138
771
|
const scenarioSlug = firstScenario ? toSlug(firstScenario.name) : ''
|
|
139
|
-
|
|
772
|
+
window.history.pushState(null, '', toPath(componentSlug, scenarioSlug))
|
|
773
|
+
setSelectedComponent(component)
|
|
774
|
+
setView({ type: 'detail', componentSlug })
|
|
775
|
+
setPlayError(null)
|
|
140
776
|
}
|
|
141
777
|
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
778
|
+
const handleBack = () => {
|
|
779
|
+
window.history.pushState(null, '', '/')
|
|
780
|
+
setSelectedComponent(null)
|
|
781
|
+
setView({ type: 'grid' })
|
|
782
|
+
setPlayError(null)
|
|
145
783
|
}
|
|
146
784
|
|
|
147
|
-
const selectedComponent = selection
|
|
148
|
-
? scenarios.find((c) => toSlug(c.name) === selection.componentSlug)
|
|
149
|
-
: null
|
|
150
|
-
|
|
151
|
-
const selectedScenario =
|
|
152
|
-
selectedComponent && selection?.scenarioSlug
|
|
153
|
-
? selectedComponent.scenarios.find((s) => toSlug(s.name) === selection.scenarioSlug)
|
|
154
|
-
: selectedComponent?.scenarios[0]
|
|
155
|
-
|
|
156
|
-
const selectionKey =
|
|
157
|
-
selectedComponent && selectedScenario
|
|
158
|
-
? `${selectedComponent.name}/${selectedScenario.name}/${playKey}`
|
|
159
|
-
: ''
|
|
160
|
-
|
|
161
785
|
const handlePlay = async (
|
|
162
786
|
scenario: { name: string, play?: (context: PlayContext) => Promise<void> },
|
|
163
787
|
getContainer: () => HTMLElement | null
|
|
164
788
|
) => {
|
|
165
789
|
if (!scenario.play) return
|
|
166
790
|
|
|
167
|
-
// Clear previous error and reset component
|
|
168
791
|
setPlayError(null)
|
|
169
|
-
|
|
792
|
+
setPlayKeys((keys) => ({ ...keys, [scenario.name]: (keys[scenario.name] ?? 0) + 1 }))
|
|
170
793
|
|
|
171
|
-
// Wait for component to remount
|
|
172
794
|
await new Promise((resolve) => {
|
|
173
795
|
setTimeout(resolve, 50)
|
|
174
796
|
})
|
|
@@ -198,109 +820,74 @@ function SandboxViewerContent ({ scenarios }: SandboxViewerProps) {
|
|
|
198
820
|
setPlayingScenario(null)
|
|
199
821
|
}
|
|
200
822
|
|
|
823
|
+
const getLayoutMode = (): 'stacked' | 'tabbed' => {
|
|
824
|
+
if (!selectedComponent) return 'stacked'
|
|
825
|
+
if (selectedComponent.layout) return selectedComponent.layout
|
|
826
|
+
if (selectedComponent.name.toLowerCase() === 'tabs') return 'tabbed'
|
|
827
|
+
return 'stacked'
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
const layoutMode = getLayoutMode()
|
|
831
|
+
|
|
201
832
|
return (
|
|
202
833
|
<div className="sandbox-viewer">
|
|
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
|
-
<div className="sandbox-tabs">
|
|
248
|
-
{ selectedComponent.scenarios.map((scenario) => (
|
|
249
|
-
<button
|
|
250
|
-
type="button"
|
|
251
|
-
key={ scenario.name }
|
|
252
|
-
className={
|
|
253
|
-
selectedScenario?.name === scenario.name
|
|
254
|
-
? 'sandbox-tab selected'
|
|
255
|
-
: 'sandbox-tab'
|
|
256
|
-
}
|
|
257
|
-
onClick={ () => handleSelectScenario(scenario.name) }
|
|
258
|
-
>
|
|
259
|
-
{ scenario.name }
|
|
260
|
-
</button>
|
|
261
|
-
)) }
|
|
262
|
-
{ selectedScenario?.play && (
|
|
263
|
-
<button
|
|
264
|
-
type="button"
|
|
265
|
-
className={ `sandbox-play ${playError?.scenarioName === selectedScenario.name ? 'error' : ''}` }
|
|
266
|
-
onClick={ () => handlePlay(selectedScenario, () => containerRef.current) }
|
|
267
|
-
disabled={ playingScenario === selectedScenario.name }
|
|
268
|
-
>
|
|
269
|
-
{ playingScenario === selectedScenario.name
|
|
270
|
-
? 'Playing...'
|
|
271
|
-
: playError?.scenarioName === selectedScenario.name
|
|
272
|
-
? 'Failed'
|
|
273
|
-
: 'Play' }
|
|
274
|
-
</button>
|
|
275
|
-
) }
|
|
276
|
-
</div>
|
|
277
|
-
<div className="sandbox-preview" ref={ containerRef } key={ selectionKey }>
|
|
278
|
-
{ selectedScenario && (
|
|
279
|
-
<ErrorBoundary fallback={ RenderErrorFallback }>
|
|
280
|
-
{ createElement(
|
|
281
|
-
selectedScenario.component ?? selectedComponent.component,
|
|
282
|
-
selectedScenario.props as Record<string, unknown>
|
|
283
|
-
) }
|
|
284
|
-
</ErrorBoundary>
|
|
285
|
-
) }
|
|
286
|
-
{ playError?.scenarioName === selectedScenario?.name && playError && (
|
|
287
|
-
<div className="sandbox-play-error">
|
|
288
|
-
<div className="sandbox-play-error-title">Play Error</div>
|
|
289
|
-
<pre className="sandbox-play-error-message">{ playError.error.message }</pre>
|
|
290
|
-
</div>
|
|
291
|
-
) }
|
|
292
|
-
</div>
|
|
293
|
-
</>
|
|
294
|
-
)) }
|
|
295
|
-
</main>
|
|
834
|
+
<Sidebar
|
|
835
|
+
scenarios={ scenarios }
|
|
836
|
+
search={ search }
|
|
837
|
+
onSearchChange={ setSearch }
|
|
838
|
+
selectedComponent={ selectedComponent }
|
|
839
|
+
onSelectComponent={ handleSelectComponent }
|
|
840
|
+
/>
|
|
841
|
+
|
|
842
|
+
<div className="sandbox-content">
|
|
843
|
+
<main className="sandbox-main">
|
|
844
|
+
{ view.type === 'grid' && (
|
|
845
|
+
<GridView
|
|
846
|
+
scenarios={ scenarios }
|
|
847
|
+
search={ search }
|
|
848
|
+
onSelectComponent={ handleSelectComponent }
|
|
849
|
+
title={ title }
|
|
850
|
+
description={ description }
|
|
851
|
+
showHero={ showHero }
|
|
852
|
+
/>
|
|
853
|
+
) }
|
|
854
|
+
|
|
855
|
+
{ view.type === 'detail' && selectedComponent && (
|
|
856
|
+
layoutMode === 'tabbed' ? (
|
|
857
|
+
<TabbedDetailView
|
|
858
|
+
component={ selectedComponent }
|
|
859
|
+
onBack={ handleBack }
|
|
860
|
+
playingScenario={ playingScenario }
|
|
861
|
+
playError={ playError }
|
|
862
|
+
playKeys={ playKeys }
|
|
863
|
+
onPlay={ handlePlay }
|
|
864
|
+
/>
|
|
865
|
+
) : (
|
|
866
|
+
<DetailView
|
|
867
|
+
component={ selectedComponent }
|
|
868
|
+
onBack={ handleBack }
|
|
869
|
+
playingScenario={ playingScenario }
|
|
870
|
+
playError={ playError }
|
|
871
|
+
playKeys={ playKeys }
|
|
872
|
+
onPlay={ handlePlay }
|
|
873
|
+
/>
|
|
874
|
+
)
|
|
875
|
+
) }
|
|
876
|
+
</main>
|
|
877
|
+
</div>
|
|
296
878
|
</div>
|
|
297
879
|
)
|
|
298
880
|
}
|
|
299
881
|
|
|
300
|
-
function SandboxViewer ({ scenarios }: SandboxViewerProps) {
|
|
882
|
+
function SandboxViewer ({ scenarios, title, description, showHero }: SandboxViewerProps) {
|
|
301
883
|
return (
|
|
302
884
|
<MediatoolThemeProvider theme={ theme }>
|
|
303
|
-
<SandboxViewerContent
|
|
885
|
+
<SandboxViewerContent
|
|
886
|
+
scenarios={ scenarios }
|
|
887
|
+
title={ title }
|
|
888
|
+
description={ description }
|
|
889
|
+
showHero={ showHero }
|
|
890
|
+
/>
|
|
304
891
|
</MediatoolThemeProvider>
|
|
305
892
|
)
|
|
306
893
|
}
|