@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.
- package/LICENSE +21 -0
- package/NOTICE +4 -0
- package/README.md +82 -0
- package/dist/index.d.ts +401 -0
- package/dist/index.js +5389 -0
- package/package.json +66 -0
- package/src/blocks/BlockWrapper.tsx +149 -0
- package/src/blocks/banner/BannerBlock.tsx +38 -0
- package/src/blocks/contact/ContactBlock.tsx +63 -0
- package/src/blocks/content/ContentBlock.tsx +38 -0
- package/src/blocks/cta/CtaBlock.tsx +67 -0
- package/src/blocks/divider/DividerBlock.tsx +30 -0
- package/src/blocks/faq/FaqBlock.tsx +82 -0
- package/src/blocks/features/FeaturesBlock.tsx +170 -0
- package/src/blocks/footer/FooterBlock.tsx +136 -0
- package/src/blocks/gallery/GalleryBlock.tsx +67 -0
- package/src/blocks/hero/HeroBlock.tsx +163 -0
- package/src/blocks/image/ImageBlock.tsx +78 -0
- package/src/blocks/logocloud/LogoCloudBlock.tsx +70 -0
- package/src/blocks/navbar/NavbarBlock.tsx +102 -0
- package/src/blocks/newsletter/NewsletterBlock.tsx +51 -0
- package/src/blocks/pricing/PricingBlock.tsx +173 -0
- package/src/blocks/registry.tsx +90 -0
- package/src/blocks/stats/StatsBlock.tsx +96 -0
- package/src/blocks/team/TeamBlock.tsx +50 -0
- package/src/blocks/testimonials/TestimonialsBlock.tsx +203 -0
- package/src/blocks/types.ts +72 -0
- package/src/blocks/video/VideoBlock.tsx +58 -0
- package/src/editor/AgentPanel.tsx +318 -0
- package/src/editor/Canvas.tsx +95 -0
- package/src/editor/CanvasEmpty.tsx +40 -0
- package/src/editor/CanvasToolbar.tsx +366 -0
- package/src/editor/DesignPanel.tsx +224 -0
- package/src/editor/EditorLayout.tsx +196 -0
- package/src/editor/GenerationOverlay.tsx +201 -0
- package/src/editor/JsonDrawer.tsx +139 -0
- package/src/editor/LayersPanel.tsx +259 -0
- package/src/editor/LeftSidebar.tsx +136 -0
- package/src/editor/PropertiesPanel.tsx +564 -0
- package/src/editor/RightSidebar.tsx +85 -0
- package/src/editor/ShortcutsModal.tsx +126 -0
- package/src/editor/VersionHistory.tsx +110 -0
- package/src/index.ts +133 -0
- package/src/lib/block-metadata.ts +167 -0
- package/src/lib/export-html.ts +1424 -0
- package/src/lib/generate-site.ts +206 -0
- package/src/lib/generation-prompt.ts +64 -0
- package/src/lib/markdown.ts +88 -0
- package/src/lib/templates.ts +139 -0
- package/src/lib/theme-presets.ts +330 -0
- package/src/lib/useGoogleFonts.ts +49 -0
- package/src/lib/useScrollReveal.ts +28 -0
- package/src/settings/GeminiKeyInputField.tsx +82 -0
- package/src/store/configStore.ts +344 -0
- package/src/store/editorStore.ts +50 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function syntaxHighlight(json: string): string {
|
|
11
|
+
const escaped = escapeHtml(json)
|
|
12
|
+
return escaped.replace(
|
|
13
|
+
/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\&])*?"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g,
|
|
14
|
+
(match) => {
|
|
15
|
+
let cls = 'text-status-yellow' // number
|
|
16
|
+
if (/^"/.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
|
+
}
|