@siteable/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +4 -0
  3. package/README.md +82 -0
  4. package/dist/index.d.ts +401 -0
  5. package/dist/index.js +5389 -0
  6. package/package.json +66 -0
  7. package/src/blocks/BlockWrapper.tsx +149 -0
  8. package/src/blocks/banner/BannerBlock.tsx +38 -0
  9. package/src/blocks/contact/ContactBlock.tsx +63 -0
  10. package/src/blocks/content/ContentBlock.tsx +38 -0
  11. package/src/blocks/cta/CtaBlock.tsx +67 -0
  12. package/src/blocks/divider/DividerBlock.tsx +30 -0
  13. package/src/blocks/faq/FaqBlock.tsx +82 -0
  14. package/src/blocks/features/FeaturesBlock.tsx +170 -0
  15. package/src/blocks/footer/FooterBlock.tsx +136 -0
  16. package/src/blocks/gallery/GalleryBlock.tsx +67 -0
  17. package/src/blocks/hero/HeroBlock.tsx +163 -0
  18. package/src/blocks/image/ImageBlock.tsx +78 -0
  19. package/src/blocks/logocloud/LogoCloudBlock.tsx +70 -0
  20. package/src/blocks/navbar/NavbarBlock.tsx +102 -0
  21. package/src/blocks/newsletter/NewsletterBlock.tsx +51 -0
  22. package/src/blocks/pricing/PricingBlock.tsx +173 -0
  23. package/src/blocks/registry.tsx +90 -0
  24. package/src/blocks/stats/StatsBlock.tsx +96 -0
  25. package/src/blocks/team/TeamBlock.tsx +50 -0
  26. package/src/blocks/testimonials/TestimonialsBlock.tsx +203 -0
  27. package/src/blocks/types.ts +72 -0
  28. package/src/blocks/video/VideoBlock.tsx +58 -0
  29. package/src/editor/AgentPanel.tsx +318 -0
  30. package/src/editor/Canvas.tsx +95 -0
  31. package/src/editor/CanvasEmpty.tsx +40 -0
  32. package/src/editor/CanvasToolbar.tsx +366 -0
  33. package/src/editor/DesignPanel.tsx +224 -0
  34. package/src/editor/EditorLayout.tsx +196 -0
  35. package/src/editor/GenerationOverlay.tsx +201 -0
  36. package/src/editor/JsonDrawer.tsx +139 -0
  37. package/src/editor/LayersPanel.tsx +259 -0
  38. package/src/editor/LeftSidebar.tsx +136 -0
  39. package/src/editor/PropertiesPanel.tsx +564 -0
  40. package/src/editor/RightSidebar.tsx +85 -0
  41. package/src/editor/ShortcutsModal.tsx +126 -0
  42. package/src/editor/VersionHistory.tsx +110 -0
  43. package/src/index.ts +133 -0
  44. package/src/lib/block-metadata.ts +167 -0
  45. package/src/lib/export-html.ts +1424 -0
  46. package/src/lib/generate-site.ts +206 -0
  47. package/src/lib/generation-prompt.ts +64 -0
  48. package/src/lib/markdown.ts +88 -0
  49. package/src/lib/templates.ts +139 -0
  50. package/src/lib/theme-presets.ts +330 -0
  51. package/src/lib/useGoogleFonts.ts +49 -0
  52. package/src/lib/useScrollReveal.ts +28 -0
  53. package/src/settings/GeminiKeyInputField.tsx +82 -0
  54. package/src/store/configStore.ts +344 -0
  55. package/src/store/editorStore.ts +50 -0
  56. package/src/styles.css +210 -0
@@ -0,0 +1,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
+ }