@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,203 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { ChevronLeft, ChevronRight, Star, Quote } from 'lucide-react'
|
|
3
|
+
import type { BlockConfig } from '../types'
|
|
4
|
+
|
|
5
|
+
interface Testimonial {
|
|
6
|
+
name: string
|
|
7
|
+
role: string
|
|
8
|
+
quote: string
|
|
9
|
+
rating?: number
|
|
10
|
+
avatar?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface TestimonialsProps {
|
|
14
|
+
title?: string
|
|
15
|
+
subtitle?: string
|
|
16
|
+
items?: Testimonial[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const defaultTestimonials: Testimonial[] = [
|
|
20
|
+
{ name: 'Sarah Chen', role: 'CEO at TechCorp', quote: 'OpenPage completely changed how we build landing pages. The JSON config approach is genius.', rating: 5 },
|
|
21
|
+
{ name: 'Marcus Johnson', role: 'Lead Developer', quote: 'Finally, a tool where both designers and AI agents can work together seamlessly.', rating: 5 },
|
|
22
|
+
{ name: 'Emma Wilson', role: 'Product Manager', quote: 'We shipped our marketing site in half the time. The component library is incredible.', rating: 4 },
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
function StarRating({ rating }: { rating: number }) {
|
|
26
|
+
return (
|
|
27
|
+
<div className="flex gap-0.5">
|
|
28
|
+
{[1, 2, 3, 4, 5].map((i) => (
|
|
29
|
+
<Star
|
|
30
|
+
key={i}
|
|
31
|
+
size={12}
|
|
32
|
+
className={i <= rating ? 'text-status-yellow fill-status-yellow' : 'text-text-3'}
|
|
33
|
+
/>
|
|
34
|
+
))}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function TestimonialsCards({ props }: { props: TestimonialsProps }) {
|
|
40
|
+
const items = props.items || defaultTestimonials
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<section className="px-6 @md:px-10 py-16 @md:py-20">
|
|
44
|
+
<div className="reveal-fade-up reveal-d1 text-center mb-10">
|
|
45
|
+
<h2 className="text-2xl @md:text-3xl font-bold tracking-tight mb-2">
|
|
46
|
+
{props.title || 'What people say'}
|
|
47
|
+
</h2>
|
|
48
|
+
{props.subtitle && (
|
|
49
|
+
<p className="text-text-2 text-sm max-w-lg mx-auto">{props.subtitle}</p>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<div className="grid grid-cols-1 @2xl:grid-cols-3 gap-4">
|
|
54
|
+
{items.map((item, i) => (
|
|
55
|
+
<div
|
|
56
|
+
key={i}
|
|
57
|
+
className={`reveal-fade-up reveal-d${Math.min(i + 2, 8)} bg-bg-2 border border-border-default rounded-xl p-5 transition-all hover:border-border-hover`}
|
|
58
|
+
>
|
|
59
|
+
<Quote size={20} className="text-green/30 mb-3" />
|
|
60
|
+
<p className="text-[13px] text-text-1 leading-relaxed mb-4 italic">
|
|
61
|
+
"{item.quote}"
|
|
62
|
+
</p>
|
|
63
|
+
{item.rating && <StarRating rating={item.rating} />}
|
|
64
|
+
<div className="flex items-center gap-3 mt-4 pt-4 border-t border-border-subtle">
|
|
65
|
+
{item.avatar ? (
|
|
66
|
+
<img src={item.avatar} alt={item.name} className="w-9 h-9 rounded-full object-cover border border-border-default" />
|
|
67
|
+
) : (
|
|
68
|
+
<div className="w-9 h-9 rounded-full bg-bg-4 border border-border-default flex items-center justify-center text-[11px] font-semibold text-text-2">
|
|
69
|
+
{item.name.split(' ').map(n => n[0]).join('')}
|
|
70
|
+
</div>
|
|
71
|
+
)}
|
|
72
|
+
<div>
|
|
73
|
+
<div className="text-[12.5px] font-semibold">{item.name}</div>
|
|
74
|
+
<div className="text-[11px] text-text-3">{item.role}</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
</section>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function TestimonialsCarousel({ props }: { props: TestimonialsProps }) {
|
|
85
|
+
const items = props.items || defaultTestimonials
|
|
86
|
+
const [activeIndex, setActiveIndex] = useState(0)
|
|
87
|
+
|
|
88
|
+
const goTo = (index: number) => {
|
|
89
|
+
setActiveIndex(((index % items.length) + items.length) % items.length)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (items.length === 0) return null
|
|
93
|
+
const safeIndex = Math.min(activeIndex, items.length - 1)
|
|
94
|
+
const item = items[safeIndex]
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<section className="px-6 @md:px-10 py-16 @md:py-20">
|
|
98
|
+
<div className="reveal-fade-up reveal-d1 text-center mb-10">
|
|
99
|
+
<h2 className="text-2xl @md:text-3xl font-bold tracking-tight mb-2">
|
|
100
|
+
{props.title || 'What people say'}
|
|
101
|
+
</h2>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<div className="reveal-fade-up reveal-d2 max-w-2xl mx-auto">
|
|
105
|
+
<div className="text-center">
|
|
106
|
+
<Quote size={32} className="text-green/20 mx-auto mb-4" />
|
|
107
|
+
<p className="text-lg text-text-0 leading-relaxed mb-4 italic">
|
|
108
|
+
"{item.quote}"
|
|
109
|
+
</p>
|
|
110
|
+
{item.rating && (
|
|
111
|
+
<div className="flex justify-center mb-4">
|
|
112
|
+
<StarRating rating={item.rating} />
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
<div className="flex items-center justify-center gap-3">
|
|
116
|
+
{item.avatar ? (
|
|
117
|
+
<img src={item.avatar} alt={item.name} className="w-10 h-10 rounded-full object-cover border border-border-default" />
|
|
118
|
+
) : (
|
|
119
|
+
<div className="w-10 h-10 rounded-full bg-bg-4 border border-border-default flex items-center justify-center text-xs font-semibold text-text-2">
|
|
120
|
+
{item.name.split(' ').map(n => n[0]).join('')}
|
|
121
|
+
</div>
|
|
122
|
+
)}
|
|
123
|
+
<div className="text-left">
|
|
124
|
+
<div className="text-sm font-semibold">{item.name}</div>
|
|
125
|
+
<div className="text-[11px] text-text-3">{item.role}</div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Navigation */}
|
|
130
|
+
<div className="flex items-center justify-center gap-4 mt-6">
|
|
131
|
+
<button
|
|
132
|
+
onClick={() => goTo(safeIndex - 1)}
|
|
133
|
+
className="w-8 h-8 rounded-full border border-border-default bg-bg-2 flex items-center justify-center text-text-2 hover:text-text-0 hover:border-border-hover transition-colors"
|
|
134
|
+
>
|
|
135
|
+
<ChevronLeft size={16} />
|
|
136
|
+
</button>
|
|
137
|
+
<div className="flex gap-1.5">
|
|
138
|
+
{items.map((_, i) => (
|
|
139
|
+
<button
|
|
140
|
+
key={i}
|
|
141
|
+
onClick={() => goTo(i)}
|
|
142
|
+
className={`w-2 h-2 rounded-full transition-colors ${
|
|
143
|
+
i === safeIndex ? 'bg-green' : 'bg-bg-4 hover:bg-bg-5'
|
|
144
|
+
}`}
|
|
145
|
+
/>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
<button
|
|
149
|
+
onClick={() => goTo(safeIndex + 1)}
|
|
150
|
+
className="w-8 h-8 rounded-full border border-border-default bg-bg-2 flex items-center justify-center text-text-2 hover:text-text-0 hover:border-border-hover transition-colors"
|
|
151
|
+
>
|
|
152
|
+
<ChevronRight size={16} />
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</section>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function TestimonialsSpotlight({ props }: { props: TestimonialsProps }) {
|
|
162
|
+
const items = props.items || defaultTestimonials
|
|
163
|
+
const item = items[0]
|
|
164
|
+
if (!item) return null
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<section className="px-6 @md:px-10 py-16 @md:py-24">
|
|
168
|
+
<div className="max-w-2xl mx-auto text-center">
|
|
169
|
+
<div className="reveal-fade-up reveal-d1"><Quote size={48} className="text-green/20 mx-auto mb-6" /></div>
|
|
170
|
+
<p className="reveal-fade-up reveal-d2 text-xl @md:text-2xl text-text-0 leading-relaxed mb-8 italic font-display">
|
|
171
|
+
"{item.quote}"
|
|
172
|
+
</p>
|
|
173
|
+
{item.rating && (
|
|
174
|
+
<div className="flex justify-center mb-6">
|
|
175
|
+
<StarRating rating={item.rating} />
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
<div className="flex items-center justify-center gap-3">
|
|
179
|
+
<div className="w-12 h-12 rounded-full bg-bg-4 border border-border-default flex items-center justify-center text-sm font-semibold text-text-2">
|
|
180
|
+
{item.name.split(' ').map(n => n[0]).join('')}
|
|
181
|
+
</div>
|
|
182
|
+
<div className="text-left">
|
|
183
|
+
<div className="text-sm font-semibold">{item.name}</div>
|
|
184
|
+
<div className="text-[12px] text-text-3">{item.role}</div>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</section>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function TestimonialsBlock({ block }: { block: BlockConfig }) {
|
|
193
|
+
const props = block.props as unknown as TestimonialsProps
|
|
194
|
+
|
|
195
|
+
switch (block.variant) {
|
|
196
|
+
case 'carousel':
|
|
197
|
+
return <TestimonialsCarousel props={props} />
|
|
198
|
+
case 'spotlight':
|
|
199
|
+
return <TestimonialsSpotlight props={props} />
|
|
200
|
+
default:
|
|
201
|
+
return <TestimonialsCards props={props} />
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type BlockType =
|
|
2
|
+
| 'navbar'
|
|
3
|
+
| 'hero'
|
|
4
|
+
| 'features'
|
|
5
|
+
| 'pricing'
|
|
6
|
+
| 'cta'
|
|
7
|
+
| 'footer'
|
|
8
|
+
| 'testimonials'
|
|
9
|
+
| 'stats'
|
|
10
|
+
| 'faq'
|
|
11
|
+
| 'team'
|
|
12
|
+
| 'contact'
|
|
13
|
+
| 'newsletter'
|
|
14
|
+
| 'logocloud'
|
|
15
|
+
| 'divider'
|
|
16
|
+
| 'banner'
|
|
17
|
+
| 'content'
|
|
18
|
+
| 'image'
|
|
19
|
+
| 'video'
|
|
20
|
+
| 'gallery'
|
|
21
|
+
|
|
22
|
+
export type BlockVariant = string
|
|
23
|
+
|
|
24
|
+
export interface BlockConfig {
|
|
25
|
+
id: string
|
|
26
|
+
type: BlockType
|
|
27
|
+
variant: BlockVariant
|
|
28
|
+
props: Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ThemeConfig {
|
|
32
|
+
// Backgrounds
|
|
33
|
+
bg0: string
|
|
34
|
+
bg1: string
|
|
35
|
+
bg2: string
|
|
36
|
+
bg3: string
|
|
37
|
+
bg4: string
|
|
38
|
+
bg5: string
|
|
39
|
+
// Text
|
|
40
|
+
text0: string
|
|
41
|
+
text1: string
|
|
42
|
+
text2: string
|
|
43
|
+
text3: string
|
|
44
|
+
// Accent
|
|
45
|
+
accent: string
|
|
46
|
+
accentDim: string
|
|
47
|
+
// Borders
|
|
48
|
+
borderDefault: string
|
|
49
|
+
borderSubtle: string
|
|
50
|
+
borderHover: string
|
|
51
|
+
// Fonts
|
|
52
|
+
fontSans: string
|
|
53
|
+
fontDisplay: string
|
|
54
|
+
fontMono: string
|
|
55
|
+
// Radius
|
|
56
|
+
radius: number
|
|
57
|
+
radiusLg: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface PageConfig {
|
|
61
|
+
id: string
|
|
62
|
+
name: string
|
|
63
|
+
path: string
|
|
64
|
+
blocks: BlockConfig[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SiteConfig {
|
|
68
|
+
name: string
|
|
69
|
+
pages?: PageConfig[]
|
|
70
|
+
blocks: BlockConfig[]
|
|
71
|
+
theme?: Partial<ThemeConfig>
|
|
72
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Play } from 'lucide-react'
|
|
2
|
+
import type { BlockConfig } from '../types'
|
|
3
|
+
|
|
4
|
+
function extractYouTubeId(url: string): string | null {
|
|
5
|
+
const match = url.match(/(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|v\/))([a-zA-Z0-9_-]{11})/)
|
|
6
|
+
return match?.[1] || null
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function extractVimeoId(url: string): string | null {
|
|
10
|
+
const match = url.match(/vimeo\.com\/(\d+)/)
|
|
11
|
+
return match?.[1] || null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function Placeholder() {
|
|
15
|
+
return (
|
|
16
|
+
<div className="w-full aspect-video bg-gradient-to-br from-bg-3 to-bg-4 rounded-lg flex items-center justify-center">
|
|
17
|
+
<div className="w-14 h-14 rounded-full bg-bg-5/50 border border-border-default flex items-center justify-center">
|
|
18
|
+
<Play size={24} className="text-text-3 ml-0.5" />
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function VideoBlock({ block }: { block: BlockConfig }) {
|
|
25
|
+
const { variant, props } = block
|
|
26
|
+
const url = (props.url as string) || ''
|
|
27
|
+
const title = props.title as string | undefined
|
|
28
|
+
|
|
29
|
+
let embedUrl: string | null = null
|
|
30
|
+
|
|
31
|
+
if (variant === 'vimeo') {
|
|
32
|
+
const id = extractVimeoId(url)
|
|
33
|
+
if (id) embedUrl = `https://player.vimeo.com/video/${id}`
|
|
34
|
+
} else {
|
|
35
|
+
// youtube (default)
|
|
36
|
+
const id = extractYouTubeId(url)
|
|
37
|
+
if (id) embedUrl = `https://www.youtube-nocookie.com/embed/${id}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="px-6 py-12 @lg:px-16 @lg:py-16">
|
|
42
|
+
{title && <h2 className="reveal-fade-up reveal-d1 text-xl font-display font-semibold mb-4 text-center">{title}</h2>}
|
|
43
|
+
{embedUrl ? (
|
|
44
|
+
<div className="reveal-scale reveal-d2 w-full aspect-video rounded-lg overflow-hidden border border-border-default">
|
|
45
|
+
<iframe
|
|
46
|
+
src={embedUrl}
|
|
47
|
+
title={title || 'Video'}
|
|
48
|
+
className="w-full h-full"
|
|
49
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
50
|
+
allowFullScreen
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
) : (
|
|
54
|
+
<Placeholder />
|
|
55
|
+
)}
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react'
|
|
2
|
+
import { Send, Check, X } from 'lucide-react'
|
|
3
|
+
import { toast } from 'sonner'
|
|
4
|
+
import { useConfigStore } from '@/store/configStore'
|
|
5
|
+
import { blockMetadata } from '@/lib/block-metadata'
|
|
6
|
+
import { themePresets } from '@/lib/theme-presets'
|
|
7
|
+
import type { BlockConfig } from '@/blocks/types'
|
|
8
|
+
|
|
9
|
+
interface ChatMessage {
|
|
10
|
+
id: string
|
|
11
|
+
role: 'user' | 'agent'
|
|
12
|
+
text: string
|
|
13
|
+
applied?: boolean
|
|
14
|
+
patch?: {
|
|
15
|
+
path: string
|
|
16
|
+
blockId?: string
|
|
17
|
+
propKey?: string
|
|
18
|
+
value?: string
|
|
19
|
+
added?: string[]
|
|
20
|
+
removed?: string[]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const initialMessages: ChatMessage[] = [
|
|
25
|
+
{
|
|
26
|
+
id: '1',
|
|
27
|
+
role: 'agent',
|
|
28
|
+
text: 'Hi! I can help you build and modify your site. Try asking me to add sections, change content, or tweak styles.',
|
|
29
|
+
},
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
function TypingIndicator() {
|
|
33
|
+
return (
|
|
34
|
+
<div className="self-start flex gap-1 px-4 py-3 bg-green-glow rounded-xl rounded-bl-sm border border-green/10">
|
|
35
|
+
{[0, 1, 2].map((i) => (
|
|
36
|
+
<span
|
|
37
|
+
key={i}
|
|
38
|
+
className="w-1.5 h-1.5 rounded-full bg-green opacity-40"
|
|
39
|
+
style={{
|
|
40
|
+
animation: 'typeDot 1.4s infinite',
|
|
41
|
+
animationDelay: `${i * 0.2}s`,
|
|
42
|
+
}}
|
|
43
|
+
/>
|
|
44
|
+
))}
|
|
45
|
+
<style>{`
|
|
46
|
+
@keyframes typeDot {
|
|
47
|
+
0%, 60%, 100% { opacity: 0.4; transform: translateY(0); }
|
|
48
|
+
30% { opacity: 1; transform: translateY(-3px); }
|
|
49
|
+
}
|
|
50
|
+
`}</style>
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Pattern matching for demo agent responses
|
|
56
|
+
function generateResponse(input: string, blocks: { id: string; type: string; variant: string; props: Record<string, unknown> }[]): ChatMessage | { action: 'addBlock'; block: BlockConfig; message: string } | { action: 'removeBlock'; blockId: string; message: string } | { action: 'changeVariant'; blockId: string; variant: string; message: string } | { action: 'changeTheme'; themeId: string; message: string } {
|
|
57
|
+
const lower = input.toLowerCase()
|
|
58
|
+
const heroBlock = blocks.find((b) => b.type === 'hero')
|
|
59
|
+
|
|
60
|
+
// Change headline
|
|
61
|
+
if (lower.includes('change') && lower.includes('headline') && heroBlock) {
|
|
62
|
+
const match = input.match(/["'](.+?)["']/) || input.match(/to\s+(.+)/i)
|
|
63
|
+
const newHeadline = match?.[1]?.trim() || 'Your New Headline'
|
|
64
|
+
const oldHeadline = String(heroBlock.props.headline || 'Build websites with JSON')
|
|
65
|
+
return {
|
|
66
|
+
id: `msg-${Date.now()}`,
|
|
67
|
+
role: 'agent',
|
|
68
|
+
text: `I'll update the hero headline for you.`,
|
|
69
|
+
patch: {
|
|
70
|
+
path: `blocks[${blocks.indexOf(heroBlock)}].props.headline`,
|
|
71
|
+
blockId: heroBlock.id,
|
|
72
|
+
propKey: 'headline',
|
|
73
|
+
value: newHeadline,
|
|
74
|
+
removed: [`"${oldHeadline}"`],
|
|
75
|
+
added: [`"${newHeadline}"`],
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Add block: "add a pricing section", "add FAQ", "add testimonials"
|
|
81
|
+
const addMatch = lower.match(/add\s+(?:a\s+)?(\w+)/)
|
|
82
|
+
if (addMatch) {
|
|
83
|
+
const blockType = addMatch[1].replace(/s$/, '') // strip trailing s
|
|
84
|
+
const meta = blockMetadata.find((b) => b.type === blockType || b.label.toLowerCase().includes(blockType))
|
|
85
|
+
if (meta) {
|
|
86
|
+
const block: BlockConfig = {
|
|
87
|
+
id: `block-${Date.now()}`,
|
|
88
|
+
type: meta.type,
|
|
89
|
+
variant: meta.variants[0],
|
|
90
|
+
props: { ...meta.defaultProps },
|
|
91
|
+
}
|
|
92
|
+
return { action: 'addBlock', block, message: `Adding a ${meta.label} block.` }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Remove block: "remove the FAQ", "delete the pricing"
|
|
97
|
+
const removeMatch = lower.match(/(?:remove|delete)\s+(?:the\s+)?(\w+)/)
|
|
98
|
+
if (removeMatch) {
|
|
99
|
+
const blockType = removeMatch[1].replace(/s$/, '')
|
|
100
|
+
const found = blocks.find((b) => b.type === blockType || b.type.includes(blockType))
|
|
101
|
+
if (found) {
|
|
102
|
+
return { action: 'removeBlock', blockId: found.id, message: `Removing the ${found.type} block.` }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Change variant: "make the hero a split layout", "change hero to minimal"
|
|
107
|
+
const variantMatch = lower.match(/(?:make|change|switch)\s+(?:the\s+)?(\w+)\s+(?:to\s+|a\s+)?(\w+)/)
|
|
108
|
+
if (variantMatch) {
|
|
109
|
+
const blockType = variantMatch[1]
|
|
110
|
+
const variant = variantMatch[2]
|
|
111
|
+
const found = blocks.find((b) => b.type === blockType || b.type.includes(blockType))
|
|
112
|
+
if (found) {
|
|
113
|
+
const meta = blockMetadata.find((b) => b.type === found.type)
|
|
114
|
+
const matchedVariant = meta?.variants.find((v) => v.includes(variant))
|
|
115
|
+
if (matchedVariant) {
|
|
116
|
+
return { action: 'changeVariant', blockId: found.id, variant: matchedVariant, message: `Changing ${found.type} to ${matchedVariant} variant.` }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Change theme: "make it blue", "switch to ocean theme", "use midnight theme"
|
|
122
|
+
const themeMatch = lower.match(/(?:make it|switch to|use|apply)\s+(?:the\s+)?(\w+)\s*(?:theme)?/)
|
|
123
|
+
if (themeMatch) {
|
|
124
|
+
const themeName = themeMatch[1]
|
|
125
|
+
const preset = themePresets.find((p) => p.id.includes(themeName) || p.name.toLowerCase().includes(themeName))
|
|
126
|
+
if (preset) {
|
|
127
|
+
return { action: 'changeTheme', themeId: preset.id, message: `Switching to the ${preset.name} theme.` }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
id: `msg-${Date.now()}`,
|
|
133
|
+
role: 'agent',
|
|
134
|
+
text: "I can help with your site. Try:\n- \"Change the headline to 'New Title'\"\n- \"Add a pricing section\"\n- \"Remove the FAQ\"\n- \"Make the hero a split layout\"\n- \"Switch to midnight theme\"",
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function AgentPanel() {
|
|
139
|
+
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages)
|
|
140
|
+
const [input, setInput] = useState('')
|
|
141
|
+
const [showTyping, setShowTyping] = useState(false)
|
|
142
|
+
const updateBlockProps = useConfigStore((s) => s.updateBlockProps)
|
|
143
|
+
const addBlock = useConfigStore((s) => s.addBlock)
|
|
144
|
+
const removeBlock = useConfigStore((s) => s.removeBlock)
|
|
145
|
+
const updateBlock = useConfigStore((s) => s.updateBlock)
|
|
146
|
+
const setTheme = useConfigStore((s) => s.setTheme)
|
|
147
|
+
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
151
|
+
}, [messages, showTyping])
|
|
152
|
+
|
|
153
|
+
function handleApply(msg: ChatMessage) {
|
|
154
|
+
if (!msg.patch?.blockId || !msg.patch?.propKey || !msg.patch?.value) {
|
|
155
|
+
toast.error('Cannot apply: missing patch data')
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
updateBlockProps(msg.patch.blockId, { [msg.patch.propKey]: msg.patch.value })
|
|
159
|
+
setMessages((prev) =>
|
|
160
|
+
prev.map((m) => (m.id === msg.id ? { ...m, applied: true } : m))
|
|
161
|
+
)
|
|
162
|
+
toast('Patch applied')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function handleReject(msg: ChatMessage) {
|
|
166
|
+
setMessages((prev) =>
|
|
167
|
+
prev.map((m) => (m.id === msg.id ? { ...m, applied: false, patch: undefined } : m))
|
|
168
|
+
)
|
|
169
|
+
toast('Patch rejected')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function handleSend() {
|
|
173
|
+
const text = input.trim()
|
|
174
|
+
if (!text) return
|
|
175
|
+
|
|
176
|
+
const userMsg: ChatMessage = { id: `msg-${Date.now()}`, role: 'user', text }
|
|
177
|
+
setMessages((prev) => [...prev, userMsg])
|
|
178
|
+
setInput('')
|
|
179
|
+
setShowTyping(true)
|
|
180
|
+
|
|
181
|
+
// Simulate agent thinking (read fresh blocks inside timeout)
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
const state = useConfigStore.getState()
|
|
184
|
+
const pages = state.config.pages
|
|
185
|
+
const currentBlocks = pages && pages.length > 0
|
|
186
|
+
? (pages.find((p) => p.id === state.activePageId) ?? pages[0]).blocks
|
|
187
|
+
: state.config.blocks
|
|
188
|
+
const response = generateResponse(text, currentBlocks)
|
|
189
|
+
setShowTyping(false)
|
|
190
|
+
|
|
191
|
+
// Handle action-based responses
|
|
192
|
+
if ('action' in response) {
|
|
193
|
+
const agentMsg: ChatMessage = { id: `msg-${Date.now()}`, role: 'agent', text: response.message }
|
|
194
|
+
setMessages((prev) => [...prev, agentMsg])
|
|
195
|
+
|
|
196
|
+
if (response.action === 'addBlock') {
|
|
197
|
+
addBlock(response.block)
|
|
198
|
+
toast(`${response.block.type} block added`)
|
|
199
|
+
} else if (response.action === 'removeBlock') {
|
|
200
|
+
removeBlock(response.blockId)
|
|
201
|
+
toast('Block removed')
|
|
202
|
+
} else if (response.action === 'changeVariant') {
|
|
203
|
+
updateBlock(response.blockId, { variant: response.variant })
|
|
204
|
+
toast(`Variant changed to ${response.variant}`)
|
|
205
|
+
} else if (response.action === 'changeTheme') {
|
|
206
|
+
const preset = themePresets.find((p) => p.id === response.themeId)
|
|
207
|
+
if (preset) {
|
|
208
|
+
setTheme(preset.theme)
|
|
209
|
+
toast(`Theme changed to ${preset.name}`)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
setMessages((prev) => [...prev, response])
|
|
214
|
+
}
|
|
215
|
+
}, 800 + Math.random() * 600)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function handleHint(hint: string) {
|
|
219
|
+
setInput(hint)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div className="flex flex-col flex-1 overflow-hidden">
|
|
224
|
+
{/* Messages */}
|
|
225
|
+
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2.5">
|
|
226
|
+
{messages.map((msg) => (
|
|
227
|
+
<div
|
|
228
|
+
key={msg.id}
|
|
229
|
+
className={`max-w-[94%] px-3 py-2.5 rounded-xl text-[12.5px] leading-relaxed ${
|
|
230
|
+
msg.role === 'user'
|
|
231
|
+
? 'self-end bg-bg-3 text-text-0 rounded-br-sm'
|
|
232
|
+
: 'self-start bg-green-glow text-text-0 rounded-bl-sm border border-green/10'
|
|
233
|
+
}`}
|
|
234
|
+
>
|
|
235
|
+
{msg.text}
|
|
236
|
+
|
|
237
|
+
{/* JSON patch diff */}
|
|
238
|
+
{msg.patch && (
|
|
239
|
+
<div className="bg-bg-2 border border-border-default rounded-md p-2 mt-2 font-mono text-[10.5px] leading-relaxed">
|
|
240
|
+
<div className="flex items-center justify-between mb-1">
|
|
241
|
+
<span className="font-sans text-[9px] font-semibold uppercase tracking-wider text-text-3">
|
|
242
|
+
JSON Patch
|
|
243
|
+
</span>
|
|
244
|
+
{!msg.applied && (
|
|
245
|
+
<div className="flex gap-1">
|
|
246
|
+
<button
|
|
247
|
+
onClick={() => handleApply(msg)}
|
|
248
|
+
className="px-1.5 py-0.5 rounded text-[9px] bg-green/20 text-green hover:bg-green/30 transition-colors flex items-center gap-0.5"
|
|
249
|
+
>
|
|
250
|
+
<Check size={9} /> Apply
|
|
251
|
+
</button>
|
|
252
|
+
<button
|
|
253
|
+
onClick={() => handleReject(msg)}
|
|
254
|
+
className="px-1.5 py-0.5 rounded text-[9px] bg-status-red/10 text-status-red hover:bg-status-red/20 transition-colors flex items-center gap-0.5"
|
|
255
|
+
>
|
|
256
|
+
<X size={9} /> Reject
|
|
257
|
+
</button>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
{msg.applied && (
|
|
261
|
+
<span className="text-[9px] text-green font-medium flex items-center gap-0.5">
|
|
262
|
+
<Check size={9} /> Applied
|
|
263
|
+
</span>
|
|
264
|
+
)}
|
|
265
|
+
</div>
|
|
266
|
+
<div className="text-text-3 text-[10px] mb-1">{msg.patch.path}</div>
|
|
267
|
+
{msg.patch.removed?.map((line, i) => (
|
|
268
|
+
<div key={`r-${i}`} className="text-status-red line-through opacity-60">
|
|
269
|
+
- {line}
|
|
270
|
+
</div>
|
|
271
|
+
))}
|
|
272
|
+
{msg.patch.added?.map((line, i) => (
|
|
273
|
+
<div key={`a-${i}`} className="text-green">
|
|
274
|
+
+ {line}
|
|
275
|
+
</div>
|
|
276
|
+
))}
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
))}
|
|
281
|
+
{showTyping && <TypingIndicator />}
|
|
282
|
+
<div ref={messagesEndRef} />
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
{/* Input */}
|
|
286
|
+
<div className="p-2.5 border-t border-border-default">
|
|
287
|
+
<div className="flex gap-1.5">
|
|
288
|
+
<input
|
|
289
|
+
type="text"
|
|
290
|
+
placeholder="Ask the agent..."
|
|
291
|
+
value={input}
|
|
292
|
+
onChange={(e) => setInput(e.target.value)}
|
|
293
|
+
onKeyDown={(e) => { if (e.key === 'Enter') handleSend() }}
|
|
294
|
+
className="flex-1 px-3 py-2 rounded-lg border border-border-default bg-bg-2 text-text-0 text-[13px] outline-none focus:border-green placeholder:text-text-3"
|
|
295
|
+
/>
|
|
296
|
+
<button
|
|
297
|
+
onClick={handleSend}
|
|
298
|
+
className="w-9 h-9 rounded-lg bg-green flex items-center justify-center text-black shrink-0 hover:bg-green-dim transition-colors"
|
|
299
|
+
aria-label="Send message"
|
|
300
|
+
>
|
|
301
|
+
<Send size={14} />
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
<div className="flex gap-1 mt-1.5 flex-wrap">
|
|
305
|
+
{['Change the headline', 'Add a pricing section', 'Make the hero split', 'Switch to midnight theme'].map((hint) => (
|
|
306
|
+
<span
|
|
307
|
+
key={hint}
|
|
308
|
+
onClick={() => handleHint(hint)}
|
|
309
|
+
className="px-2 py-0.5 rounded-full text-[10.5px] text-text-2 border border-border-default bg-bg-2 cursor-pointer hover:border-green hover:text-green hover:bg-green-glow transition-all"
|
|
310
|
+
>
|
|
311
|
+
{hint}
|
|
312
|
+
</span>
|
|
313
|
+
))}
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
}
|