astryx-dracula 0.1.3 → 0.2.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 (94) hide show
  1. package/BRAND.md +1 -1
  2. package/README.md +2 -0
  3. package/USAGE.md +13 -0
  4. package/astryx-dracula.js +4 -4
  5. package/astryx.integration.mjs +3 -0
  6. package/package.json +4 -2
  7. package/templates/ai-chat-landing.template.mjs +6 -0
  8. package/templates/ai-chat-landing.tsx +476 -0
  9. package/templates/ai-chat.template.mjs +6 -0
  10. package/templates/ai-chat.tsx +762 -0
  11. package/templates/blank.template.mjs +6 -0
  12. package/templates/blank.tsx +16 -0
  13. package/templates/centered-hero.template.mjs +6 -0
  14. package/templates/centered-hero.tsx +188 -0
  15. package/templates/classic-gallery.template.mjs +6 -0
  16. package/templates/classic-gallery.tsx +181 -0
  17. package/templates/contact-form.template.mjs +6 -0
  18. package/templates/contact-form.tsx +292 -0
  19. package/templates/dashboard-portfolio.template.mjs +6 -0
  20. package/templates/dashboard-portfolio.tsx +800 -0
  21. package/templates/dashboard.template.mjs +6 -0
  22. package/templates/dashboard.tsx +774 -0
  23. package/templates/detail-page.template.mjs +6 -0
  24. package/templates/detail-page.tsx +695 -0
  25. package/templates/documentation-design.template.mjs +6 -0
  26. package/templates/documentation-design.tsx +751 -0
  27. package/templates/documentation-technical.template.mjs +6 -0
  28. package/templates/documentation-technical.tsx +292 -0
  29. package/templates/documentation.template.mjs +6 -0
  30. package/templates/documentation.tsx +347 -0
  31. package/templates/editor.template.mjs +6 -0
  32. package/templates/editor.tsx +959 -0
  33. package/templates/file-explorer.template.mjs +6 -0
  34. package/templates/file-explorer.tsx +577 -0
  35. package/templates/form-two-column.template.mjs +6 -0
  36. package/templates/form-two-column.tsx +314 -0
  37. package/templates/gallery-hero.template.mjs +6 -0
  38. package/templates/gallery-hero.tsx +287 -0
  39. package/templates/ide.template.mjs +6 -0
  40. package/templates/ide.tsx +533 -0
  41. package/templates/incident-console.template.mjs +6 -0
  42. package/templates/incident-console.tsx +584 -0
  43. package/templates/kanban-board.template.mjs +6 -0
  44. package/templates/kanban-board.tsx +746 -0
  45. package/templates/library.template.mjs +6 -0
  46. package/templates/library.tsx +489 -0
  47. package/templates/login-card.template.mjs +6 -0
  48. package/templates/login-card.tsx +200 -0
  49. package/templates/login-split.template.mjs +6 -0
  50. package/templates/login-split.tsx +318 -0
  51. package/templates/login-sso.template.mjs +6 -0
  52. package/templates/login-sso.tsx +277 -0
  53. package/templates/login.template.mjs +6 -0
  54. package/templates/login.tsx +98 -0
  55. package/templates/messaging-shell.template.mjs +6 -0
  56. package/templates/messaging-shell.tsx +675 -0
  57. package/templates/mixed-gallery.template.mjs +6 -0
  58. package/templates/mixed-gallery.tsx +190 -0
  59. package/templates/payment-form.template.mjs +6 -0
  60. package/templates/payment-form.tsx +990 -0
  61. package/templates/product-detail.template.mjs +6 -0
  62. package/templates/product-detail.tsx +366 -0
  63. package/templates/product-gallery.template.mjs +6 -0
  64. package/templates/product-gallery.tsx +173 -0
  65. package/templates/settings-dialog.template.mjs +6 -0
  66. package/templates/settings-dialog.tsx +920 -0
  67. package/templates/settings-sidebar.template.mjs +6 -0
  68. package/templates/settings-sidebar.tsx +952 -0
  69. package/templates/settings.template.mjs +6 -0
  70. package/templates/settings.tsx +235 -0
  71. package/templates/shell-nav.template.mjs +6 -0
  72. package/templates/shell-nav.tsx +306 -0
  73. package/templates/shell-side-nav.template.mjs +6 -0
  74. package/templates/shell-side-nav.tsx +238 -0
  75. package/templates/shell-top-nav.template.mjs +6 -0
  76. package/templates/shell-top-nav.tsx +243 -0
  77. package/templates/side-gallery.template.mjs +6 -0
  78. package/templates/side-gallery.tsx +179 -0
  79. package/templates/table-grouped.template.mjs +6 -0
  80. package/templates/table-grouped.tsx +1174 -0
  81. package/templates/table-page-chart.template.mjs +6 -0
  82. package/templates/table-page-chart.tsx +693 -0
  83. package/templates/table-page-heatmap-status.template.mjs +6 -0
  84. package/templates/table-page-heatmap-status.tsx +547 -0
  85. package/templates/table-page-shoe-store-heatmap.template.mjs +6 -0
  86. package/templates/table-page-shoe-store-heatmap.tsx +1038 -0
  87. package/templates/table-page.template.mjs +6 -0
  88. package/templates/table-page.tsx +445 -0
  89. package/templates/table.template.mjs +6 -0
  90. package/templates/table.tsx +84 -0
  91. package/templates/theme-showcase.template.mjs +6 -0
  92. package/templates/theme-showcase.tsx +1282 -0
  93. package/theme.css +4 -4
  94. package/tokens.css +4 -4
