@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,224 @@
|
|
|
1
|
+
import { useState, useMemo } from 'react'
|
|
2
|
+
import { ChevronDown } from 'lucide-react'
|
|
3
|
+
import { useConfigStore } from '@/store/configStore'
|
|
4
|
+
import type { ThemeConfig } from '@/blocks/types'
|
|
5
|
+
import { themePresets, resolveTheme, googleFontOptions } from '@/lib/theme-presets'
|
|
6
|
+
|
|
7
|
+
function ColorInput({ value, onInput, onChange }: { value: string; onInput: (v: string) => void; onChange: (v: string) => void }) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex items-center gap-1.5">
|
|
10
|
+
<input
|
|
11
|
+
type="color"
|
|
12
|
+
value={value}
|
|
13
|
+
onInput={(e) => onInput((e.target as HTMLInputElement).value)}
|
|
14
|
+
onChange={(e) => onChange(e.target.value)}
|
|
15
|
+
className="w-6 h-6 rounded border border-border-default bg-bg-2 cursor-pointer p-0.5 shrink-0"
|
|
16
|
+
/>
|
|
17
|
+
<input
|
|
18
|
+
type="text"
|
|
19
|
+
value={value}
|
|
20
|
+
onChange={(e) => {
|
|
21
|
+
const v = e.target.value
|
|
22
|
+
if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v)
|
|
23
|
+
}}
|
|
24
|
+
onBlur={(e) => {
|
|
25
|
+
const v = e.target.value
|
|
26
|
+
if (/^#[0-9a-fA-F]{6}$/.test(v)) onChange(v)
|
|
27
|
+
}}
|
|
28
|
+
className="w-[72px] px-1.5 py-1 rounded border border-border-default bg-bg-2 text-text-1 text-[10px] font-mono outline-none focus:border-green"
|
|
29
|
+
/>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ColorSection({ title, colors, defaultOpen = false }: {
|
|
35
|
+
title: string
|
|
36
|
+
colors: { key: keyof ThemeConfig; label: string }[]
|
|
37
|
+
defaultOpen?: boolean
|
|
38
|
+
}) {
|
|
39
|
+
const [open, setOpen] = useState(defaultOpen)
|
|
40
|
+
const theme = useConfigStore((s) => s.config.theme)
|
|
41
|
+
const previewTheme = useConfigStore((s) => s.previewTheme)
|
|
42
|
+
const updateTheme = useConfigStore((s) => s.updateTheme)
|
|
43
|
+
const resolved = useMemo(() => resolveTheme(theme), [theme])
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="border border-border-default rounded-lg overflow-hidden">
|
|
47
|
+
<button
|
|
48
|
+
onClick={() => setOpen(!open)}
|
|
49
|
+
className="w-full flex items-center justify-between px-3 py-2 bg-bg-2 hover:bg-bg-3 transition-colors text-left"
|
|
50
|
+
>
|
|
51
|
+
<div className="flex items-center gap-2">
|
|
52
|
+
<span className="text-[11px] font-semibold">{title}</span>
|
|
53
|
+
<div className="flex gap-0.5">
|
|
54
|
+
{colors.slice(0, 4).map((c) => (
|
|
55
|
+
<div
|
|
56
|
+
key={c.key}
|
|
57
|
+
className="w-3 h-3 rounded-sm border border-border-subtle"
|
|
58
|
+
style={{ backgroundColor: resolved[c.key] as string }}
|
|
59
|
+
/>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
<ChevronDown size={12} className={`text-text-3 transition-transform ${open ? 'rotate-180' : ''}`} />
|
|
64
|
+
</button>
|
|
65
|
+
{open && (
|
|
66
|
+
<div className="px-3 py-2.5 space-y-2.5 bg-bg-1">
|
|
67
|
+
{colors.map((c) => (
|
|
68
|
+
<div key={c.key} className="flex items-center justify-between">
|
|
69
|
+
<span className="text-[10.5px] text-text-2">{c.label}</span>
|
|
70
|
+
<ColorInput
|
|
71
|
+
value={resolved[c.key] as string}
|
|
72
|
+
onInput={(v) => previewTheme({ [c.key]: v })}
|
|
73
|
+
onChange={(v) => updateTheme({ [c.key]: v })}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function DesignPanel() {
|
|
84
|
+
const theme = useConfigStore((s) => s.config.theme)
|
|
85
|
+
const setTheme = useConfigStore((s) => s.setTheme)
|
|
86
|
+
const updateTheme = useConfigStore((s) => s.updateTheme)
|
|
87
|
+
const resolved = useMemo(() => resolveTheme(theme), [theme])
|
|
88
|
+
|
|
89
|
+
const activePresetId = useMemo(() => {
|
|
90
|
+
for (const preset of themePresets) {
|
|
91
|
+
const match = Object.keys(preset.theme).every(
|
|
92
|
+
(k) => resolved[k as keyof ThemeConfig] === preset.theme[k as keyof ThemeConfig]
|
|
93
|
+
)
|
|
94
|
+
if (match) return preset.id
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
}, [resolved])
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="px-3.5 py-3.5">
|
|
101
|
+
{/* Preset grid */}
|
|
102
|
+
<div className="mb-4">
|
|
103
|
+
<div className="text-[10px] font-semibold uppercase tracking-wider text-text-3 mb-2">Presets</div>
|
|
104
|
+
<div className="grid grid-cols-2 gap-1.5">
|
|
105
|
+
{themePresets.map((preset) => (
|
|
106
|
+
<button
|
|
107
|
+
key={preset.id}
|
|
108
|
+
onClick={() => setTheme(preset.theme)}
|
|
109
|
+
className={`p-2 rounded-lg border transition-all text-left ${
|
|
110
|
+
activePresetId === preset.id
|
|
111
|
+
? 'border-green bg-green/5'
|
|
112
|
+
: 'border-border-default bg-bg-2 hover:border-border-hover hover:bg-bg-3'
|
|
113
|
+
}`}
|
|
114
|
+
>
|
|
115
|
+
<div className="flex gap-0.5 mb-1.5">
|
|
116
|
+
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: preset.theme.bg0 }} />
|
|
117
|
+
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: preset.theme.bg2 }} />
|
|
118
|
+
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: preset.theme.accent }} />
|
|
119
|
+
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: preset.theme.text0 }} />
|
|
120
|
+
</div>
|
|
121
|
+
<div className="text-[10px] font-medium truncate">{preset.name}</div>
|
|
122
|
+
</button>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
{/* Color sections */}
|
|
128
|
+
<div className="space-y-2 mb-4">
|
|
129
|
+
<ColorSection
|
|
130
|
+
title="Backgrounds"
|
|
131
|
+
defaultOpen
|
|
132
|
+
colors={[
|
|
133
|
+
{ key: 'bg0', label: 'Base' },
|
|
134
|
+
{ key: 'bg1', label: 'Surface 1' },
|
|
135
|
+
{ key: 'bg2', label: 'Surface 2' },
|
|
136
|
+
{ key: 'bg3', label: 'Surface 3' },
|
|
137
|
+
{ key: 'bg4', label: 'Surface 4' },
|
|
138
|
+
{ key: 'bg5', label: 'Surface 5' },
|
|
139
|
+
]}
|
|
140
|
+
/>
|
|
141
|
+
<ColorSection
|
|
142
|
+
title="Text"
|
|
143
|
+
colors={[
|
|
144
|
+
{ key: 'text0', label: 'Primary' },
|
|
145
|
+
{ key: 'text1', label: 'Secondary' },
|
|
146
|
+
{ key: 'text2', label: 'Muted' },
|
|
147
|
+
{ key: 'text3', label: 'Dimmed' },
|
|
148
|
+
]}
|
|
149
|
+
/>
|
|
150
|
+
<ColorSection
|
|
151
|
+
title="Accent"
|
|
152
|
+
defaultOpen
|
|
153
|
+
colors={[
|
|
154
|
+
{ key: 'accent', label: 'Accent' },
|
|
155
|
+
{ key: 'accentDim', label: 'Accent Dim' },
|
|
156
|
+
]}
|
|
157
|
+
/>
|
|
158
|
+
<ColorSection
|
|
159
|
+
title="Borders"
|
|
160
|
+
colors={[
|
|
161
|
+
{ key: 'borderDefault', label: 'Default' },
|
|
162
|
+
{ key: 'borderSubtle', label: 'Subtle' },
|
|
163
|
+
{ key: 'borderHover', label: 'Hover' },
|
|
164
|
+
]}
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{/* Fonts */}
|
|
169
|
+
<div className="mb-4">
|
|
170
|
+
<div className="text-[10px] font-semibold uppercase tracking-wider text-text-3 mb-2">Fonts</div>
|
|
171
|
+
<div className="space-y-2.5">
|
|
172
|
+
{([
|
|
173
|
+
{ key: 'fontSans' as const, label: 'Body' },
|
|
174
|
+
{ key: 'fontDisplay' as const, label: 'Display' },
|
|
175
|
+
{ key: 'fontMono' as const, label: 'Mono' },
|
|
176
|
+
]).map(({ key, label }) => (
|
|
177
|
+
<div key={key}>
|
|
178
|
+
<label className="block text-[10.5px] text-text-2 mb-1">{label}</label>
|
|
179
|
+
<select
|
|
180
|
+
value={resolved[key]}
|
|
181
|
+
onChange={(e) => updateTheme({ [key]: e.target.value })}
|
|
182
|
+
className="w-full px-2 py-1.5 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[11px] outline-none focus:border-green cursor-pointer"
|
|
183
|
+
style={{ fontFamily: `"${resolved[key]}", sans-serif` }}
|
|
184
|
+
>
|
|
185
|
+
{googleFontOptions.map((f) => (
|
|
186
|
+
<option key={f} value={f}>{f}</option>
|
|
187
|
+
))}
|
|
188
|
+
</select>
|
|
189
|
+
</div>
|
|
190
|
+
))}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Radius */}
|
|
195
|
+
<div>
|
|
196
|
+
<div className="text-[10px] font-semibold uppercase tracking-wider text-text-3 mb-2">Radius</div>
|
|
197
|
+
<div className="grid grid-cols-2 gap-2">
|
|
198
|
+
<div>
|
|
199
|
+
<label className="block text-[10.5px] text-text-2 mb-1">Default</label>
|
|
200
|
+
<input
|
|
201
|
+
type="number"
|
|
202
|
+
min={0}
|
|
203
|
+
max={24}
|
|
204
|
+
value={resolved.radius}
|
|
205
|
+
onChange={(e) => updateTheme({ radius: Number(e.target.value) })}
|
|
206
|
+
className="w-full px-2 py-1.5 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[11px] outline-none focus:border-green"
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
<div>
|
|
210
|
+
<label className="block text-[10.5px] text-text-2 mb-1">Large</label>
|
|
211
|
+
<input
|
|
212
|
+
type="number"
|
|
213
|
+
min={0}
|
|
214
|
+
max={32}
|
|
215
|
+
value={resolved.radiusLg}
|
|
216
|
+
onChange={(e) => updateTheme({ radiusLg: Number(e.target.value) })}
|
|
217
|
+
className="w-full px-2 py-1.5 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[11px] outline-none focus:border-green"
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
import { toast } from 'sonner'
|
|
3
|
+
import { FolderOpen, Layers, Briefcase, UtensilsCrossed, Building2, BookOpen } from 'lucide-react'
|
|
4
|
+
import { CanvasToolbar } from './CanvasToolbar'
|
|
5
|
+
import { LeftSidebar } from './LeftSidebar'
|
|
6
|
+
import { Canvas } from './Canvas'
|
|
7
|
+
import { RightSidebar } from './RightSidebar'
|
|
8
|
+
import { JsonDrawer } from './JsonDrawer'
|
|
9
|
+
import { VersionHistory } from './VersionHistory'
|
|
10
|
+
import { GenerationOverlay } from './GenerationOverlay'
|
|
11
|
+
import { useConfigStore } from '@/store/configStore'
|
|
12
|
+
import { useEditorStore } from '@/store/editorStore'
|
|
13
|
+
import { generateSiteConfig } from '@/lib/generate-site'
|
|
14
|
+
import { templateMeta, buildTemplate } from '@/lib/templates'
|
|
15
|
+
import { hexToRgb } from '@/lib/theme-presets'
|
|
16
|
+
import type { SiteConfig } from '@/blocks/types'
|
|
17
|
+
import type { ExportSiteSettings } from '@/lib/export-html'
|
|
18
|
+
|
|
19
|
+
const templateIcons: Record<string, typeof Briefcase> = {
|
|
20
|
+
Briefcase, UtensilsCrossed, Building2, BookOpen,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface EditorLayoutProps {
|
|
24
|
+
onConfigChange?: (id: string, config: SiteConfig) => void
|
|
25
|
+
onRename?: (id: string, name: string) => void
|
|
26
|
+
onCreate?: (name: string) => string
|
|
27
|
+
activeProject?: { name: string; settings?: ExportSiteSettings }
|
|
28
|
+
onExit?: () => void
|
|
29
|
+
onServerFallback?: (prompt: string, signal?: AbortSignal) => Promise<SiteConfig>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function useAutoSaveToProject(onConfigChange?: EditorLayoutProps['onConfigChange']) {
|
|
33
|
+
const config = useConfigStore((s) => s.config)
|
|
34
|
+
const activeProjectId = useEditorStore((s) => s.activeProjectId)
|
|
35
|
+
const loadedConfigRef = useRef<string | null>(null)
|
|
36
|
+
|
|
37
|
+
// Snapshot the config at load time so we can diff
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
loadedConfigRef.current = JSON.stringify(config)
|
|
40
|
+
}, [activeProjectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!activeProjectId) return
|
|
44
|
+
const serialized = JSON.stringify(config)
|
|
45
|
+
// Only save when config actually differs from what was loaded
|
|
46
|
+
if (serialized === loadedConfigRef.current) return
|
|
47
|
+
onConfigChange?.(activeProjectId, config)
|
|
48
|
+
}, [config, activeProjectId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function useGenerationOrchestration(onConfigChange?: EditorLayoutProps['onConfigChange'], onRename?: EditorLayoutProps['onRename'], onServerFallback?: EditorLayoutProps['onServerFallback']) {
|
|
52
|
+
const isGenerating = useEditorStore((s) => s.isGenerating)
|
|
53
|
+
const generationPrompt = useEditorStore((s) => s.generationPrompt)
|
|
54
|
+
const clearGeneration = useEditorStore((s) => s.clearGeneration)
|
|
55
|
+
const setGenerationError = useEditorStore((s) => s.setGenerationError)
|
|
56
|
+
const activeProjectId = useEditorStore((s) => s.activeProjectId)
|
|
57
|
+
const setConfig = useConfigStore((s) => s.setConfig)
|
|
58
|
+
const abortRef = useRef<AbortController | null>(null)
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!isGenerating || !generationPrompt) return
|
|
62
|
+
|
|
63
|
+
const controller = new AbortController()
|
|
64
|
+
abortRef.current = controller
|
|
65
|
+
|
|
66
|
+
// Timeout after 30s to prevent infinite loading
|
|
67
|
+
const timeout = setTimeout(() => {
|
|
68
|
+
controller.abort()
|
|
69
|
+
setGenerationError('Generation timed out')
|
|
70
|
+
toast.error('Generation timed out. Try again or add a Gemini API key in Settings.')
|
|
71
|
+
clearGeneration()
|
|
72
|
+
}, 30000)
|
|
73
|
+
|
|
74
|
+
generateSiteConfig(generationPrompt, controller.signal, onServerFallback)
|
|
75
|
+
.then(({ config, source }) => {
|
|
76
|
+
clearTimeout(timeout)
|
|
77
|
+
if (controller.signal.aborted) return
|
|
78
|
+
setConfig(config)
|
|
79
|
+
if (activeProjectId) {
|
|
80
|
+
onConfigChange?.(activeProjectId, config)
|
|
81
|
+
if (config.name) onRename?.(activeProjectId, config.name)
|
|
82
|
+
}
|
|
83
|
+
clearGeneration()
|
|
84
|
+
if (source === 'template') {
|
|
85
|
+
toast('Generated from template. Add a Gemini API key in Settings for AI generation.')
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
.catch((err) => {
|
|
89
|
+
clearTimeout(timeout)
|
|
90
|
+
if (err instanceof Error && err.name === 'AbortError') return
|
|
91
|
+
setGenerationError(err instanceof Error ? err.message : 'Generation failed')
|
|
92
|
+
toast.error(err instanceof Error ? err.message : 'Generation failed')
|
|
93
|
+
clearGeneration()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
return () => {
|
|
97
|
+
clearTimeout(timeout)
|
|
98
|
+
controller.abort()
|
|
99
|
+
abortRef.current = null
|
|
100
|
+
}
|
|
101
|
+
}, [isGenerating, generationPrompt]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function EditorEmptyState({ onCreate, onExit }: Pick<EditorLayoutProps, 'onCreate' | 'onExit'>) {
|
|
105
|
+
const setConfig = useConfigStore((s) => s.setConfig)
|
|
106
|
+
const setActiveProject = useEditorStore((s) => s.setActiveProject)
|
|
107
|
+
|
|
108
|
+
function startFromTemplate(tplId: string, tplName: string) {
|
|
109
|
+
const id = onCreate?.(tplName) ?? `local-${Date.now()}` // eslint-disable-line react-hooks/purity
|
|
110
|
+
setActiveProject(id)
|
|
111
|
+
setConfig(buildTemplate(tplId, tplName))
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="h-full flex items-center justify-center">
|
|
116
|
+
<div className="flex flex-col items-center text-center px-6 max-w-md">
|
|
117
|
+
<div className="w-12 h-12 rounded-xl bg-bg-3 border border-border-default flex items-center justify-center mb-4">
|
|
118
|
+
<FolderOpen size={20} className="text-text-3" />
|
|
119
|
+
</div>
|
|
120
|
+
<h2 className="text-[16px] font-display font-semibold text-text-1 mb-1">No project selected</h2>
|
|
121
|
+
<p className="text-text-2 text-[13px] mb-6">Open a project from the Dashboard, or start from a template.</p>
|
|
122
|
+
|
|
123
|
+
<button
|
|
124
|
+
onClick={() => onExit?.()}
|
|
125
|
+
className="px-5 py-2 rounded-xl bg-green text-black text-[13px] font-semibold hover:bg-green-dim active:scale-[0.97] transition-all mb-6"
|
|
126
|
+
>
|
|
127
|
+
Go to Dashboard
|
|
128
|
+
</button>
|
|
129
|
+
|
|
130
|
+
<div className="grid grid-cols-2 gap-2 w-full">
|
|
131
|
+
{templateMeta.map((tpl) => {
|
|
132
|
+
const rgb = hexToRgb(tpl.accent)
|
|
133
|
+
const Icon = templateIcons[tpl.icon] || Layers
|
|
134
|
+
return (
|
|
135
|
+
<button
|
|
136
|
+
key={tpl.id}
|
|
137
|
+
onClick={() => startFromTemplate(tpl.id, tpl.name)}
|
|
138
|
+
className="group relative bg-bg-1 border border-border-default rounded-lg p-3 text-left transition-all hover:border-border-hover card-lift hover:card-lift-hover active:scale-[0.97]"
|
|
139
|
+
>
|
|
140
|
+
<div
|
|
141
|
+
className="absolute inset-0 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"
|
|
142
|
+
style={{ background: `rgba(${rgb}, 0.06)` }}
|
|
143
|
+
/>
|
|
144
|
+
<div className="relative">
|
|
145
|
+
<div className="flex items-center gap-2 mb-1.5">
|
|
146
|
+
<div
|
|
147
|
+
className="w-6 h-6 rounded flex items-center justify-center shrink-0 transition-all opacity-70 group-hover:opacity-100 group-hover:scale-110"
|
|
148
|
+
style={{ background: `rgba(${rgb}, 0.12)`, color: tpl.accent }}
|
|
149
|
+
>
|
|
150
|
+
<Icon size={12} />
|
|
151
|
+
</div>
|
|
152
|
+
<div className="text-[11.5px] font-semibold text-text-0">{tpl.name}</div>
|
|
153
|
+
</div>
|
|
154
|
+
<div className="text-[10px] text-text-2 leading-snug mb-1.5">{tpl.description}</div>
|
|
155
|
+
<div className="text-[10px] text-text-3 flex items-center gap-1">
|
|
156
|
+
<Layers size={9} />
|
|
157
|
+
{tpl.blockCount} blocks
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</button>
|
|
161
|
+
)
|
|
162
|
+
})}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function EditorLayout({ onConfigChange, onRename, onCreate, activeProject, onExit, onServerFallback }: EditorLayoutProps = {}) {
|
|
170
|
+
useAutoSaveToProject(onConfigChange)
|
|
171
|
+
useGenerationOrchestration(onConfigChange, onRename, onServerFallback)
|
|
172
|
+
const previewMode = useEditorStore((s) => s.previewMode)
|
|
173
|
+
const activeProjectId = useEditorStore((s) => s.activeProjectId)
|
|
174
|
+
|
|
175
|
+
if (!activeProjectId) {
|
|
176
|
+
return <EditorEmptyState onCreate={onCreate} onExit={onExit} />
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className="h-full flex flex-col relative">
|
|
181
|
+
<div className="flex-1 flex overflow-hidden">
|
|
182
|
+
{!previewMode && <LeftSidebar />}
|
|
183
|
+
<div className="flex-1 flex flex-col min-w-0 relative">
|
|
184
|
+
<CanvasToolbar activeProject={activeProject} onExit={onExit} />
|
|
185
|
+
<div className="flex-1 flex flex-col overflow-hidden relative">
|
|
186
|
+
<Canvas />
|
|
187
|
+
<JsonDrawer />
|
|
188
|
+
<GenerationOverlay />
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
{!previewMode && <RightSidebar />}
|
|
192
|
+
</div>
|
|
193
|
+
<VersionHistory />
|
|
194
|
+
</div>
|
|
195
|
+
)
|
|
196
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useEffect, useReducer } from 'react'
|
|
2
|
+
import { X } from 'lucide-react'
|
|
3
|
+
import { useEditorStore } from '@/store/editorStore'
|
|
4
|
+
|
|
5
|
+
const steps = [
|
|
6
|
+
{ label: 'Analyzing prompt', duration: 3 },
|
|
7
|
+
{ label: 'Generating layout', duration: 5 },
|
|
8
|
+
{ label: 'Writing copy', duration: 4 },
|
|
9
|
+
{ label: 'Applying theme', duration: 3 },
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
type OverlayState = {
|
|
13
|
+
elapsed: number
|
|
14
|
+
showAfterFade: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type OverlayAction =
|
|
18
|
+
| { type: 'start' }
|
|
19
|
+
| { type: 'tick' }
|
|
20
|
+
| { type: 'hide' }
|
|
21
|
+
|
|
22
|
+
function overlayReducer(state: OverlayState, action: OverlayAction): OverlayState {
|
|
23
|
+
switch (action.type) {
|
|
24
|
+
case 'start':
|
|
25
|
+
return { elapsed: 0, showAfterFade: true }
|
|
26
|
+
case 'tick':
|
|
27
|
+
return state.showAfterFade ? { ...state, elapsed: state.elapsed + 1 } : state
|
|
28
|
+
case 'hide':
|
|
29
|
+
return { ...state, showAfterFade: false }
|
|
30
|
+
default:
|
|
31
|
+
return state
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function GenerationOverlay() {
|
|
36
|
+
const isGenerating = useEditorStore((s) => s.isGenerating)
|
|
37
|
+
const clearGeneration = useEditorStore((s) => s.clearGeneration)
|
|
38
|
+
const [{ elapsed, showAfterFade }, dispatch] = useReducer(overlayReducer, {
|
|
39
|
+
elapsed: 0,
|
|
40
|
+
showAfterFade: false,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (isGenerating) {
|
|
45
|
+
dispatch({ type: 'start' })
|
|
46
|
+
const timer = setInterval(() => dispatch({ type: 'tick' }), 1000)
|
|
47
|
+
return () => clearInterval(timer)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const timer = setTimeout(() => dispatch({ type: 'hide' }), 600)
|
|
51
|
+
return () => clearTimeout(timer)
|
|
52
|
+
}, [isGenerating])
|
|
53
|
+
|
|
54
|
+
const visible = isGenerating || showAfterFade
|
|
55
|
+
const fading = !isGenerating && showAfterFade
|
|
56
|
+
|
|
57
|
+
if (!visible) return null
|
|
58
|
+
|
|
59
|
+
// Determine active step based on elapsed time
|
|
60
|
+
let stepTime = 0
|
|
61
|
+
let activeStep = steps.length - 1
|
|
62
|
+
for (let i = 0; i < steps.length; i++) {
|
|
63
|
+
stepTime += steps[i].duration
|
|
64
|
+
if (elapsed < stepTime) { activeStep = i; break }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div
|
|
69
|
+
className={`absolute inset-0 z-50 flex items-center justify-center transition-opacity duration-600 ${
|
|
70
|
+
fading ? 'opacity-0' : 'opacity-100'
|
|
71
|
+
}`}
|
|
72
|
+
>
|
|
73
|
+
{/* Backdrop */}
|
|
74
|
+
<div className="absolute inset-0 bg-bg-0/90 backdrop-blur-md" />
|
|
75
|
+
|
|
76
|
+
{/* Animated glow orb */}
|
|
77
|
+
<div
|
|
78
|
+
className="absolute w-[400px] h-[400px] rounded-full opacity-20 blur-[100px]"
|
|
79
|
+
style={{
|
|
80
|
+
background: 'radial-gradient(circle, var(--color-green) 0%, transparent 70%)',
|
|
81
|
+
animation: 'gen-orb 4s ease-in-out infinite',
|
|
82
|
+
}}
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
<div className="relative flex flex-col items-center gap-8 max-w-md px-8">
|
|
86
|
+
{/* Wireframe animation */}
|
|
87
|
+
<div className="relative w-[200px] h-[130px] rounded-lg border border-border-default bg-bg-1/80 overflow-hidden">
|
|
88
|
+
{/* Animated wireframe blocks */}
|
|
89
|
+
<div className="absolute top-0 left-0 right-0 h-4 bg-bg-3/60 flex items-center px-2 gap-1">
|
|
90
|
+
<div className="w-6 h-1.5 rounded-sm bg-green/40" />
|
|
91
|
+
<div className="flex-1" />
|
|
92
|
+
<div className="w-3 h-1.5 rounded-sm bg-bg-4" />
|
|
93
|
+
<div className="w-3 h-1.5 rounded-sm bg-bg-4" />
|
|
94
|
+
<div className="w-3 h-1.5 rounded-sm bg-bg-4" />
|
|
95
|
+
</div>
|
|
96
|
+
<div className="absolute top-6 left-3 right-3 space-y-1.5">
|
|
97
|
+
<div className="h-2 w-16 rounded-sm bg-green/30 gen-shimmer" style={{ animationDelay: '0ms' }} />
|
|
98
|
+
<div className="h-4 w-full rounded-sm bg-bg-4/80 gen-shimmer" style={{ animationDelay: '100ms' }} />
|
|
99
|
+
<div className="h-2 w-3/4 rounded-sm bg-bg-4/50 gen-shimmer" style={{ animationDelay: '200ms' }} />
|
|
100
|
+
<div className="flex gap-1.5 pt-1">
|
|
101
|
+
<div className="h-3 w-12 rounded-sm bg-green/40 gen-shimmer" style={{ animationDelay: '300ms' }} />
|
|
102
|
+
<div className="h-3 w-10 rounded-sm bg-bg-4/60 gen-shimmer" style={{ animationDelay: '350ms' }} />
|
|
103
|
+
</div>
|
|
104
|
+
</div>
|
|
105
|
+
<div className="absolute bottom-2 left-3 right-3 flex gap-1.5">
|
|
106
|
+
<div className="flex-1 h-8 rounded bg-bg-3/60 gen-shimmer" style={{ animationDelay: '400ms' }} />
|
|
107
|
+
<div className="flex-1 h-8 rounded bg-bg-3/60 gen-shimmer" style={{ animationDelay: '500ms' }} />
|
|
108
|
+
<div className="flex-1 h-8 rounded bg-bg-3/60 gen-shimmer" style={{ animationDelay: '600ms' }} />
|
|
109
|
+
</div>
|
|
110
|
+
{/* Scan line */}
|
|
111
|
+
<div className="absolute left-0 right-0 h-px bg-green/40 gen-scan" />
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Status */}
|
|
115
|
+
<div className="text-center">
|
|
116
|
+
<p className="text-text-0 text-[16px] font-display font-semibold mb-2 tracking-tight">
|
|
117
|
+
Building your site
|
|
118
|
+
</p>
|
|
119
|
+
<div className="flex items-center justify-center gap-2 text-green text-[13px] tabular-nums">
|
|
120
|
+
<div className="w-4 h-4 rounded-full border-2 border-green/30 border-t-green animate-spin" />
|
|
121
|
+
<span>{elapsed}s</span>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
|
|
125
|
+
{/* Progress steps */}
|
|
126
|
+
<div className="w-full space-y-2">
|
|
127
|
+
{steps.map((step, i) => (
|
|
128
|
+
<div key={step.label} className="flex items-center gap-3">
|
|
129
|
+
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-semibold border transition-all duration-500 ${
|
|
130
|
+
i < activeStep
|
|
131
|
+
? 'bg-green/20 border-green/40 text-green'
|
|
132
|
+
: i === activeStep
|
|
133
|
+
? 'border-green text-green animate-pulse'
|
|
134
|
+
: 'border-border-default text-text-3'
|
|
135
|
+
}`}>
|
|
136
|
+
{i < activeStep ? (
|
|
137
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
|
|
138
|
+
<path d="M2 5L4 7L8 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
139
|
+
</svg>
|
|
140
|
+
) : (
|
|
141
|
+
<span>{i + 1}</span>
|
|
142
|
+
)}
|
|
143
|
+
</div>
|
|
144
|
+
<span className={`text-[12px] transition-colors duration-500 ${
|
|
145
|
+
i < activeStep ? 'text-text-2' : i === activeStep ? 'text-text-0 font-medium' : 'text-text-3'
|
|
146
|
+
}`}>
|
|
147
|
+
{step.label}
|
|
148
|
+
</span>
|
|
149
|
+
{i === activeStep && (
|
|
150
|
+
<div className="flex-1 h-1 rounded-full bg-bg-3 overflow-hidden ml-auto max-w-[80px]">
|
|
151
|
+
<div className="h-full bg-green/60 rounded-full gen-progress" />
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
{/* Cancel */}
|
|
159
|
+
<button
|
|
160
|
+
onClick={() => clearGeneration()}
|
|
161
|
+
className="px-4 py-2 rounded-lg bg-bg-2 text-text-2 text-[12px] border border-border-default hover:bg-bg-3 hover:text-text-0 hover:border-border-hover transition-all inline-flex items-center gap-1.5"
|
|
162
|
+
>
|
|
163
|
+
<X size={12} />
|
|
164
|
+
Cancel
|
|
165
|
+
</button>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<style>{`
|
|
169
|
+
@keyframes gen-orb {
|
|
170
|
+
0%, 100% { transform: translate(-30%, -20%) scale(1); }
|
|
171
|
+
33% { transform: translate(20%, -10%) scale(1.1); }
|
|
172
|
+
66% { transform: translate(-10%, 20%) scale(0.9); }
|
|
173
|
+
}
|
|
174
|
+
.gen-shimmer {
|
|
175
|
+
animation: gen-shimmer 2s ease-in-out infinite;
|
|
176
|
+
}
|
|
177
|
+
@keyframes gen-shimmer {
|
|
178
|
+
0%, 100% { opacity: 0.4; }
|
|
179
|
+
50% { opacity: 0.8; }
|
|
180
|
+
}
|
|
181
|
+
.gen-scan {
|
|
182
|
+
animation: gen-scan 2.5s ease-in-out infinite;
|
|
183
|
+
}
|
|
184
|
+
@keyframes gen-scan {
|
|
185
|
+
0% { top: 0; opacity: 0; }
|
|
186
|
+
10% { opacity: 1; }
|
|
187
|
+
90% { opacity: 1; }
|
|
188
|
+
100% { top: 100%; opacity: 0; }
|
|
189
|
+
}
|
|
190
|
+
.gen-progress {
|
|
191
|
+
animation: gen-progress 3s ease-in-out infinite;
|
|
192
|
+
}
|
|
193
|
+
@keyframes gen-progress {
|
|
194
|
+
0% { width: 0%; }
|
|
195
|
+
50% { width: 80%; }
|
|
196
|
+
100% { width: 100%; }
|
|
197
|
+
}
|
|
198
|
+
`}</style>
|
|
199
|
+
</div>
|
|
200
|
+
)
|
|
201
|
+
}
|