@siteable/core 0.1.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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +4 -0
  3. package/README.md +82 -0
  4. package/dist/index.d.ts +401 -0
  5. package/dist/index.js +5389 -0
  6. package/package.json +66 -0
  7. package/src/blocks/BlockWrapper.tsx +149 -0
  8. package/src/blocks/banner/BannerBlock.tsx +38 -0
  9. package/src/blocks/contact/ContactBlock.tsx +63 -0
  10. package/src/blocks/content/ContentBlock.tsx +38 -0
  11. package/src/blocks/cta/CtaBlock.tsx +67 -0
  12. package/src/blocks/divider/DividerBlock.tsx +30 -0
  13. package/src/blocks/faq/FaqBlock.tsx +82 -0
  14. package/src/blocks/features/FeaturesBlock.tsx +170 -0
  15. package/src/blocks/footer/FooterBlock.tsx +136 -0
  16. package/src/blocks/gallery/GalleryBlock.tsx +67 -0
  17. package/src/blocks/hero/HeroBlock.tsx +163 -0
  18. package/src/blocks/image/ImageBlock.tsx +78 -0
  19. package/src/blocks/logocloud/LogoCloudBlock.tsx +70 -0
  20. package/src/blocks/navbar/NavbarBlock.tsx +102 -0
  21. package/src/blocks/newsletter/NewsletterBlock.tsx +51 -0
  22. package/src/blocks/pricing/PricingBlock.tsx +173 -0
  23. package/src/blocks/registry.tsx +90 -0
  24. package/src/blocks/stats/StatsBlock.tsx +96 -0
  25. package/src/blocks/team/TeamBlock.tsx +50 -0
  26. package/src/blocks/testimonials/TestimonialsBlock.tsx +203 -0
  27. package/src/blocks/types.ts +72 -0
  28. package/src/blocks/video/VideoBlock.tsx +58 -0
  29. package/src/editor/AgentPanel.tsx +318 -0
  30. package/src/editor/Canvas.tsx +95 -0
  31. package/src/editor/CanvasEmpty.tsx +40 -0
  32. package/src/editor/CanvasToolbar.tsx +366 -0
  33. package/src/editor/DesignPanel.tsx +224 -0
  34. package/src/editor/EditorLayout.tsx +196 -0
  35. package/src/editor/GenerationOverlay.tsx +201 -0
  36. package/src/editor/JsonDrawer.tsx +139 -0
  37. package/src/editor/LayersPanel.tsx +259 -0
  38. package/src/editor/LeftSidebar.tsx +136 -0
  39. package/src/editor/PropertiesPanel.tsx +564 -0
  40. package/src/editor/RightSidebar.tsx +85 -0
  41. package/src/editor/ShortcutsModal.tsx +126 -0
  42. package/src/editor/VersionHistory.tsx +110 -0
  43. package/src/index.ts +133 -0
  44. package/src/lib/block-metadata.ts +167 -0
  45. package/src/lib/export-html.ts +1424 -0
  46. package/src/lib/generate-site.ts +206 -0
  47. package/src/lib/generation-prompt.ts +64 -0
  48. package/src/lib/markdown.ts +88 -0
  49. package/src/lib/templates.ts +139 -0
  50. package/src/lib/theme-presets.ts +330 -0
  51. package/src/lib/useGoogleFonts.ts +49 -0
  52. package/src/lib/useScrollReveal.ts +28 -0
  53. package/src/settings/GeminiKeyInputField.tsx +82 -0
  54. package/src/store/configStore.ts +344 -0
  55. package/src/store/editorStore.ts +50 -0
  56. package/src/styles.css +210 -0