@@ -0,0 +1,959 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {useState, useCallback, type CSSProperties} from 'react';
4
+ import {useMediaQuery} from '@astryxdesign/core/hooks';
5
+ import {Button} from '@astryxdesign/core/Button';
6
+ import {Card} from '@astryxdesign/core/Card';
7
+ import {Center} from '@astryxdesign/core/Center';
8
+ import {Dialog} from '@astryxdesign/core/Dialog';
9
+ import {Divider} from '@astryxdesign/core/Divider';
10
+ import {EmptyState} from '@astryxdesign/core/EmptyState';
11
+ import {
12
+ HStack,
13
+ VStack,
14
+ Layout,
15
+ LayoutContent,
16
+ LayoutHeader,
17
+ LayoutPanel,
18
+ } from '@astryxdesign/core/Layout';
19
+ import {Icon} from '@astryxdesign/core/Icon';
20
+ import {List, ListItem} from '@astryxdesign/core/List';
21
+ import {Table, proportional, pixel} from '@astryxdesign/core/Table';
22
+ import type {TableColumn} from '@astryxdesign/core/Table';
23
+ import {Section} from '@astryxdesign/core/Section';
24
+ import {
25
+ SegmentedControl,
26
+ SegmentedControlItem,
27
+ } from '@astryxdesign/core/SegmentedControl';
28
+ import {Selector} from '@astryxdesign/core/Selector';
29
+ import {TabList, Tab} from '@astryxdesign/core/TabList';
30
+ import {Text, Heading} from '@astryxdesign/core/Text';
31
+ import {TextArea} from '@astryxdesign/core/TextArea';
32
+ import {TextInput} from '@astryxdesign/core/TextInput';
33
+ import {Toolbar} from '@astryxdesign/core/Toolbar';
34
+ import {
35
+ LayoutGrid,
36
+ FileText,
37
+ Image,
38
+ MousePointerClick,
39
+ Columns,
40
+ Sparkles,
41
+ Megaphone,
42
+ Trash2,
43
+ ChevronUp,
44
+ ChevronDown,
45
+ Monitor,
46
+ Tablet,
47
+ Smartphone,
48
+ Eye,
49
+ SlidersHorizontal,
50
+ X,
51
+ CirclePlus,
52
+ ShoppingBag,
53
+ ShoppingCart,
54
+ Banknote,
55
+ CarFront,
56
+ CirclePlay,
57
+ Ellipsis,
58
+ Lock,
59
+ } from 'lucide-react';
60
+ import {Spinner} from '@astryxdesign/core/Spinner';
61
+
62
+ type BlockType =
63
+ 'hero' | 'text' | 'image' | 'button' | 'cards' | 'features' | 'cta';
64
+
65
+ interface Block {
66
+ id: string;
67
+ type: BlockType;
68
+ label: string;
69
+ props: Record<string, unknown>;
70
+ }
71
+
72
+ type ViewportSize = 'desktop' | 'tablet' | 'phone';
73
+ type SidebarTab = 'blocks' | 'properties';
74
+ type IconComponent = React.ComponentType<React.SVGProps<SVGSVGElement>>;
75
+
76
+ const BLOCK_META: Record<BlockType, {label: string; icon: IconComponent}> = {
77
+ hero: {label: 'Hero', icon: LayoutGrid},
78
+ text: {label: 'Text', icon: FileText},
79
+ image: {label: 'Image', icon: Image},
80
+ button: {label: 'Button', icon: MousePointerClick},
81
+ cards: {label: 'Cards', icon: Columns},
82
+ features: {label: 'Features', icon: Sparkles},
83
+ cta: {label: 'CTA', icon: Megaphone},
84
+ };
85
+
86
+ type Transaction = {
87
+ id: string;
88
+ name: string;
89
+ category: string;
90
+ date: string;
91
+ amount: string;
92
+ isPositive?: boolean;
93
+ };
94
+
95
+ const CATEGORY_ICONS: Record<string, IconComponent> = {
96
+ 'Food & Drink': ShoppingBag,
97
+ Groceries: ShoppingCart,
98
+ Income: Banknote,
99
+ Transport: CarFront,
100
+ Entertainment: CirclePlay,
101
+ };
102
+
103
+ const TRANSACTION_COLUMNS: TableColumn<Transaction>[] = [
104
+ {
105
+ key: 'name',
106
+ header: 'Transaction',
107
+ width: proportional(2, {minWidth: 170}),
108
+ renderCell: (item: Transaction) => (
109
+ <HStack gap={3} vAlign="center">
110
+ <Icon icon={CATEGORY_ICONS[item.category] || Sparkles} />
111
+ <VStack gap={0}>
112
+ <Text type="label" weight="semibold">
113
+ {item.name}
114
+ </Text>
115
+ <Text type="supporting" color="secondary">
116
+ {item.category}
117
+ </Text>
118
+ </VStack>
119
+ </HStack>
120
+ ),
121
+ },
122
+ {
123
+ key: 'date',
124
+ header: 'Date',
125
+ width: proportional(1, {minWidth: 100}),
126
+ renderCell: (item: Transaction) => (
127
+ <Text type="body" color="secondary" hasTabularNumbers>
128
+ {item.date}
129
+ </Text>
130
+ ),
131
+ },
132
+ {
133
+ key: 'amount',
134
+ header: 'Amount',
135
+ width: proportional(1, {minWidth: 90}),
136
+ renderCell: (item: Transaction) => (
137
+ <Text type="label" weight="semibold" hasTabularNumbers>
138
+ {item.amount}
139
+ </Text>
140
+ ),
141
+ },
142
+ {
143
+ key: 'actions',
144
+ header: '',
145
+ width: pixel(44),
146
+ renderCell: () => (
147
+ <Button
148
+ label="More"
149
+ icon={<Icon icon={Ellipsis} size="sm" />}
150
+ variant="ghost"
151
+ size="sm"
152
+ isIconOnly
153
+ />
154
+ ),
155
+ },
156
+ ];
157
+
158
+ const VIEWPORT_MAX: Record<ViewportSize, number> = {
159
+ desktop: 960,
160
+ tablet: 768,
161
+ phone: 375,
162
+ };
163
+
164
+ let nextId = 5;
165
+ function uid() {
166
+ return String(nextId++);
167
+ }
168
+
169
+ const DEFAULT_BLOCKS: Block[] = [
170
+ {
171
+ id: '2',
172
+ type: 'features',
173
+ label: 'Recent Transactions',
174
+ props: {
175
+ heading: 'Recent Transactions',
176
+ description: 'Your latest account activity.',
177
+ items: [
178
+ {
179
+ id: 't1',
180
+ name: 'Midnight Oil Coffee',
181
+ category: 'Food & Drink',
182
+ date: 'Today, 10:24 AM',
183
+ amount: '-$6.50',
184
+ },
185
+ {
186
+ id: 't2',
187
+ name: 'Moonlit Market',
188
+ category: 'Groceries',
189
+ date: 'Yesterday',
190
+ amount: '-$142.30',
191
+ },
192
+ {
193
+ id: 't3',
194
+ name: 'Night Shift Payout',
195
+ category: 'Income',
196
+ date: 'Oct 12',
197
+ amount: '+$4,200.00',
198
+ isPositive: true,
199
+ },
200
+ {
201
+ id: 't4',
202
+ name: 'Night Owl Rides',
203
+ category: 'Transport',
204
+ date: 'Oct 11',
205
+ amount: '-$24.10',
206
+ },
207
+ {
208
+ id: 't5',
209
+ name: 'Cryptflix Subscription',
210
+ category: 'Entertainment',
211
+ date: 'Oct 10',
212
+ amount: '-$19.99',
213
+ },
214
+ ],
215
+ },
216
+ },
217
+ {
218
+ id: '3',
219
+ type: 'text',
220
+ label: 'Syncing State',
221
+ props: {
222
+ heading: 'Syncing your accounts',
223
+ description:
224
+ "We're pulling in your latest transactions.\nThis usually takes a few seconds.",
225
+ buttonLabel: 'Cancel',
226
+ },
227
+ },
228
+ {
229
+ id: '4',
230
+ type: 'cta',
231
+ label: 'Trust Notice',
232
+ props: {
233
+ heading: 'Adding devices from people you trust',
234
+ description:
235
+ "When you approve a request, you grant someone full access to your account. They'll be able to change reservations and send messages on your behalf.",
236
+ },
237
+ },
238
+ ];
239
+
240
+ function defaultProps(type: BlockType): Record<string, unknown> {
241
+ switch (type) {
242
+ case 'hero':
243
+ return {
244
+ heading: 'New Hero',
245
+ subheading: 'Subtitle goes here',
246
+ buttonLabel: 'Click Me',
247
+ alignment: 'center',
248
+ };
249
+ case 'text':
250
+ return {content: 'Enter your text here.'};
251
+ case 'image':
252
+ return {};
253
+ case 'button':
254
+ return {label: 'Button', variant: 'primary', size: 'md'};
255
+ case 'cards':
256
+ return {
257
+ cards: [
258
+ {
259
+ title: 'Pricing',
260
+ description: 'Flexible plans for every team size.',
261
+ },
262
+ {
263
+ title: 'Support',
264
+ description: 'Get help whenever you need it.',
265
+ },
266
+ ],
267
+ };
268
+ case 'features':
269
+ return {
270
+ heading: 'Activity',
271
+ description: '',
272
+ items: [
273
+ {
274
+ id: 't1',
275
+ name: 'New Item',
276
+ category: 'General',
277
+ date: 'Today',
278
+ amount: '$0.00',
279
+ },
280
+ ],
281
+ };
282
+ case 'cta':
283
+ return {
284
+ heading: 'Call to Action',
285
+ description: 'Description text',
286
+ primaryLabel: 'Primary',
287
+ secondaryLabel: 'Secondary',
288
+ };
289
+ }
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Styles
294
+ // ---------------------------------------------------------------------------
295
+ // The layout (sidebar + scrollable canvas, full height) is all Layout +
296
+ // LayoutPanel now. The few remaining styles are things Astryx has no prop for:
297
+ // the responsive canvas max-width, a selection ring on the active block card,
298
+ // and the circular icon chip's surface.
299
+
300
+ // Fill the window. Layout height="fill" is height:100%, which only resolves
301
+ // against a definite height — and the host's <html>/<body> don't set one, so
302
+ // the layout anchors a definite viewport height itself. No background; the
303
+ // host owns the page surface.
304
+ const pageStyle: CSSProperties = {height: '100dvh'};
305
+ // Canvas reflows to the chosen viewport width; VStack has no maxWidth prop.
306
+ const canvasStyle = (maxWidth: number): CSSProperties => ({
307
+ maxWidth,
308
+ width: '100%',
309
+ marginInline: 'auto',
310
+ });
311
+ const clickable: CSSProperties = {
312
+ cursor: 'pointer',
313
+ };
314
+ // Selection ring on the active block — Card has no `isSelected` state.
315
+ const selectedCard: CSSProperties = {
316
+ outline: '2px solid',
317
+ outlineColor: 'var(--color-border-blue)',
318
+ outlineOffset: -2,
319
+ };
320
+ // Circular muted chip behind the CTA icon — Center handles the centering
321
+ // and sizing; only the surface (radius + fill) needs custom CSS.
322
+ const iconCircle: CSSProperties = {
323
+ borderRadius: '50%',
324
+ backgroundColor: 'var(--color-background-muted)',
325
+ };
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Properties Form
329
+ // ---------------------------------------------------------------------------
330
+
331
+ function PropertiesForm({
332
+ block,
333
+ onUpdate,
334
+ }: {
335
+ block: Block;
336
+ onUpdate: (key: string, value: unknown) => void;
337
+ }) {
338
+ const {type, props} = block;
339
+
340
+ switch (type) {
341
+ case 'hero':
342
+ return (
343
+ <VStack gap={4}>
344
+ <TextInput
345
+ label="Heading"
346
+ value={(props.heading as string) ?? ''}
347
+ onChange={(v: string) => onUpdate('heading', v)}
348
+ />
349
+ <TextArea
350
+ label="Subheading"
351
+ value={(props.subheading as string) ?? ''}
352
+ onChange={(v: string) => onUpdate('subheading', v)}
353
+ />
354
+ <TextInput
355
+ label="Button Label"
356
+ value={(props.buttonLabel as string) ?? ''}
357
+ onChange={(v: string) => onUpdate('buttonLabel', v)}
358
+ />
359
+ <Selector
360
+ label="Alignment"
361
+ value={(props.alignment as string) ?? 'center'}
362
+ onChange={(v: string) => onUpdate('alignment', v)}
363
+ options={[
364
+ {label: 'Left', value: 'left'},
365
+ {label: 'Center', value: 'center'},
366
+ {label: 'Right', value: 'right'},
367
+ ]}
368
+ />
369
+ </VStack>
370
+ );
371
+
372
+ case 'text':
373
+ return (
374
+ <VStack gap={4}>
375
+ <TextInput
376
+ label="Heading"
377
+ value={(props.heading as string) ?? ''}
378
+ onChange={(v: string) => onUpdate('heading', v)}
379
+ />
380
+ <TextArea
381
+ label="Description"
382
+ value={(props.description as string) ?? ''}
383
+ onChange={(v: string) => onUpdate('description', v)}
384
+ />
385
+ <TextInput
386
+ label="Button Label"
387
+ value={(props.buttonLabel as string) ?? ''}
388
+ onChange={(v: string) => onUpdate('buttonLabel', v)}
389
+ />
390
+ </VStack>
391
+ );
392
+
393
+ case 'features':
394
+ case 'cta':
395
+ return (
396
+ <VStack gap={4}>
397
+ <TextInput
398
+ label="Heading"
399
+ value={(props.heading as string) ?? ''}
400
+ onChange={(v: string) => onUpdate('heading', v)}
401
+ />
402
+ <TextArea
403
+ label="Description"
404
+ value={(props.description as string) ?? ''}
405
+ onChange={(v: string) => onUpdate('description', v)}
406
+ />
407
+ </VStack>
408
+ );
409
+
410
+ case 'button':
411
+ return (
412
+ <VStack gap={4}>
413
+ <TextInput
414
+ label="Label"
415
+ value={(props.label as string) ?? ''}
416
+ onChange={(v: string) => onUpdate('label', v)}
417
+ />
418
+ <Selector
419
+ label="Variant"
420
+ value={(props.variant as string) ?? 'primary'}
421
+ onChange={(v: string) => onUpdate('variant', v)}
422
+ options={[
423
+ {label: 'Primary', value: 'primary'},
424
+ {label: 'Secondary', value: 'secondary'},
425
+ {label: 'Ghost', value: 'ghost'},
426
+ ]}
427
+ />
428
+ <Selector
429
+ label="Size"
430
+ value={(props.size as string) ?? 'md'}
431
+ onChange={(v: string) => onUpdate('size', v)}
432
+ options={[
433
+ {label: 'Small', value: 'sm'},
434
+ {label: 'Medium', value: 'md'},
435
+ {label: 'Large', value: 'lg'},
436
+ ]}
437
+ />
438
+ </VStack>
439
+ );
440
+
441
+ default:
442
+ return <EmptyState title="No configurable properties" isCompact />;
443
+ }
444
+ }
445
+
446
+ // ---------------------------------------------------------------------------
447
+ // Block Preview
448
+ // ---------------------------------------------------------------------------
449
+
450
+ function BlockPreview({
451
+ block,
452
+ isSelected,
453
+ onSelect,
454
+ }: {
455
+ block: Block;
456
+ isSelected: boolean;
457
+ onSelect: () => void;
458
+ }) {
459
+ const {type, props} = block;
460
+ const cardStyle: CSSProperties = {
461
+ ...clickable,
462
+ ...(isSelected ? selectedCard : null),
463
+ };
464
+
465
+ switch (type) {
466
+ case 'hero':
467
+ return (
468
+ <Card padding={6} style={cardStyle} onClick={onSelect}>
469
+ <VStack gap={4}>
470
+ <Heading level={2}>
471
+ {(props.heading as string) || 'Hero Heading'}
472
+ </Heading>
473
+ <Text type="supporting" color="secondary">
474
+ {(props.subheading as string) || 'Subtitle text goes here'}
475
+ </Text>
476
+ {(props.buttonLabel as string) && (
477
+ <Button label={props.buttonLabel as string} />
478
+ )}
479
+ </VStack>
480
+ </Card>
481
+ );
482
+
483
+ case 'text':
484
+ if (props.heading) {
485
+ return (
486
+ <Card padding={6} style={cardStyle} onClick={onSelect}>
487
+ <EmptyState
488
+ title={props.heading as string}
489
+ description={props.description as string}
490
+ icon={<Spinner />}
491
+ actions={
492
+ (props.buttonLabel as string) ? (
493
+ <Button
494
+ label={props.buttonLabel as string}
495
+ variant="secondary"
496
+ />
497
+ ) : undefined
498
+ }
499
+ />
500
+ </Card>
501
+ );
502
+ }
503
+ return (
504
+ <Card style={cardStyle} onClick={onSelect}>
505
+ <Text type="body">
506
+ {(props.content as string) || 'Text content goes here'}
507
+ </Text>
508
+ </Card>
509
+ );
510
+
511
+ case 'image':
512
+ return (
513
+ <Card style={cardStyle} onClick={onSelect}>
514
+ <EmptyState
515
+ title="Image Block"
516
+ description="Drop an image or enter a URL"
517
+ icon={<Icon icon={Image} />}
518
+ isCompact
519
+ />
520
+ </Card>
521
+ );
522
+
523
+ case 'button':
524
+ return (
525
+ <Card padding={6} style={cardStyle} onClick={onSelect}>
526
+ <Center>
527
+ <Button
528
+ label={(props.label as string) || 'Button'}
529
+ variant={
530
+ (props.variant as 'primary' | 'secondary' | 'ghost') ||
531
+ 'primary'
532
+ }
533
+ size={(props.size as 'sm' | 'md' | 'lg') || 'md'}
534
+ />
535
+ </Center>
536
+ </Card>
537
+ );
538
+
539
+ case 'features': {
540
+ const items = (props.items as Transaction[]) || [];
541
+ return (
542
+ <Card padding={6} style={cardStyle} onClick={onSelect}>
543
+ <VStack gap={4}>
544
+ <HStack gap={3} vAlign="start" hAlign="between">
545
+ <VStack gap={1}>
546
+ <Heading level={3}>
547
+ {(props.heading as string) || 'Features'}
548
+ </Heading>
549
+ {(props.description as string) && (
550
+ <Text type="supporting" color="secondary">
551
+ {props.description as string}
552
+ </Text>
553
+ )}
554
+ </VStack>
555
+ <Button label="View All" variant="secondary" size="sm" />
556
+ </HStack>
557
+ <Table
558
+ data={items}
559
+ columns={TRANSACTION_COLUMNS}
560
+ idKey="id"
561
+ hasHover
562
+ textOverflow="truncate"
563
+ />
564
+ </VStack>
565
+ </Card>
566
+ );
567
+ }
568
+
569
+ case 'cards': {
570
+ const cardItems =
571
+ (props.cards as Array<{title: string; description: string}>) || [];
572
+ return (
573
+ <Card style={cardStyle} onClick={onSelect}>
574
+ <VStack gap={4}>
575
+ <Heading level={3}>Cards</Heading>
576
+ <Divider />
577
+ <List density="balanced" hasDividers={false}>
578
+ {cardItems.map((card, i) => (
579
+ <ListItem
580
+ key={i}
581
+ label={card.title}
582
+ description={card.description}
583
+ />
584
+ ))}
585
+ </List>
586
+ </VStack>
587
+ </Card>
588
+ );
589
+ }
590
+
591
+ case 'cta':
592
+ return (
593
+ <Card padding={6} style={cardStyle} onClick={onSelect}>
594
+ <HStack gap={4} vAlign="start">
595
+ <Center width={40} height={40} style={iconCircle}>
596
+ <Icon icon={Lock} color="secondary" />
597
+ </Center>
598
+ <VStack gap={1}>
599
+ <Text type="label" weight="semibold">
600
+ {(props.heading as string) || 'Notice'}
601
+ </Text>
602
+ <Text type="supporting" color="secondary">
603
+ {(props.description as string) || 'Description text'}
604
+ </Text>
605
+ </VStack>
606
+ </HStack>
607
+ </Card>
608
+ );
609
+
610
+ default:
611
+ return null;
612
+ }
613
+ }
614
+
615
+ // ---------------------------------------------------------------------------
616
+ // Editor Page
617
+ // ---------------------------------------------------------------------------
618
+
619
+ export default function PageEditor() {
620
+ const [blocks, setBlocks] = useState<Block[]>(DEFAULT_BLOCKS);
621
+ const [selectedId, setSelectedId] = useState<string | null>(null);
622
+ const [sidebarTab, setSidebarTab] = useState<SidebarTab>('blocks');
623
+ const [pageTitle, setPageTitle] = useState('Page Editor');
624
+ const [isEditingTitle, setIsEditingTitle] = useState(false);
625
+ const [viewport, setViewport] = useState<ViewportSize>('desktop');
626
+ // Mobile only: the customizations (tabs + Add Block/Layers) open in a
627
+ // fullscreen dialog over the preview, since there's no room to dock them.
628
+ const [isCustomizeOpen, setIsCustomizeOpen] = useState(false);
629
+
630
+ // On phones the editor stacks: the panel sits in the header slot (full width,
631
+ // above the canvas) showing just its toolbar, so the canvas isn't crushed
632
+ // beside a 320px sidebar. On desktop the full panel (tabs + lists) shows.
633
+ const isMobile = useMediaQuery('(max-width: 768px)');
634
+
635
+ const selectedBlock = blocks.find(b => b.id === selectedId) ?? null;
636
+
637
+ const updateBlockProp = useCallback(
638
+ (id: string, key: string, value: unknown) => {
639
+ setBlocks(prev =>
640
+ prev.map(b =>
641
+ b.id === id ? {...b, props: {...b.props, [key]: value}} : b,
642
+ ),
643
+ );
644
+ },
645
+ [],
646
+ );
647
+
648
+ const moveBlock = useCallback((id: string, dir: -1 | 1) => {
649
+ setBlocks(prev => {
650
+ const idx = prev.findIndex(b => b.id === id);
651
+ if (idx < 0) {
652
+ return prev;
653
+ }
654
+ const target = idx + dir;
655
+ if (target < 0 || target >= prev.length) {
656
+ return prev;
657
+ }
658
+ const next = [...prev];
659
+ [next[idx], next[target]] = [next[target], next[idx]];
660
+ return next;
661
+ });
662
+ }, []);
663
+
664
+ const deleteBlock = useCallback(
665
+ (id: string) => {
666
+ setBlocks(prev => prev.filter(b => b.id !== id));
667
+ if (selectedId === id) {
668
+ setSelectedId(null);
669
+ }
670
+ },
671
+ [selectedId],
672
+ );
673
+
674
+ const addBlock = useCallback((type: BlockType) => {
675
+ const id = uid();
676
+ const newBlock: Block = {
677
+ id,
678
+ type,
679
+ label: BLOCK_META[type].label,
680
+ props: defaultProps(type),
681
+ };
682
+ setBlocks(prev => [...prev, newBlock]);
683
+ setSelectedId(id);
684
+ setSidebarTab('properties');
685
+ }, []);
686
+
687
+ const selectBlock = useCallback(
688
+ (id: string) => {
689
+ setSelectedId(prev => (prev === id ? null : id));
690
+ setSidebarTab('properties');
691
+ // On mobile, tapping a block on the canvas opens its properties dialog.
692
+ if (isMobile) {
693
+ setIsCustomizeOpen(true);
694
+ }
695
+ },
696
+ [isMobile],
697
+ );
698
+
699
+ // --- sidebar content ---
700
+
701
+ const blocksTabContent = (
702
+ <VStack gap={2}>
703
+ <VStack gap={1}>
704
+ <Heading level={4}>Add Block</Heading>
705
+ <List density="balanced" hasDividers={false}>
706
+ {(Object.keys(BLOCK_META) as BlockType[]).map(type => (
707
+ <ListItem
708
+ key={type}
709
+ label={BLOCK_META[type].label}
710
+ startContent={
711
+ <Icon icon={BLOCK_META[type].icon} color="secondary" />
712
+ }
713
+ onClick={() => addBlock(type)}
714
+ />
715
+ ))}
716
+ </List>
717
+ </VStack>
718
+
719
+ <VStack gap={1}>
720
+ <Heading level={4}>Layers</Heading>
721
+ <List density="balanced" hasDividers={false}>
722
+ {blocks.map(block => (
723
+ <ListItem
724
+ key={block.id}
725
+ label={block.label}
726
+ isSelected={block.id === selectedId}
727
+ onClick={() => selectBlock(block.id)}
728
+ startContent={
729
+ <Icon icon={BLOCK_META[block.type].icon} color="secondary" />
730
+ }
731
+ endContent={
732
+ <HStack gap={1}>
733
+ <Button
734
+ label="Move up"
735
+ icon={<Icon icon={ChevronUp} size="sm" />}
736
+ variant="ghost"
737
+ size="sm"
738
+ onClick={(e: React.MouseEvent) => {
739
+ e.stopPropagation();
740
+ moveBlock(block.id, -1);
741
+ }}
742
+ isIconOnly
743
+ />
744
+ <Button
745
+ label="Move down"
746
+ icon={<Icon icon={ChevronDown} size="sm" />}
747
+ variant="ghost"
748
+ size="sm"
749
+ onClick={(e: React.MouseEvent) => {
750
+ e.stopPropagation();
751
+ moveBlock(block.id, 1);
752
+ }}
753
+ isIconOnly
754
+ />
755
+ <Button
756
+ label="Delete"
757
+ icon={<Icon icon={Trash2} size="sm" />}
758
+ variant="ghost"
759
+ size="sm"
760
+ onClick={(e: React.MouseEvent) => {
761
+ e.stopPropagation();
762
+ deleteBlock(block.id);
763
+ }}
764
+ isIconOnly
765
+ />
766
+ </HStack>
767
+ }
768
+ />
769
+ ))}
770
+ </List>
771
+ </VStack>
772
+ </VStack>
773
+ );
774
+
775
+ const propertiesTabContent = selectedBlock ? (
776
+ <PropertiesForm
777
+ block={selectedBlock}
778
+ onUpdate={(key, value) => updateBlockProp(selectedBlock.id, key, value)}
779
+ />
780
+ ) : (
781
+ <EmptyState
782
+ title="No block selected"
783
+ description="Select a block to edit its properties"
784
+ isCompact
785
+ />
786
+ );
787
+
788
+ // Tabs + the active tab's content. Shown inline in the sidebar on desktop,
789
+ // and inside a fullscreen dialog on mobile.
790
+ const editingContent = (
791
+ <VStack gap={4}>
792
+ <VStack gap={0}>
793
+ <TabList
794
+ layout="fill"
795
+ value={sidebarTab}
796
+ onChange={(v: string) => setSidebarTab(v as SidebarTab)}>
797
+ <Tab value="blocks" label="Blocks" />
798
+ <Tab value="properties" label="Properties" />
799
+ </TabList>
800
+ <Divider />
801
+ </VStack>
802
+ <Section variant="transparent" padding={4}>
803
+ {sidebarTab === 'blocks' ? blocksTabContent : propertiesTabContent}
804
+ </Section>
805
+ </VStack>
806
+ );
807
+
808
+ const sidebar = (
809
+ <LayoutPanel
810
+ hasDivider={!isMobile}
811
+ padding={0}
812
+ style={{width: isMobile ? '100%' : 320, flexShrink: 0}}>
813
+ <VStack gap={4}>
814
+ {/* Panel Header */}
815
+ <Section variant="transparent" padding={4}>
816
+ {isMobile ? (
817
+ // Mobile: the title, an Edit button that opens the customizations
818
+ // dialog, and the primary action.
819
+ <HStack gap={3} vAlign="center" hAlign="between">
820
+ <Heading level={2}>{pageTitle}</Heading>
821
+ <HStack gap={2} vAlign="center">
822
+ <Button
823
+ label="Edit"
824
+ icon={<Icon icon={SlidersHorizontal} size="sm" />}
825
+ variant="ghost"
826
+ isIconOnly
827
+ onClick={() => setIsCustomizeOpen(true)}
828
+ />
829
+ <Button label="Publish" variant="primary" />
830
+ </HStack>
831
+ </HStack>
832
+ ) : (
833
+ <VStack gap={4}>
834
+ {isEditingTitle ? (
835
+ <TextInput
836
+ label="Page title"
837
+ isLabelHidden
838
+ value={pageTitle}
839
+ onChange={setPageTitle}
840
+ onKeyDown={(e: React.KeyboardEvent) => {
841
+ if (e.key === 'Enter') {
842
+ setIsEditingTitle(false);
843
+ }
844
+ }}
845
+ hasAutoFocus
846
+ onBlur={() => setIsEditingTitle(false)}
847
+ />
848
+ ) : (
849
+ <Heading level={2}>{pageTitle}</Heading>
850
+ )}
851
+
852
+ <Toolbar
853
+ label="Viewport and actions"
854
+ startContent={
855
+ <SegmentedControl
856
+ label="Viewport size"
857
+ value={viewport}
858
+ onChange={(v: string) => setViewport(v as ViewportSize)}>
859
+ <SegmentedControlItem
860
+ value="desktop"
861
+ label="Desktop"
862
+ icon={<Icon icon={Monitor} size="sm" />}
863
+ isLabelHidden
864
+ />
865
+ <SegmentedControlItem
866
+ value="tablet"
867
+ label="Tablet"
868
+ icon={<Icon icon={Tablet} size="sm" />}
869
+ isLabelHidden
870
+ />
871
+ <SegmentedControlItem
872
+ value="phone"
873
+ label="Phone"
874
+ icon={<Icon icon={Smartphone} size="sm" />}
875
+ isLabelHidden
876
+ />
877
+ </SegmentedControl>
878
+ }
879
+ endContent={
880
+ <HStack gap={2}>
881
+ <Button
882
+ label="Preview"
883
+ icon={<Icon icon={Eye} size="sm" />}
884
+ variant="ghost"
885
+ isIconOnly
886
+ />
887
+ <Button label="Publish" variant="primary" />
888
+ </HStack>
889
+ }
890
+ />
891
+ </VStack>
892
+ )}
893
+ </Section>
894
+
895
+ {!isMobile && editingContent}
896
+ </VStack>
897
+ </LayoutPanel>
898
+ );
899
+
900
+ return (
901
+ <>
902
+ <Layout
903
+ style={pageStyle}
904
+ height="fill"
905
+ header={isMobile ? sidebar : undefined}
906
+ start={isMobile ? undefined : sidebar}
907
+ content={
908
+ <LayoutContent padding={8}>
909
+ <VStack gap={4} style={canvasStyle(VIEWPORT_MAX[viewport])}>
910
+ {blocks.length > 0 ? (
911
+ blocks.map(block => (
912
+ <BlockPreview
913
+ key={block.id}
914
+ block={block}
915
+ isSelected={block.id === selectedId}
916
+ onSelect={() => selectBlock(block.id)}
917
+ />
918
+ ))
919
+ ) : (
920
+ <EmptyState
921
+ title="No blocks yet"
922
+ description="Add blocks from the sidebar to start building your page"
923
+ icon={<Icon icon={CirclePlus} />}
924
+ />
925
+ )}
926
+ </VStack>
927
+ </LayoutContent>
928
+ }
929
+ />
930
+
931
+ {/* Mobile: customizations open in a fullscreen dialog over the preview. */}
932
+ <Dialog
933
+ isOpen={isMobile && isCustomizeOpen}
934
+ onOpenChange={setIsCustomizeOpen}
935
+ variant="fullscreen"
936
+ purpose="info"
937
+ padding={0}>
938
+ <Layout
939
+ height="fill"
940
+ header={
941
+ <LayoutHeader hasDivider>
942
+ <HStack gap={3} vAlign="center" hAlign="between">
943
+ <Heading level={3}>Customize</Heading>
944
+ <Button
945
+ label="Close"
946
+ icon={<Icon icon={X} size="sm" />}
947
+ variant="ghost"
948
+ isIconOnly
949
+ onClick={() => setIsCustomizeOpen(false)}
950
+ />
951
+ </HStack>
952
+ </LayoutHeader>
953
+ }
954
+ content={<LayoutContent padding={0}>{editingContent}</LayoutContent>}
955
+ />
956
+ </Dialog>
957
+ </>
958
+ );
959
+ }