@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,139 @@
1
+ import { useState } from 'react'
2
+ import { toast } from 'sonner'
3
+ import { useConfigStore } from '@/store/configStore'
4
+ import { useEditorStore } from '@/store/editorStore'
5
+
6
+ function escapeHtml(str: string): string {
7
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
8
+ }
9
+
10
+ function syntaxHighlight(json: string): string {
11
+ const escaped = escapeHtml(json)
12
+ return escaped.replace(
13
+ /(&quot;(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\&])*?&quot;(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
14
+ (match) => {
15
+ let cls = 'text-status-yellow' // number
16
+ if (/^&quot;/.test(match)) {
17
+ if (/:$/.test(match)) {
18
+ cls = 'text-sky-300' // key
19
+ match = match.replace(/:$/, '')
20
+ return `<span class="${cls}">${match}</span>:`
21
+ } else {
22
+ cls = 'text-emerald-300' // string
23
+ }
24
+ } else if (/true|false/.test(match)) {
25
+ cls = 'text-status-blue'
26
+ } else if (/null/.test(match)) {
27
+ cls = 'text-text-3'
28
+ }
29
+ return `<span class="${cls}">${match}</span>`
30
+ }
31
+ )
32
+ }
33
+
34
+ export function JsonDrawer() {
35
+ const config = useConfigStore((s) => s.config)
36
+ const setConfig = useConfigStore((s) => s.setConfig)
37
+ const { jsonDrawerOpen, toggleJsonDrawer } = useEditorStore()
38
+ const [editing, setEditing] = useState(false)
39
+ const [editValue, setEditValue] = useState('')
40
+ const [error, setError] = useState<string | null>(null)
41
+
42
+ const jsonStr = JSON.stringify(config, null, 2)
43
+ const highlighted = syntaxHighlight(jsonStr)
44
+
45
+ function startEditing() {
46
+ setEditValue(jsonStr)
47
+ setError(null)
48
+ setEditing(true)
49
+ }
50
+
51
+ function applyEdit() {
52
+ try {
53
+ const parsed = JSON.parse(editValue)
54
+ if (!parsed.name || (!Array.isArray(parsed.blocks) && !Array.isArray(parsed.pages))) {
55
+ setError('Invalid config: must have "name" and "blocks" or "pages" array')
56
+ return
57
+ }
58
+ setConfig(parsed)
59
+ setEditing(false)
60
+ setError(null)
61
+ toast('Config updated from JSON')
62
+ } catch (e) {
63
+ setError((e as Error).message)
64
+ }
65
+ }
66
+
67
+ function cancelEdit() {
68
+ setEditing(false)
69
+ setError(null)
70
+ }
71
+
72
+ return (
73
+ <div
74
+ className={`bg-bg-0 flex flex-col overflow-hidden transition-all duration-250 ease-in-out ${jsonDrawerOpen ? 'border-t border-border-default' : ''}`}
75
+ style={{ height: jsonDrawerOpen ? '220px' : '0px' }}
76
+ >
77
+ {/* Header */}
78
+ <div
79
+ className="h-8 min-h-8 bg-bg-1 border-b border-border-default flex items-center px-3 text-[11px] text-text-2 gap-2 cursor-pointer select-none hover:bg-bg-2 transition-colors"
80
+ onClick={toggleJsonDrawer}
81
+ >
82
+ <span className="font-mono">{'{ }'}</span>
83
+ <span>Site Config</span>
84
+
85
+ <div className="ml-auto flex items-center gap-2">
86
+ {!editing && (
87
+ <button
88
+ onClick={(e) => { e.stopPropagation(); startEditing() }}
89
+ className="text-[10px] text-text-3 hover:text-green transition-colors"
90
+ >
91
+ Edit
92
+ </button>
93
+ )}
94
+ {editing && (
95
+ <>
96
+ <button
97
+ onClick={(e) => { e.stopPropagation(); applyEdit() }}
98
+ className="text-[10px] text-green hover:text-green-dim transition-colors font-medium"
99
+ >
100
+ Apply
101
+ </button>
102
+ <button
103
+ onClick={(e) => { e.stopPropagation(); cancelEdit() }}
104
+ className="text-[10px] text-text-3 hover:text-status-red transition-colors"
105
+ >
106
+ Cancel
107
+ </button>
108
+ </>
109
+ )}
110
+ <div className="flex items-center gap-1 text-[10px] text-green">
111
+ <span className="w-1.5 h-1.5 rounded-full bg-green" />
112
+ Live
113
+ </div>
114
+ </div>
115
+ </div>
116
+
117
+ {/* JSON body */}
118
+ <div className="flex-1 overflow-auto px-3.5 py-2.5 font-mono text-[11.5px] leading-relaxed text-text-1">
119
+ {editing ? (
120
+ <div className="h-full flex flex-col">
121
+ <textarea
122
+ value={editValue}
123
+ onChange={(e) => { setEditValue(e.target.value); setError(null) }}
124
+ className="flex-1 w-full bg-transparent text-text-1 outline-none resize-none font-mono text-[11.5px] leading-relaxed"
125
+ spellCheck={false}
126
+ />
127
+ {error && (
128
+ <div className="text-status-red text-[10px] mt-1 py-1">
129
+ {error}
130
+ </div>
131
+ )}
132
+ </div>
133
+ ) : (
134
+ <pre dangerouslySetInnerHTML={{ __html: highlighted }} />
135
+ )}
136
+ </div>
137
+ </div>
138
+ )
139
+ }
@@ -0,0 +1,259 @@
1
+ import { useState } from 'react'
2
+ import { toast } from 'sonner'
3
+ import {
4
+ Layout, Type, Grid3X3, DollarSign, Megaphone, PanelBottom,
5
+ MessageSquare, BarChart3, HelpCircle, Users, Mail, Newspaper, Image,
6
+ Copy, Trash2, GripVertical, Plus, Search, Minus, Flag,
7
+ FileText, ImageIcon, Play, GalleryHorizontalEnd,
8
+ } from 'lucide-react'
9
+ import {
10
+ DndContext,
11
+ closestCenter,
12
+ KeyboardSensor,
13
+ PointerSensor,
14
+ useSensor,
15
+ useSensors,
16
+ type DragEndEvent,
17
+ } from '@dnd-kit/core'
18
+ import {
19
+ SortableContext,
20
+ sortableKeyboardCoordinates,
21
+ useSortable,
22
+ verticalListSortingStrategy,
23
+ } from '@dnd-kit/sortable'
24
+ import { CSS } from '@dnd-kit/utilities'
25
+ import { useConfigStore } from '@/store/configStore'
26
+ import { useEditorStore } from '@/store/editorStore'
27
+ import { blockMetadata } from '@/lib/block-metadata'
28
+ import type { BlockType, BlockConfig } from '@/blocks/types'
29
+
30
+ const blockIcons: Record<BlockType, typeof Layout> = {
31
+ navbar: Layout, hero: Type, features: Grid3X3, pricing: DollarSign,
32
+ cta: Megaphone, footer: PanelBottom, testimonials: MessageSquare,
33
+ stats: BarChart3, faq: HelpCircle, team: Users, contact: Mail,
34
+ newsletter: Newspaper, logocloud: Image, divider: Minus, banner: Flag,
35
+ content: FileText, image: ImageIcon, video: Play, gallery: GalleryHorizontalEnd,
36
+ }
37
+
38
+ const blockLabels: Record<BlockType, string> = {
39
+ navbar: 'Navbar', hero: 'Hero', features: 'Features', pricing: 'Pricing',
40
+ cta: 'CTA', footer: 'Footer', testimonials: 'Testimonials', stats: 'Stats',
41
+ faq: 'FAQ', team: 'Team', contact: 'Contact', newsletter: 'Newsletter',
42
+ logocloud: 'Logo Cloud', divider: 'Divider', banner: 'Banner',
43
+ content: 'Content', image: 'Image', video: 'Video', gallery: 'Gallery',
44
+ }
45
+
46
+ function SortableLayer({ block, isSelected, onSelect, onDuplicate, onRemove }: {
47
+ block: BlockConfig
48
+ isSelected: boolean
49
+ onSelect: () => void
50
+ onDuplicate: () => void
51
+ onRemove: () => void
52
+ }) {
53
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: block.id })
54
+ const Icon = blockIcons[block.type] || Layout
55
+
56
+ const style = {
57
+ transform: CSS.Transform.toString(transform),
58
+ transition,
59
+ opacity: isDragging ? 0.5 : 1,
60
+ }
61
+
62
+ return (
63
+ <div
64
+ ref={setNodeRef}
65
+ style={style}
66
+ onClick={onSelect}
67
+ className={`group px-2.5 py-2 rounded-md text-[12.5px] flex items-center gap-2 transition-all cursor-pointer select-none relative ${
68
+ isSelected ? 'bg-green-glow text-green' : 'text-text-1 hover:bg-bg-3 hover:text-text-0'
69
+ }`}
70
+ >
71
+ <div
72
+ {...attributes}
73
+ {...listeners}
74
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-text-3 cursor-grab active:cursor-grabbing"
75
+ aria-label={`Drag to reorder ${blockLabels[block.type]}`}
76
+ >
77
+ <GripVertical size={12} />
78
+ </div>
79
+
80
+ <div className={`w-[26px] h-[26px] rounded flex items-center justify-center text-[11px] shrink-0 border ${
81
+ isSelected ? 'border-green/30 bg-green-glow' : 'border-border-default bg-bg-3'
82
+ }`}>
83
+ <Icon size={13} />
84
+ </div>
85
+
86
+ <span className="font-medium flex-1">{blockLabels[block.type]}</span>
87
+
88
+ <div className="flex gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
89
+ <button
90
+ onClick={(e) => { e.stopPropagation(); onDuplicate() }}
91
+ className="w-[22px] h-[22px] rounded flex items-center justify-center text-text-3 hover:bg-bg-4 hover:text-text-0 transition-all"
92
+ aria-label={`Duplicate ${blockLabels[block.type]}`}
93
+ >
94
+ <Copy size={11} />
95
+ </button>
96
+ <button
97
+ onClick={(e) => { e.stopPropagation(); onRemove() }}
98
+ className="w-[22px] h-[22px] rounded flex items-center justify-center text-text-3 hover:bg-status-red/10 hover:text-status-red transition-all"
99
+ aria-label={`Remove ${blockLabels[block.type]}`}
100
+ >
101
+ <Trash2 size={11} />
102
+ </button>
103
+ </div>
104
+ </div>
105
+ )
106
+ }
107
+
108
+ function AddComponentPopover({ onAdd, onClose }: { onAdd: (type: BlockType) => void; onClose: () => void }) {
109
+ const [search, setSearch] = useState('')
110
+ const filtered = blockMetadata.filter((b) =>
111
+ b.label.toLowerCase().includes(search.toLowerCase()) ||
112
+ b.category.toLowerCase().includes(search.toLowerCase())
113
+ )
114
+
115
+ const grouped = filtered.reduce<Record<string, typeof blockMetadata>>((acc, b) => {
116
+ if (!acc[b.category]) acc[b.category] = []
117
+ acc[b.category].push(b)
118
+ return acc
119
+ }, {})
120
+
121
+ return (
122
+ <div className="absolute bottom-[52px] left-2 right-2 bg-bg-2 border border-border-default rounded-lg p-1.5 shadow-[0_8px_24px_rgba(0,0,0,0.4)] z-10 max-h-[280px] overflow-y-auto">
123
+ <input
124
+ autoFocus
125
+ type="text"
126
+ placeholder="Search components..."
127
+ value={search}
128
+ onChange={(e) => setSearch(e.target.value)}
129
+ onKeyDown={(e) => e.key === 'Escape' && onClose()}
130
+ className="w-full px-2 py-1.5 rounded border border-border-default bg-bg-3 text-text-0 text-[11.5px] outline-none focus:border-green mb-1"
131
+ />
132
+ {Object.entries(grouped).map(([category, items]) => (
133
+ <div key={category}>
134
+ <div className="text-[9px] font-semibold uppercase tracking-wider text-text-3 px-1.5 pt-2 pb-1">
135
+ {category}
136
+ </div>
137
+ {items.map((meta) => {
138
+ const Icon = blockIcons[meta.type] || Layout
139
+ return (
140
+ <button
141
+ key={meta.type}
142
+ onClick={() => { onAdd(meta.type); onClose() }}
143
+ className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-[12px] text-text-1 hover:bg-bg-3 hover:text-text-0 transition-colors text-left"
144
+ >
145
+ <div className="w-[22px] h-[22px] rounded border border-border-default bg-bg-3 flex items-center justify-center text-[10px] shrink-0">
146
+ <Icon size={12} />
147
+ </div>
148
+ <span>{meta.label}</span>
149
+ <span className="ml-auto text-[10px] text-text-3">{meta.variants.length}v</span>
150
+ </button>
151
+ )
152
+ })}
153
+ </div>
154
+ ))}
155
+ {filtered.length === 0 && (
156
+ <div className="px-2 py-3 text-center text-[11px] text-text-3 flex items-center justify-center gap-1.5">
157
+ <Search size={12} />
158
+ No components match "{search}"
159
+ </div>
160
+ )}
161
+ </div>
162
+ )
163
+ }
164
+
165
+ export function LayersPanel() {
166
+ const blocks = useConfigStore((s) => {
167
+ const pages = s.config.pages
168
+ if (!pages || pages.length === 0) return s.config.blocks
169
+ const page = pages.find((p) => p.id === s.activePageId) ?? pages[0]
170
+ return page.blocks
171
+ })
172
+ const { duplicateBlock, removeBlock, moveBlock, addBlock } = useConfigStore()
173
+ const { selectedBlockId, selectBlock } = useEditorStore()
174
+ const [showPopover, setShowPopover] = useState(false)
175
+
176
+ const sensors = useSensors(
177
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
178
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
179
+ )
180
+
181
+ function handleDragEnd(event: DragEndEvent) {
182
+ const { active, over } = event
183
+ if (!over || active.id === over.id) return
184
+ const oldIndex = blocks.findIndex((b) => b.id === active.id)
185
+ const newIndex = blocks.findIndex((b) => b.id === over.id)
186
+ if (oldIndex !== -1 && newIndex !== -1) {
187
+ moveBlock(oldIndex, newIndex)
188
+ }
189
+ }
190
+
191
+ function handleAddBlock(type: BlockType) {
192
+ const meta = blockMetadata.find((b) => b.type === type)
193
+ if (!meta) return
194
+ const block: BlockConfig = {
195
+ id: `block-${Date.now()}`,
196
+ type,
197
+ variant: meta.variants[0],
198
+ props: { ...meta.defaultProps },
199
+ }
200
+ addBlock(block)
201
+ selectBlock(block.id)
202
+ toast(`${meta.label} added`)
203
+ }
204
+
205
+ return (
206
+ <div className="flex flex-col flex-1 overflow-hidden relative">
207
+ <div className="px-3 pt-2.5 pb-1.5 flex items-center justify-between">
208
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-text-3">
209
+ Layers
210
+ </span>
211
+ <span className="text-[10px] text-text-3">{blocks.length}</span>
212
+ </div>
213
+
214
+ <div className="flex-1 overflow-y-auto px-2 pb-2">
215
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
216
+ <SortableContext items={blocks.map((b) => b.id)} strategy={verticalListSortingStrategy}>
217
+ {blocks.map((block) => (
218
+ <SortableLayer
219
+ key={block.id}
220
+ block={block}
221
+ isSelected={selectedBlockId === block.id}
222
+ onSelect={() => selectBlock(block.id)}
223
+ onDuplicate={() => { duplicateBlock(block.id); toast('Block duplicated') }}
224
+ onRemove={() => {
225
+ if (selectedBlockId === block.id) selectBlock(null)
226
+ removeBlock(block.id)
227
+ toast('Block removed', {
228
+ action: {
229
+ label: 'Undo',
230
+ onClick: () => {
231
+ useConfigStore.getState().undo()
232
+ toast('Block restored')
233
+ },
234
+ },
235
+ duration: 3000,
236
+ })
237
+ }}
238
+ />
239
+ ))}
240
+ </SortableContext>
241
+ </DndContext>
242
+ </div>
243
+
244
+ {/* Add component */}
245
+ <div className="p-2 border-t border-border-subtle relative">
246
+ <button
247
+ onClick={() => setShowPopover(!showPopover)}
248
+ className="w-full py-2 rounded-md border border-dashed border-border-default text-text-2 text-xs flex items-center justify-center gap-1.5 transition-all hover:border-green hover:text-green hover:bg-green-glow2"
249
+ >
250
+ <Plus size={13} />
251
+ Add Component
252
+ </button>
253
+ {showPopover && (
254
+ <AddComponentPopover onAdd={handleAddBlock} onClose={() => setShowPopover(false)} />
255
+ )}
256
+ </div>
257
+ </div>
258
+ )
259
+ }
@@ -0,0 +1,136 @@
1
+ import { useState } from 'react'
2
+ import { toast } from 'sonner'
3
+ import { Search, Layout, Type, Grid3X3, DollarSign, Megaphone, PanelBottom, MessageSquare, BarChart3, HelpCircle, Users, Mail, Newspaper, Image, Plus, Minus, Flag, FileText, ImageIcon, Play, GalleryHorizontalEnd } from 'lucide-react'
4
+ import { LayersPanel } from './LayersPanel'
5
+ import { useConfigStore } from '@/store/configStore'
6
+ import { useEditorStore } from '@/store/editorStore'
7
+ import { blockMetadata } from '@/lib/block-metadata'
8
+ import type { BlockType, BlockConfig } from '@/blocks/types'
9
+
10
+ // Generates a block id. Kept outside any component so the impure Date.now()
11
+ // call isn't attributed to a component/hook body (react-hooks/purity).
12
+ function generateBlockId(): string {
13
+ return `block-${Date.now()}`
14
+ }
15
+
16
+ const blockIcons: Record<BlockType, typeof Layout> = {
17
+ navbar: Layout, hero: Type, features: Grid3X3, pricing: DollarSign,
18
+ cta: Megaphone, footer: PanelBottom, testimonials: MessageSquare,
19
+ stats: BarChart3, faq: HelpCircle, team: Users, contact: Mail,
20
+ newsletter: Newspaper, logocloud: Image, divider: Minus, banner: Flag,
21
+ content: FileText, image: ImageIcon, video: Play, gallery: GalleryHorizontalEnd,
22
+ }
23
+
24
+ function ComponentsPanel() {
25
+ const [search, setSearch] = useState('')
26
+ const addBlock = useConfigStore((s) => s.addBlock)
27
+ const selectBlock = useEditorStore((s) => s.selectBlock)
28
+
29
+ const filtered = blockMetadata.filter((b) =>
30
+ b.label.toLowerCase().includes(search.toLowerCase()) ||
31
+ b.category.toLowerCase().includes(search.toLowerCase())
32
+ )
33
+
34
+ const grouped = filtered.reduce<Record<string, typeof blockMetadata>>((acc, b) => {
35
+ if (!acc[b.category]) acc[b.category] = []
36
+ acc[b.category].push(b)
37
+ return acc
38
+ }, {})
39
+
40
+ function handleAdd(type: BlockType) {
41
+ const meta = blockMetadata.find((b) => b.type === type)
42
+ if (!meta) return
43
+ const block: BlockConfig = {
44
+ id: generateBlockId(),
45
+ type,
46
+ variant: meta.variants[0],
47
+ props: { ...meta.defaultProps },
48
+ }
49
+ addBlock(block)
50
+ selectBlock(block.id)
51
+ toast(`${meta.label} added`)
52
+ }
53
+
54
+ return (
55
+ <div className="flex flex-col flex-1 overflow-hidden">
56
+ <div className="px-3 pt-2.5 pb-1.5">
57
+ <div className="relative">
58
+ <Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-3" />
59
+ <input
60
+ type="text"
61
+ placeholder="Search components..."
62
+ value={search}
63
+ onChange={(e) => setSearch(e.target.value)}
64
+ className="w-full pr-2 py-1.5 rounded-md border border-border-default bg-bg-2 text-text-0 text-[11px] outline-none focus:border-green placeholder:text-text-3"
65
+ style={{ paddingLeft: '1.625rem' }}
66
+ />
67
+ </div>
68
+ </div>
69
+
70
+ <div className="flex-1 overflow-y-auto px-2 pb-2">
71
+ {Object.entries(grouped).map(([category, items]) => (
72
+ <div key={category}>
73
+ <div className="text-[9px] font-semibold uppercase tracking-wider text-text-3 px-1.5 pt-2.5 pb-1">
74
+ {category}
75
+ </div>
76
+ {items.map((meta) => {
77
+ const Icon = blockIcons[meta.type] || Layout
78
+ return (
79
+ <button
80
+ key={meta.type}
81
+ onClick={() => handleAdd(meta.type)}
82
+ className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-md text-[12px] text-text-1 hover:bg-bg-3 hover:text-text-0 transition-colors text-left group"
83
+ >
84
+ <div className="w-[22px] h-[22px] rounded border border-border-default bg-bg-3 flex items-center justify-center text-[10px] shrink-0">
85
+ <Icon size={12} />
86
+ </div>
87
+ <span className="flex-1">{meta.label}</span>
88
+ <Plus size={11} className="text-text-3 opacity-0 group-hover:opacity-100 transition-opacity" />
89
+ </button>
90
+ )
91
+ })}
92
+ </div>
93
+ ))}
94
+ {filtered.length === 0 && (
95
+ <div className="px-2 py-6 text-center text-[11px] text-text-3">
96
+ No components match "{search}"
97
+ </div>
98
+ )}
99
+ </div>
100
+ </div>
101
+ )
102
+ }
103
+
104
+ type Tab = 'layers' | 'components'
105
+
106
+ export function LeftSidebar() {
107
+ const [tab, setTab] = useState<Tab>('layers')
108
+
109
+ return (
110
+ <div className="hidden md:flex w-[280px] bg-bg-1 border-r border-border-default flex-col shrink-0">
111
+ <div className="flex border-b border-border-default shrink-0">
112
+ <button
113
+ onClick={() => setTab('layers')}
114
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
115
+ tab === 'layers'
116
+ ? 'text-text-0 border-b border-green'
117
+ : 'text-text-3 hover:text-text-1'
118
+ }`}
119
+ >
120
+ Layers
121
+ </button>
122
+ <button
123
+ onClick={() => setTab('components')}
124
+ className={`flex-1 py-2 text-[11px] font-medium transition-colors ${
125
+ tab === 'components'
126
+ ? 'text-text-0 border-b border-green'
127
+ : 'text-text-3 hover:text-text-1'
128
+ }`}
129
+ >
130
+ Components
131
+ </button>
132
+ </div>
133
+ {tab === 'layers' ? <LayersPanel /> : <ComponentsPanel />}
134
+ </div>
135
+ )
136
+ }