@@ -0,0 +1,564 @@
1
+ import { useState } from 'react'
2
+ import { ChevronDown, ChevronRight, Code } from 'lucide-react'
3
+ import type { BlockConfig, BlockType } from '@/blocks/types'
4
+ import { useConfigStore } from '@/store/configStore'
5
+
6
+ interface FieldDef {
7
+ key: string
8
+ label: string
9
+ type: 'text' | 'textarea' | 'select' | 'array-strings' | 'array-items'
10
+ options?: string[]
11
+ }
12
+
13
+ const blockFields: Partial<Record<BlockType, { sections: { title: string; fields: FieldDef[] }[] }>> = {
14
+ navbar: {
15
+ sections: [
16
+ {
17
+ title: 'Content',
18
+ fields: [
19
+ { key: 'logo', label: 'Logo Text', type: 'text' },
20
+ { key: 'logoImage', label: 'Logo Image URL', type: 'text' },
21
+ { key: 'ctaText', label: 'CTA Button', type: 'text' },
22
+ { key: 'links', label: 'Nav Links', type: 'array-strings' },
23
+ ],
24
+ },
25
+ {
26
+ title: 'Style',
27
+ fields: [
28
+ { key: 'variant', label: 'Variant', type: 'select', options: ['default', 'centered'] },
29
+ ],
30
+ },
31
+ ],
32
+ },
33
+ hero: {
34
+ sections: [
35
+ {
36
+ title: 'Content',
37
+ fields: [
38
+ { key: 'badge', label: 'Badge', type: 'text' },
39
+ { key: 'headline', label: 'Headline', type: 'text' },
40
+ { key: 'subheadline', label: 'Subheadline', type: 'textarea' },
41
+ { key: 'primaryCta', label: 'Primary CTA', type: 'text' },
42
+ { key: 'primaryCtaUrl', label: 'Primary CTA URL', type: 'text' },
43
+ { key: 'secondaryCta', label: 'Secondary CTA', type: 'text' },
44
+ { key: 'secondaryCtaUrl', label: 'Secondary CTA URL', type: 'text' },
45
+ { key: 'heroImage', label: 'Hero Image URL', type: 'text' },
46
+ ],
47
+ },
48
+ {
49
+ title: 'Style',
50
+ fields: [
51
+ { key: 'variant', label: 'Variant', type: 'select', options: ['centered', 'split', 'gradient', 'minimal'] },
52
+ ],
53
+ },
54
+ ],
55
+ },
56
+ features: {
57
+ sections: [
58
+ {
59
+ title: 'Content',
60
+ fields: [
61
+ { key: 'label', label: 'Section Label', type: 'text' },
62
+ { key: 'title', label: 'Title', type: 'text' },
63
+ { key: 'subtitle', label: 'Subtitle', type: 'text' },
64
+ ],
65
+ },
66
+ {
67
+ title: 'Items',
68
+ fields: [
69
+ { key: 'items', label: 'Feature Cards', type: 'array-items' },
70
+ ],
71
+ },
72
+ {
73
+ title: 'Style',
74
+ fields: [
75
+ { key: 'variant', label: 'Variant', type: 'select', options: ['grid', 'list', 'alternating'] },
76
+ ],
77
+ },
78
+ ],
79
+ },
80
+ pricing: {
81
+ sections: [
82
+ {
83
+ title: 'Content',
84
+ fields: [
85
+ { key: 'title', label: 'Title', type: 'text' },
86
+ { key: 'subtitle', label: 'Subtitle', type: 'text' },
87
+ ],
88
+ },
89
+ {
90
+ title: 'Style',
91
+ fields: [
92
+ { key: 'variant', label: 'Variant', type: 'select', options: ['simple', 'comparison'] },
93
+ ],
94
+ },
95
+ ],
96
+ },
97
+ cta: {
98
+ sections: [
99
+ {
100
+ title: 'Content',
101
+ fields: [
102
+ { key: 'headline', label: 'Headline', type: 'text' },
103
+ { key: 'subheadline', label: 'Subheadline', type: 'text' },
104
+ { key: 'buttonText', label: 'Button Text', type: 'text' },
105
+ { key: 'buttonUrl', label: 'Button URL', type: 'text' },
106
+ ],
107
+ },
108
+ {
109
+ title: 'Style',
110
+ fields: [
111
+ { key: 'variant', label: 'Variant', type: 'select', options: ['simple', 'split'] },
112
+ ],
113
+ },
114
+ ],
115
+ },
116
+ footer: {
117
+ sections: [
118
+ {
119
+ title: 'Content',
120
+ fields: [
121
+ { key: 'logo', label: 'Logo Text', type: 'text' },
122
+ { key: 'logoImage', label: 'Logo Image URL', type: 'text' },
123
+ { key: 'copyright', label: 'Copyright', type: 'text' },
124
+ { key: 'links', label: 'Links', type: 'array-strings' },
125
+ ],
126
+ },
127
+ {
128
+ title: 'Style',
129
+ fields: [
130
+ { key: 'variant', label: 'Variant', type: 'select', options: ['simple', 'multi-column', 'minimal'] },
131
+ ],
132
+ },
133
+ ],
134
+ },
135
+ testimonials: {
136
+ sections: [
137
+ {
138
+ title: 'Content',
139
+ fields: [
140
+ { key: 'title', label: 'Title', type: 'text' },
141
+ { key: 'subtitle', label: 'Subtitle', type: 'text' },
142
+ { key: 'items', label: 'Testimonials', type: 'array-items' },
143
+ ],
144
+ },
145
+ {
146
+ title: 'Style',
147
+ fields: [
148
+ { key: 'variant', label: 'Variant', type: 'select', options: ['cards', 'carousel', 'spotlight'] },
149
+ ],
150
+ },
151
+ ],
152
+ },
153
+ stats: {
154
+ sections: [
155
+ {
156
+ title: 'Content',
157
+ fields: [
158
+ { key: 'title', label: 'Title', type: 'text' },
159
+ { key: 'items', label: 'Stats', type: 'array-items' },
160
+ ],
161
+ },
162
+ {
163
+ title: 'Style',
164
+ fields: [
165
+ { key: 'variant', label: 'Variant', type: 'select', options: ['grid', 'bar', 'counter'] },
166
+ ],
167
+ },
168
+ ],
169
+ },
170
+ faq: {
171
+ sections: [
172
+ {
173
+ title: 'Content',
174
+ fields: [
175
+ { key: 'title', label: 'Title', type: 'text' },
176
+ { key: 'subtitle', label: 'Subtitle', type: 'text' },
177
+ { key: 'items', label: 'Questions', type: 'array-items' },
178
+ ],
179
+ },
180
+ ],
181
+ },
182
+ team: {
183
+ sections: [
184
+ {
185
+ title: 'Content',
186
+ fields: [
187
+ { key: 'title', label: 'Title', type: 'text' },
188
+ { key: 'subtitle', label: 'Subtitle', type: 'text' },
189
+ { key: 'members', label: 'Members', type: 'array-items' },
190
+ ],
191
+ },
192
+ ],
193
+ },
194
+ contact: {
195
+ sections: [
196
+ {
197
+ title: 'Content',
198
+ fields: [
199
+ { key: 'title', label: 'Title', type: 'text' },
200
+ { key: 'subtitle', label: 'Subtitle', type: 'text' },
201
+ ],
202
+ },
203
+ ],
204
+ },
205
+ newsletter: {
206
+ sections: [
207
+ {
208
+ title: 'Content',
209
+ fields: [
210
+ { key: 'title', label: 'Title', type: 'text' },
211
+ { key: 'subtitle', label: 'Subtitle', type: 'text' },
212
+ { key: 'buttonText', label: 'Button Text', type: 'text' },
213
+ { key: 'socialProof', label: 'Social Proof', type: 'text' },
214
+ ],
215
+ },
216
+ ],
217
+ },
218
+ logocloud: {
219
+ sections: [
220
+ {
221
+ title: 'Content',
222
+ fields: [
223
+ { key: 'title', label: 'Title', type: 'text' },
224
+ { key: 'logos', label: 'Logos', type: 'array-strings' },
225
+ ],
226
+ },
227
+ ],
228
+ },
229
+ content: {
230
+ sections: [
231
+ {
232
+ title: 'Content',
233
+ fields: [
234
+ { key: 'body', label: 'Body', type: 'textarea' },
235
+ ],
236
+ },
237
+ {
238
+ title: 'Style',
239
+ fields: [
240
+ { key: 'variant', label: 'Variant', type: 'select', options: ['prose', 'columns', 'highlight'] },
241
+ ],
242
+ },
243
+ ],
244
+ },
245
+ image: {
246
+ sections: [
247
+ {
248
+ title: 'Content',
249
+ fields: [
250
+ { key: 'src', label: 'Image URL', type: 'text' },
251
+ { key: 'alt', label: 'Alt Text', type: 'text' },
252
+ { key: 'title', label: 'Title', type: 'text' },
253
+ { key: 'subtitle', label: 'Subtitle', type: 'text' },
254
+ { key: 'imageSide', label: 'Image Side', type: 'select', options: ['left', 'right'] },
255
+ ],
256
+ },
257
+ {
258
+ title: 'Grid Images',
259
+ fields: [
260
+ { key: 'images', label: 'Images', type: 'array-items' },
261
+ ],
262
+ },
263
+ {
264
+ title: 'Style',
265
+ fields: [
266
+ { key: 'variant', label: 'Variant', type: 'select', options: ['hero-image', 'side-by-side', 'grid'] },
267
+ ],
268
+ },
269
+ ],
270
+ },
271
+ video: {
272
+ sections: [
273
+ {
274
+ title: 'Content',
275
+ fields: [
276
+ { key: 'url', label: 'Video URL', type: 'text' },
277
+ { key: 'title', label: 'Title', type: 'text' },
278
+ ],
279
+ },
280
+ {
281
+ title: 'Style',
282
+ fields: [
283
+ { key: 'variant', label: 'Platform', type: 'select', options: ['youtube', 'vimeo'] },
284
+ ],
285
+ },
286
+ ],
287
+ },
288
+ gallery: {
289
+ sections: [
290
+ {
291
+ title: 'Content',
292
+ fields: [
293
+ { key: 'title', label: 'Title', type: 'text' },
294
+ { key: 'images', label: 'Images', type: 'array-items' },
295
+ ],
296
+ },
297
+ {
298
+ title: 'Style',
299
+ fields: [
300
+ { key: 'variant', label: 'Variant', type: 'select', options: ['grid', 'masonry'] },
301
+ ],
302
+ },
303
+ ],
304
+ },
305
+ divider: {
306
+ sections: [
307
+ {
308
+ title: 'Style',
309
+ fields: [
310
+ { key: 'variant', label: 'Variant', type: 'select', options: ['line', 'space', 'dots'] },
311
+ { key: 'width', label: 'Width', type: 'select', options: ['full', 'centered', 'narrow'] },
312
+ { key: 'height', label: 'Height (px)', type: 'text' },
313
+ ],
314
+ },
315
+ ],
316
+ },
317
+ banner: {
318
+ sections: [
319
+ {
320
+ title: 'Content',
321
+ fields: [
322
+ { key: 'text', label: 'Text', type: 'text' },
323
+ { key: 'linkText', label: 'Link Text', type: 'text' },
324
+ { key: 'linkUrl', label: 'Link URL', type: 'text' },
325
+ ],
326
+ },
327
+ {
328
+ title: 'Style',
329
+ fields: [
330
+ { key: 'variant', label: 'Variant', type: 'select', options: ['ribbon', 'bar'] },
331
+ ],
332
+ },
333
+ ],
334
+ },
335
+ }
336
+
337
+ function PropertyField({ field, block }: { field: FieldDef; block: BlockConfig }) {
338
+ const updateBlockProps = useConfigStore((s) => s.updateBlockProps)
339
+ const updateBlock = useConfigStore((s) => s.updateBlock)
340
+
341
+ // For variant field, it's on the block itself
342
+ const value = field.key === 'variant'
343
+ ? block.variant
344
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
345
+ : (block.props as any)[field.key]
346
+
347
+ const onChange = (newValue: unknown) => {
348
+ if (field.key === 'variant') {
349
+ updateBlock(block.id, { variant: newValue as string })
350
+ } else {
351
+ updateBlockProps(block.id, { [field.key]: newValue })
352
+ }
353
+ }
354
+
355
+ switch (field.type) {
356
+ case 'text':
357
+ return (
358
+ <div className="mb-2.5">
359
+ <label className="block text-[11.5px] text-text-2 mb-1 font-medium">{field.label}</label>
360
+ <input
361
+ type="text"
362
+ value={String(value || '')}
363
+ onChange={(e) => onChange(e.target.value)}
364
+ className="w-full px-2 py-1.5 rounded border border-border-default bg-bg-2 text-text-0 text-xs outline-none focus:border-green"
365
+ />
366
+ </div>
367
+ )
368
+
369
+ case 'textarea':
370
+ return (
371
+ <div className="mb-2.5">
372
+ <label className="block text-[11.5px] text-text-2 mb-1 font-medium">{field.label}</label>
373
+ <textarea
374
+ value={String(value || '')}
375
+ onChange={(e) => onChange(e.target.value)}
376
+ rows={3}
377
+ className="w-full px-2 py-1.5 rounded border border-border-default bg-bg-2 text-text-0 text-xs outline-none focus:border-green resize-y"
378
+ />
379
+ </div>
380
+ )
381
+
382
+ case 'select':
383
+ return (
384
+ <div className="mb-2.5">
385
+ <label className="block text-[11.5px] text-text-2 mb-1 font-medium">{field.label}</label>
386
+ <select
387
+ value={String(value || '')}
388
+ onChange={(e) => onChange(e.target.value)}
389
+ className="w-full px-2 py-1.5 rounded border border-border-default bg-bg-2 text-text-0 text-xs outline-none focus:border-green cursor-pointer"
390
+ >
391
+ {field.options?.map((opt) => (
392
+ <option key={opt} value={opt}>{opt}</option>
393
+ ))}
394
+ </select>
395
+ </div>
396
+ )
397
+
398
+ case 'array-strings': {
399
+ const items = (Array.isArray(value) ? value : []) as string[]
400
+ return (
401
+ <div className="mb-2.5">
402
+ <label className="block text-[11.5px] text-text-2 mb-1 font-medium">{field.label}</label>
403
+ {items.map((item, i) => (
404
+ <div key={i} className="flex gap-1 mb-1">
405
+ <input
406
+ type="text"
407
+ value={item}
408
+ onChange={(e) => {
409
+ const updated = [...items]
410
+ updated[i] = e.target.value
411
+ onChange(updated)
412
+ }}
413
+ className="flex-1 px-2 py-1 rounded border border-border-default bg-bg-2 text-text-0 text-xs outline-none focus:border-green"
414
+ />
415
+ <button
416
+ onClick={() => onChange(items.filter((_, idx) => idx !== i))}
417
+ className="px-1.5 text-text-3 hover:text-status-red text-xs transition-colors"
418
+ >
419
+ x
420
+ </button>
421
+ </div>
422
+ ))}
423
+ <button
424
+ onClick={() => onChange([...items, ''])}
425
+ className="text-[10px] text-green hover:text-green-dim transition-colors mt-0.5"
426
+ >
427
+ + Add item
428
+ </button>
429
+ </div>
430
+ )
431
+ }
432
+
433
+ case 'array-items': {
434
+ const items = (Array.isArray(value) ? value : []) as Array<Record<string, string>>
435
+
436
+ // Infer new item shape from existing items, or use sensible defaults per field key
437
+ function createEmptyItem(): Record<string, string> {
438
+ if (items.length > 0) {
439
+ const template: Record<string, string> = {}
440
+ for (const key of Object.keys(items[0])) template[key] = ''
441
+ return template
442
+ }
443
+ // Fallback templates by block type + field key
444
+ const blockTemplates: Partial<Record<string, Record<string, Record<string, string>>>> = {
445
+ testimonials: { items: { name: '', role: '', quote: '' } },
446
+ stats: { items: { value: '', label: '' } },
447
+ faq: { items: { question: '', answer: '' } },
448
+ team: { members: { name: '', role: '' } },
449
+ features: { items: { title: '', description: '' } },
450
+ image: { images: { src: '', alt: '' } },
451
+ gallery: { images: { src: '', alt: '', caption: '' } },
452
+ }
453
+ return blockTemplates[block.type]?.[field.key] || { title: '', description: '' }
454
+ }
455
+
456
+ return (
457
+ <div className="mb-2.5">
458
+ <label className="block text-[11.5px] text-text-2 mb-1 font-medium">{field.label}</label>
459
+ {items.map((item, i) => (
460
+ <div key={i} className="bg-bg-2 border border-border-default rounded p-2 mb-1.5">
461
+ <div className="flex items-center justify-between mb-1.5">
462
+ <span className="text-[10px] text-text-3 font-medium">Item {i + 1}</span>
463
+ <button
464
+ onClick={() => onChange(items.filter((_, idx) => idx !== i))}
465
+ className="text-[10px] text-text-3 hover:text-status-red transition-colors"
466
+ >
467
+ Remove
468
+ </button>
469
+ </div>
470
+ {Object.entries(item).map(([key, val]) => (
471
+ <div key={key} className="mb-1">
472
+ <label className="block text-[10px] text-text-3 mb-0.5">{key}</label>
473
+ <input
474
+ type="text"
475
+ value={String(val)}
476
+ onChange={(e) => {
477
+ const updated = [...items]
478
+ updated[i] = { ...updated[i], [key]: e.target.value }
479
+ onChange(updated)
480
+ }}
481
+ className="w-full px-1.5 py-1 rounded border border-border-subtle bg-bg-3 text-text-0 text-[11px] outline-none focus:border-green"
482
+ />
483
+ </div>
484
+ ))}
485
+ </div>
486
+ ))}
487
+ <button
488
+ onClick={() => onChange([...items, createEmptyItem()])}
489
+ className="text-[10px] text-green hover:text-green-dim transition-colors"
490
+ >
491
+ + Add item
492
+ </button>
493
+ </div>
494
+ )
495
+ }
496
+
497
+ default:
498
+ return null
499
+ }
500
+ }
501
+
502
+ function Section({ title, children }: { title: string; children: React.ReactNode }) {
503
+ const [open, setOpen] = useState(true)
504
+ return (
505
+ <div className="border-b border-border-subtle">
506
+ <button
507
+ onClick={() => setOpen(!open)}
508
+ className="w-full px-3.5 py-2.5 flex items-center gap-1 text-[10px] font-semibold uppercase tracking-wider text-text-3 hover:text-text-2 transition-colors"
509
+ >
510
+ {open ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
511
+ {title}
512
+ </button>
513
+ {open && <div className="px-3.5 pb-3">{children}</div>}
514
+ </div>
515
+ )
516
+ }
517
+
518
+ export function PropertiesPanel({ block }: { block: BlockConfig }) {
519
+ const [showJson, setShowJson] = useState(false)
520
+ const schema = blockFields[block.type]
521
+
522
+ return (
523
+ <>
524
+ {/* Header */}
525
+ <div className="px-3.5 py-3 border-b border-border-default flex items-center justify-between">
526
+ <span className="text-[11px] font-semibold uppercase tracking-wider text-text-2">
527
+ Properties
528
+ </span>
529
+ <span className="text-[10px] px-2 py-0.5 rounded-full bg-green-glow text-green font-semibold">
530
+ {block.type}
531
+ </span>
532
+ </div>
533
+
534
+ {/* Property sections */}
535
+ {schema?.sections.map((section) => (
536
+ <Section key={section.title} title={section.title}>
537
+ {section.fields.map((field) => (
538
+ <PropertyField key={field.key} field={field} block={block} />
539
+ ))}
540
+ </Section>
541
+ )) || (
542
+ <div className="p-3.5 text-[11px] text-text-3">
543
+ No editable properties defined for this block type.
544
+ </div>
545
+ )}
546
+
547
+ {/* View JSON toggle */}
548
+ <div className="border-t border-border-subtle">
549
+ <button
550
+ onClick={() => setShowJson(!showJson)}
551
+ className="w-full px-3.5 py-2 flex items-center gap-1.5 text-[10px] text-text-3 hover:text-text-2 transition-colors"
552
+ >
553
+ <Code size={11} />
554
+ {showJson ? 'Hide' : 'View'} Block JSON
555
+ </button>
556
+ {showJson && (
557
+ <pre className="px-3.5 pb-3 text-[10px] font-mono text-text-2 leading-relaxed overflow-x-auto max-h-48 overflow-y-auto">
558
+ {JSON.stringify({ id: block.id, type: block.type, variant: block.variant, props: block.props }, null, 2)}
559
+ </pre>
560
+ )}
561
+ </div>
562
+ </>
563
+ )
564
+ }
@@ -0,0 +1,85 @@
1
+ import { useState } from 'react'
2
+ import { MousePointer2 } from 'lucide-react'
3
+ import { useEditorStore } from '@/store/editorStore'
4
+ import { useConfigStore } from '@/store/configStore'
5
+ import { PropertiesPanel } from './PropertiesPanel'
6
+ import { DesignPanel } from './DesignPanel'
7
+
8
+ type Tab = 'properties' | 'design'
9
+
10
+ export function RightSidebar() {
11
+ const selectedBlockId = useEditorStore((s) => s.selectedBlockId)
12
+ const blocks = useConfigStore((s) => {
13
+ const pages = s.config.pages
14
+ if (!pages || pages.length === 0) return s.config.blocks
15
+ const page = pages.find((p) => p.id === s.activePageId) ?? pages[0]
16
+ return page.blocks
17
+ })
18
+ const selectedBlock = blocks.find((b) => b.id === selectedBlockId)
19
+ const [tab, setTab] = useState<Tab>('properties')
20
+
21
+ // Auto-switch to Properties when a (newly) selected block changes, computed
22
+ // during render instead of via an effect + setState (avoids cascading renders).
23
+ // Pattern: https://react.dev/reference/react/useState#storing-information-from-previous-renders
24
+ const [lastSelectedId, setLastSelectedId] = useState<string | null>(selectedBlockId)
25
+ let activeTab = tab
26
+ if (selectedBlockId !== lastSelectedId) {
27
+ setLastSelectedId(selectedBlockId)
28
+ if (selectedBlock) {
29
+ activeTab = 'properties'
30
+ if (tab !== 'properties') setTab('properties')
31
+ }
32
+ }
33
+
34
+ return (
35
+ <div className="hidden md:flex w-[280px] bg-bg-1 border-l border-border-default flex-col shrink-0">
36
+ {/* Tabs */}
37
+ <div className="flex border-b border-border-default shrink-0">
38
+ <button
39
+ onClick={() => setTab('properties')}
40
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
41
+ activeTab === 'properties'
42
+ ? 'text-text-0 border-b border-green'
43
+ : 'text-text-3 hover:text-text-1'
44
+ }`}
45
+ >
46
+ Properties
47
+ </button>
48
+ <button
49
+ onClick={() => setTab('design')}
50
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
51
+ activeTab === 'design'
52
+ ? 'text-text-0 border-b border-green'
53
+ : 'text-text-3 hover:text-text-1'
54
+ }`}
55
+ >
56
+ Design
57
+ </button>
58
+ </div>
59
+
60
+ {/* Content */}
61
+ <div className="flex-1 overflow-y-auto">
62
+ {activeTab === 'design' ? (
63
+ <DesignPanel />
64
+ ) : selectedBlock ? (
65
+ <>
66
+ <PropertiesPanel block={selectedBlock} />
67
+ <div className="mt-auto px-3.5 py-2.5 font-mono text-[10.5px] text-text-3 break-all border-t border-border-subtle">
68
+ config.blocks[{blocks.indexOf(selectedBlock)}]
69
+ </div>
70
+ </>
71
+ ) : (
72
+ <div className="flex flex-col items-center justify-center text-center px-6 py-16 gap-3">
73
+ <div className="w-10 h-10 rounded-lg bg-bg-3 border border-border-default flex items-center justify-center">
74
+ <MousePointer2 size={16} className="text-text-3" />
75
+ </div>
76
+ <div>
77
+ <p className="text-text-1 text-[12px] font-medium">Click a block to edit</p>
78
+ <p className="text-text-3 text-[11px] mt-1">Select any block on the canvas to see its properties here</p>
79
+ </div>
80
+ </div>
81
+ )}
82
+ </div>
83
+ </div>
84
+ )
85
+ }