@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,95 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { useConfigStore } from '@/store/configStore'
|
|
3
|
+
import { useEditorStore } from '@/store/editorStore'
|
|
4
|
+
import { CanvasEmpty } from './CanvasEmpty'
|
|
5
|
+
import { BlockWrapper } from '@/blocks/BlockWrapper'
|
|
6
|
+
import { RenderBlock } from '@/blocks/registry'
|
|
7
|
+
import { resolveTheme, themeToCSS } from '@/lib/theme-presets'
|
|
8
|
+
import { useGoogleFonts } from '@/lib/useGoogleFonts'
|
|
9
|
+
|
|
10
|
+
export function Canvas() {
|
|
11
|
+
const blocks = useConfigStore((s) => {
|
|
12
|
+
const pages = s.config.pages
|
|
13
|
+
if (!pages || pages.length === 0) return s.config.blocks
|
|
14
|
+
const page = pages.find((p) => p.id === s.activePageId) ?? pages[0]
|
|
15
|
+
return page.blocks
|
|
16
|
+
})
|
|
17
|
+
const theme = useConfigStore((s) => s.config.theme)
|
|
18
|
+
const { selectedBlockId, selectBlock, viewport } = useEditorStore()
|
|
19
|
+
|
|
20
|
+
const resolved = useMemo(() => resolveTheme(theme), [theme])
|
|
21
|
+
const cssVars = useMemo(() => themeToCSS(resolved), [resolved])
|
|
22
|
+
useGoogleFonts([resolved.fontSans, resolved.fontDisplay, resolved.fontMono])
|
|
23
|
+
|
|
24
|
+
const maxWidth = viewport === 'desktop' ? '880px' : viewport === 'tablet' ? '768px' : '375px'
|
|
25
|
+
|
|
26
|
+
if (blocks.length === 0) {
|
|
27
|
+
return <CanvasEmpty />
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const canvasContent = (
|
|
31
|
+
<div
|
|
32
|
+
className="@container border rounded-xl min-h-[400px] relative z-[1] overflow-hidden transition-all duration-300"
|
|
33
|
+
style={{ width: '100%', maxWidth, ...cssVars, color: 'var(--color-text-0)', backgroundColor: 'var(--color-bg-1)', borderColor: 'var(--color-border-default)' } as React.CSSProperties}
|
|
34
|
+
onClick={(e) => {
|
|
35
|
+
if (e.target === e.currentTarget) selectBlock(null)
|
|
36
|
+
}}
|
|
37
|
+
role="region"
|
|
38
|
+
aria-label={`Site preview, ${blocks.length} blocks, ${viewport} viewport`}
|
|
39
|
+
>
|
|
40
|
+
{blocks.map((block) => (
|
|
41
|
+
<BlockWrapper
|
|
42
|
+
key={block.id}
|
|
43
|
+
block={block}
|
|
44
|
+
isSelected={selectedBlockId === block.id}
|
|
45
|
+
onSelect={() => selectBlock(block.id)}
|
|
46
|
+
>
|
|
47
|
+
<RenderBlock block={block} />
|
|
48
|
+
</BlockWrapper>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="flex-1 flex items-start justify-center p-6 overflow-auto relative">
|
|
55
|
+
{/* Dot grid background */}
|
|
56
|
+
<div
|
|
57
|
+
className="absolute inset-0 opacity-40 pointer-events-none"
|
|
58
|
+
style={{
|
|
59
|
+
backgroundImage: 'radial-gradient(circle, var(--color-bg-3) 1px, transparent 1px)',
|
|
60
|
+
backgroundSize: '20px 20px',
|
|
61
|
+
}}
|
|
62
|
+
/>
|
|
63
|
+
|
|
64
|
+
{viewport === 'tablet' ? (
|
|
65
|
+
<div className="relative z-[1]">
|
|
66
|
+
{/* Tablet frame */}
|
|
67
|
+
<div className="border-[12px] border-bg-4 rounded-2xl bg-bg-4 shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
|
68
|
+
<div className="rounded-lg overflow-hidden">
|
|
69
|
+
{canvasContent}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
) : viewport === 'mobile' ? (
|
|
74
|
+
<div className="relative z-[1]">
|
|
75
|
+
{/* Phone frame */}
|
|
76
|
+
<div className="border-[10px] border-bg-4 rounded-[2rem] bg-bg-4 shadow-[0_8px_32px_rgba(0,0,0,0.3)]">
|
|
77
|
+
{/* Notch */}
|
|
78
|
+
<div className="flex justify-center -mt-[4px] mb-1">
|
|
79
|
+
<div className="w-24 h-5 bg-bg-4 rounded-b-xl" />
|
|
80
|
+
</div>
|
|
81
|
+
<div className="rounded-xl overflow-hidden">
|
|
82
|
+
{canvasContent}
|
|
83
|
+
</div>
|
|
84
|
+
{/* Home indicator */}
|
|
85
|
+
<div className="flex justify-center mt-2 pb-1">
|
|
86
|
+
<div className="w-28 h-1 bg-bg-5 rounded-full" />
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
) : (
|
|
91
|
+
canvasContent
|
|
92
|
+
)}
|
|
93
|
+
</div>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { Plus } from 'lucide-react'
|
|
2
|
+
import { toast } from 'sonner'
|
|
3
|
+
import { useConfigStore } from '@/store/configStore'
|
|
4
|
+
import { useEditorStore } from '@/store/editorStore'
|
|
5
|
+
import { blockMetadata } from '@/lib/block-metadata'
|
|
6
|
+
import type { BlockConfig } from '@/blocks/types'
|
|
7
|
+
|
|
8
|
+
export function CanvasEmpty() {
|
|
9
|
+
const addBlock = useConfigStore((s) => s.addBlock)
|
|
10
|
+
const selectBlock = useEditorStore((s) => s.selectBlock)
|
|
11
|
+
|
|
12
|
+
function handleAddBlock() {
|
|
13
|
+
const heroMeta = blockMetadata.find((b) => b.type === 'hero')!
|
|
14
|
+
const block: BlockConfig = {
|
|
15
|
+
id: `block-${Date.now()}`,
|
|
16
|
+
type: heroMeta.type,
|
|
17
|
+
variant: heroMeta.variants[0],
|
|
18
|
+
props: { ...heroMeta.defaultProps },
|
|
19
|
+
}
|
|
20
|
+
addBlock(block)
|
|
21
|
+
selectBlock(block.id)
|
|
22
|
+
toast('Hero block added')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="flex-1 flex flex-col items-center justify-center gap-4 text-center p-10 relative z-[1]">
|
|
27
|
+
<h3 className="text-lg font-semibold text-text-1">Start building</h3>
|
|
28
|
+
<p className="text-[13px] text-text-3 max-w-[360px] leading-relaxed">
|
|
29
|
+
Add your first component from the library, or use the Components page to browse all blocks.
|
|
30
|
+
</p>
|
|
31
|
+
<button
|
|
32
|
+
onClick={handleAddBlock}
|
|
33
|
+
className="px-3.5 py-1.5 rounded-md bg-green text-black text-[12.5px] font-semibold border border-green hover:bg-green-dim transition-colors flex items-center gap-1.5"
|
|
34
|
+
>
|
|
35
|
+
<Plus size={14} />
|
|
36
|
+
Add Hero Block
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { useState, useRef, useEffect } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Monitor,
|
|
4
|
+
Tablet,
|
|
5
|
+
Smartphone,
|
|
6
|
+
Undo2,
|
|
7
|
+
Redo2,
|
|
8
|
+
Code,
|
|
9
|
+
Clock,
|
|
10
|
+
Eye,
|
|
11
|
+
Plus,
|
|
12
|
+
HelpCircle,
|
|
13
|
+
Download,
|
|
14
|
+
Loader2,
|
|
15
|
+
} from 'lucide-react'
|
|
16
|
+
import { toast } from 'sonner'
|
|
17
|
+
import { useEditorStore, type Viewport } from '@/store/editorStore'
|
|
18
|
+
import { useConfigStore } from '@/store/configStore'
|
|
19
|
+
import type { PageConfig } from '@/blocks/types'
|
|
20
|
+
import { exportToHTML, downloadHTML, type ExportSiteSettings } from '@/lib/export-html'
|
|
21
|
+
|
|
22
|
+
const viewports: { value: Viewport; icon: typeof Monitor; label: string }[] = [
|
|
23
|
+
{ value: 'desktop', icon: Monitor, label: 'Desktop' },
|
|
24
|
+
{ value: 'tablet', icon: Tablet, label: 'Tablet' },
|
|
25
|
+
{ value: 'mobile', icon: Smartphone, label: 'Mobile' },
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
function AddPagePopover({ onAdd, onClose }: { onAdd: (name: string, path: string) => void; onClose: () => void }) {
|
|
29
|
+
const [name, setName] = useState('')
|
|
30
|
+
const [path, setPath] = useState('/')
|
|
31
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
32
|
+
|
|
33
|
+
useEffect(() => { inputRef.current?.focus() }, [])
|
|
34
|
+
|
|
35
|
+
function submit() {
|
|
36
|
+
const trimmed = name.trim()
|
|
37
|
+
if (!trimmed) return
|
|
38
|
+
const cleanPath = path.trim() || `/${trimmed.toLowerCase().replace(/\s+/g, '-')}`
|
|
39
|
+
onAdd(trimmed, cleanPath)
|
|
40
|
+
onClose()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="absolute top-full left-0 mt-1 bg-bg-2 border border-border-default rounded-lg p-2.5 shadow-[0_8px_24px_rgba(0,0,0,0.4)] z-20 w-52">
|
|
45
|
+
<div className="space-y-2">
|
|
46
|
+
<div>
|
|
47
|
+
<label className="block text-[10px] text-text-3 mb-0.5">Page name</label>
|
|
48
|
+
<input
|
|
49
|
+
ref={inputRef}
|
|
50
|
+
value={name}
|
|
51
|
+
onChange={(e) => {
|
|
52
|
+
setName(e.target.value)
|
|
53
|
+
if (!path || path === '/') setPath(`/${e.target.value.toLowerCase().replace(/\s+/g, '-')}`)
|
|
54
|
+
}}
|
|
55
|
+
onKeyDown={(e) => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose() }}
|
|
56
|
+
placeholder="About"
|
|
57
|
+
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"
|
|
58
|
+
/>
|
|
59
|
+
</div>
|
|
60
|
+
<div>
|
|
61
|
+
<label className="block text-[10px] text-text-3 mb-0.5">Path</label>
|
|
62
|
+
<input
|
|
63
|
+
value={path}
|
|
64
|
+
onChange={(e) => setPath(e.target.value)}
|
|
65
|
+
onKeyDown={(e) => { if (e.key === 'Enter') submit(); if (e.key === 'Escape') onClose() }}
|
|
66
|
+
placeholder="/about"
|
|
67
|
+
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 font-mono"
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
<button
|
|
71
|
+
onClick={submit}
|
|
72
|
+
disabled={!name.trim()}
|
|
73
|
+
className="w-full py-1.5 rounded bg-green text-black text-[11px] font-semibold hover:bg-green-dim transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
|
74
|
+
>
|
|
75
|
+
Add Page
|
|
76
|
+
</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function PageTab({ page, isActive, onClick, onRename, onDelete, canDelete }: {
|
|
83
|
+
page: PageConfig
|
|
84
|
+
isActive: boolean
|
|
85
|
+
onClick: () => void
|
|
86
|
+
onRename: (name: string) => void
|
|
87
|
+
onDelete: () => void
|
|
88
|
+
canDelete: boolean
|
|
89
|
+
}) {
|
|
90
|
+
const [editing, setEditing] = useState(false)
|
|
91
|
+
const [name, setName] = useState(page.name)
|
|
92
|
+
const [showContext, setShowContext] = useState(false)
|
|
93
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
94
|
+
|
|
95
|
+
useEffect(() => { if (editing) inputRef.current?.focus() }, [editing])
|
|
96
|
+
|
|
97
|
+
function commitRename() {
|
|
98
|
+
const trimmed = name.trim()
|
|
99
|
+
if (trimmed && trimmed !== page.name) onRename(trimmed)
|
|
100
|
+
else setName(page.name)
|
|
101
|
+
setEditing(false)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (editing) {
|
|
105
|
+
return (
|
|
106
|
+
<input
|
|
107
|
+
ref={inputRef}
|
|
108
|
+
value={name}
|
|
109
|
+
onChange={(e) => setName(e.target.value)}
|
|
110
|
+
onBlur={commitRename}
|
|
111
|
+
onKeyDown={(e) => {
|
|
112
|
+
if (e.key === 'Enter') commitRename()
|
|
113
|
+
if (e.key === 'Escape') { setName(page.name); setEditing(false) }
|
|
114
|
+
}}
|
|
115
|
+
className="px-2 py-1 rounded text-xs bg-bg-3 border border-green outline-none w-20"
|
|
116
|
+
onClick={(e) => e.stopPropagation()}
|
|
117
|
+
/>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className="relative">
|
|
123
|
+
<button
|
|
124
|
+
onClick={onClick}
|
|
125
|
+
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true) }}
|
|
126
|
+
onContextMenu={(e) => { e.preventDefault(); setShowContext(true) }}
|
|
127
|
+
className={`px-2 py-1 rounded text-xs transition-all ${
|
|
128
|
+
isActive ? 'bg-bg-3 text-text-0' : 'text-text-3 hover:text-text-1 hover:bg-bg-2'
|
|
129
|
+
}`}
|
|
130
|
+
title={`${page.name} (${page.path})`}
|
|
131
|
+
>
|
|
132
|
+
{page.name}
|
|
133
|
+
</button>
|
|
134
|
+
{showContext && (
|
|
135
|
+
<>
|
|
136
|
+
<div className="fixed inset-0 z-10" onClick={() => setShowContext(false)} />
|
|
137
|
+
<div className="absolute top-full left-0 mt-1 bg-bg-2 border border-border-default rounded-lg p-1 shadow-[0_8px_24px_rgba(0,0,0,0.4)] z-20 min-w-[100px]">
|
|
138
|
+
<button
|
|
139
|
+
onClick={() => { setShowContext(false); setEditing(true) }}
|
|
140
|
+
className="w-full text-left px-2.5 py-1.5 rounded text-[11px] text-text-1 hover:bg-bg-3 hover:text-text-0 transition-colors"
|
|
141
|
+
>
|
|
142
|
+
Rename
|
|
143
|
+
</button>
|
|
144
|
+
{canDelete && (
|
|
145
|
+
<button
|
|
146
|
+
onClick={() => { setShowContext(false); onDelete() }}
|
|
147
|
+
className="w-full text-left px-2.5 py-1.5 rounded text-[11px] text-text-1 hover:bg-status-red/10 hover:text-status-red transition-colors"
|
|
148
|
+
>
|
|
149
|
+
Delete
|
|
150
|
+
</button>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface CanvasToolbarProps {
|
|
160
|
+
activeProject?: { name: string; settings?: ExportSiteSettings }
|
|
161
|
+
onExit?: () => void
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function CanvasToolbar({ activeProject, onExit }: CanvasToolbarProps = {}) {
|
|
165
|
+
const { viewport, setViewport, toggleJsonDrawer, jsonDrawerOpen, toggleHistory, togglePreview, toggleShortcutsModal, previewMode } = useEditorStore()
|
|
166
|
+
const { undo, redo, canUndo, canRedo } = useConfigStore()
|
|
167
|
+
const undoStack = useConfigStore((s) => s.undoStack)
|
|
168
|
+
const redoStack = useConfigStore((s) => s.redoStack)
|
|
169
|
+
const pages = useConfigStore((s) => s.config.pages) ?? []
|
|
170
|
+
const activePageId = useConfigStore((s) => s.activePageId)
|
|
171
|
+
const setActivePage = useConfigStore((s) => s.setActivePage)
|
|
172
|
+
const addPage = useConfigStore((s) => s.addPage)
|
|
173
|
+
const removePage = useConfigStore((s) => s.removePage)
|
|
174
|
+
const renamePage = useConfigStore((s) => s.renamePage)
|
|
175
|
+
const configName = useConfigStore((s) => s.config.name)
|
|
176
|
+
const config = useConfigStore((s) => s.config)
|
|
177
|
+
const [showAddPage, setShowAddPage] = useState(false)
|
|
178
|
+
const [exporting, setExporting] = useState(false)
|
|
179
|
+
|
|
180
|
+
const projectName = activeProject?.name || configName
|
|
181
|
+
|
|
182
|
+
async function handleExport() {
|
|
183
|
+
setExporting(true)
|
|
184
|
+
try {
|
|
185
|
+
const html = await exportToHTML(config, { settings: activeProject?.settings })
|
|
186
|
+
const filename = `${(activeProject?.name || config.name || 'site').toLowerCase().replace(/\s+/g, '-')}.html`
|
|
187
|
+
downloadHTML(html, filename)
|
|
188
|
+
toast('HTML exported')
|
|
189
|
+
} catch {
|
|
190
|
+
toast.error('Export failed')
|
|
191
|
+
} finally {
|
|
192
|
+
setExporting(false)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<div className="h-10 bg-bg-1 border-b border-border-default flex items-center px-3 gap-1">
|
|
198
|
+
{/* Breadcrumb */}
|
|
199
|
+
<div className="flex items-center gap-1.5 text-xs text-text-3 shrink-0">
|
|
200
|
+
<span
|
|
201
|
+
className="cursor-pointer hover:text-text-1 transition-colors"
|
|
202
|
+
onClick={() => onExit?.()}
|
|
203
|
+
>
|
|
204
|
+
Projects
|
|
205
|
+
</span>
|
|
206
|
+
<span>/</span>
|
|
207
|
+
<span className="text-text-0 font-medium max-w-[120px] truncate">{projectName}</span>
|
|
208
|
+
</div>
|
|
209
|
+
|
|
210
|
+
<div className="w-px h-5 bg-border-default mx-1.5 shrink-0" />
|
|
211
|
+
|
|
212
|
+
{/* Page tabs */}
|
|
213
|
+
<div className="flex items-center gap-0.5 relative overflow-x-auto">
|
|
214
|
+
{pages.map((page) => (
|
|
215
|
+
<PageTab
|
|
216
|
+
key={page.id}
|
|
217
|
+
page={page}
|
|
218
|
+
isActive={activePageId === page.id}
|
|
219
|
+
onClick={() => setActivePage(page.id)}
|
|
220
|
+
onRename={(name) => renamePage(page.id, name)}
|
|
221
|
+
onDelete={() => removePage(page.id)}
|
|
222
|
+
canDelete={pages.length > 1}
|
|
223
|
+
/>
|
|
224
|
+
))}
|
|
225
|
+
<div className="relative">
|
|
226
|
+
<button
|
|
227
|
+
onClick={() => setShowAddPage(!showAddPage)}
|
|
228
|
+
className="w-6 h-6 rounded flex items-center justify-center text-text-3 hover:text-green hover:bg-bg-2 transition-all"
|
|
229
|
+
title="Add page"
|
|
230
|
+
aria-label="Add page"
|
|
231
|
+
>
|
|
232
|
+
<Plus size={12} />
|
|
233
|
+
</button>
|
|
234
|
+
{showAddPage && (
|
|
235
|
+
<AddPagePopover
|
|
236
|
+
onAdd={(name, path) => addPage(name, path)}
|
|
237
|
+
onClose={() => setShowAddPage(false)}
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
|
|
243
|
+
{/* Right side */}
|
|
244
|
+
<div className="ml-auto flex items-center gap-1 shrink-0">
|
|
245
|
+
{/* Viewport toggle */}
|
|
246
|
+
{viewports.map(({ value, icon: Icon, label }) => (
|
|
247
|
+
<button
|
|
248
|
+
key={value}
|
|
249
|
+
title={label}
|
|
250
|
+
aria-label={label}
|
|
251
|
+
aria-pressed={viewport === value}
|
|
252
|
+
onClick={() => setViewport(value)}
|
|
253
|
+
className={`w-7 h-7 rounded flex items-center justify-center text-xs transition-all ${
|
|
254
|
+
viewport === value
|
|
255
|
+
? 'bg-bg-3 text-text-0'
|
|
256
|
+
: 'text-text-3 hover:text-text-1 hover:bg-bg-3'
|
|
257
|
+
}`}
|
|
258
|
+
>
|
|
259
|
+
<Icon size={14} />
|
|
260
|
+
</button>
|
|
261
|
+
))}
|
|
262
|
+
|
|
263
|
+
<div className="w-px h-5 bg-border-default mx-1" />
|
|
264
|
+
|
|
265
|
+
{/* Undo/Redo */}
|
|
266
|
+
<button
|
|
267
|
+
onClick={() => {
|
|
268
|
+
const label = undoStack[undoStack.length - 1]?.label
|
|
269
|
+
undo()
|
|
270
|
+
if (label) toast(`Undo: ${label}`, { duration: 1500 })
|
|
271
|
+
}}
|
|
272
|
+
disabled={!canUndo()}
|
|
273
|
+
className="w-7 h-7 rounded flex items-center justify-center text-text-3 hover:text-text-1 hover:bg-bg-3 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
|
274
|
+
title="Undo"
|
|
275
|
+
aria-label="Undo"
|
|
276
|
+
>
|
|
277
|
+
<Undo2 size={14} />
|
|
278
|
+
</button>
|
|
279
|
+
<button
|
|
280
|
+
onClick={() => {
|
|
281
|
+
const label = redoStack[redoStack.length - 1]?.label
|
|
282
|
+
redo()
|
|
283
|
+
if (label) toast(`Redo: ${label}`, { duration: 1500 })
|
|
284
|
+
}}
|
|
285
|
+
disabled={!canRedo()}
|
|
286
|
+
className="w-7 h-7 rounded flex items-center justify-center text-text-3 hover:text-text-1 hover:bg-bg-3 transition-all disabled:opacity-30 disabled:cursor-not-allowed"
|
|
287
|
+
title="Redo"
|
|
288
|
+
aria-label="Redo"
|
|
289
|
+
>
|
|
290
|
+
<Redo2 size={14} />
|
|
291
|
+
</button>
|
|
292
|
+
|
|
293
|
+
<div className="w-px h-5 bg-border-default mx-1" />
|
|
294
|
+
|
|
295
|
+
{/* Preview toggle */}
|
|
296
|
+
<button
|
|
297
|
+
onClick={togglePreview}
|
|
298
|
+
className={`h-7 px-2 rounded flex items-center gap-1 text-[11px] transition-all ${
|
|
299
|
+
previewMode ? 'bg-green-glow text-green' : 'text-text-3 hover:text-text-1 hover:bg-bg-3'
|
|
300
|
+
}`}
|
|
301
|
+
title="Preview (P)"
|
|
302
|
+
aria-label="Toggle preview mode"
|
|
303
|
+
aria-pressed={previewMode}
|
|
304
|
+
>
|
|
305
|
+
<Eye size={13} />
|
|
306
|
+
<span>Preview</span>
|
|
307
|
+
</button>
|
|
308
|
+
|
|
309
|
+
{/* JSON drawer toggle */}
|
|
310
|
+
<button
|
|
311
|
+
onClick={toggleJsonDrawer}
|
|
312
|
+
className={`h-7 px-2 rounded flex items-center gap-1 text-[11px] transition-all ${
|
|
313
|
+
jsonDrawerOpen ? 'bg-green-glow text-green' : 'text-text-3 hover:text-text-1 hover:bg-bg-3'
|
|
314
|
+
}`}
|
|
315
|
+
title="JSON (J)"
|
|
316
|
+
aria-label="Toggle JSON drawer"
|
|
317
|
+
aria-pressed={jsonDrawerOpen}
|
|
318
|
+
>
|
|
319
|
+
<Code size={13} />
|
|
320
|
+
<span>JSON</span>
|
|
321
|
+
</button>
|
|
322
|
+
|
|
323
|
+
{/* History */}
|
|
324
|
+
<button
|
|
325
|
+
onClick={toggleHistory}
|
|
326
|
+
className="h-7 px-2 rounded flex items-center gap-1 text-[11px] text-text-3 hover:text-text-1 hover:bg-bg-3 transition-all"
|
|
327
|
+
title="History (H)"
|
|
328
|
+
aria-label="Toggle version history"
|
|
329
|
+
>
|
|
330
|
+
<Clock size={13} />
|
|
331
|
+
<span>History</span>
|
|
332
|
+
</button>
|
|
333
|
+
|
|
334
|
+
{/* Shortcuts help */}
|
|
335
|
+
<button
|
|
336
|
+
onClick={toggleShortcutsModal}
|
|
337
|
+
className="w-7 h-7 rounded flex items-center justify-center text-text-3 hover:text-text-1 hover:bg-bg-3 transition-all"
|
|
338
|
+
title="Keyboard shortcuts (?)"
|
|
339
|
+
aria-label="Show keyboard shortcuts"
|
|
340
|
+
>
|
|
341
|
+
<HelpCircle size={14} />
|
|
342
|
+
</button>
|
|
343
|
+
|
|
344
|
+
<div className="w-px h-5 bg-border-default mx-1" />
|
|
345
|
+
|
|
346
|
+
<button
|
|
347
|
+
onClick={handleExport}
|
|
348
|
+
disabled={exporting}
|
|
349
|
+
className="h-7 px-3 rounded-lg bg-green text-bg-0 text-[11.5px] font-semibold hover:bg-green/90 transition-all disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
|
|
350
|
+
>
|
|
351
|
+
{exporting ? (
|
|
352
|
+
<>
|
|
353
|
+
<Loader2 size={12} className="animate-spin" />
|
|
354
|
+
<span>Exporting...</span>
|
|
355
|
+
</>
|
|
356
|
+
) : (
|
|
357
|
+
<>
|
|
358
|
+
<Download size={12} />
|
|
359
|
+
<span>Export</span>
|
|
360
|
+
</>
|
|
361
|
+
)}
|
|
362
|
+
</button>
|
|
363
|
+
</div>
|
|
364
|
+
</div>
|
|
365
|
+
)
|
|
366
|
+
}
|