@jimmy_harika/websitekit 0.3.1

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 (45) hide show
  1. package/README.md +86 -0
  2. package/package.json +54 -0
  3. package/src/blocks/author-bio.tsx +52 -0
  4. package/src/blocks/author-hero.tsx +99 -0
  5. package/src/blocks/book-grid.tsx +97 -0
  6. package/src/blocks/book-spotlight.tsx +109 -0
  7. package/src/blocks/contact.tsx +64 -0
  8. package/src/blocks/faq.tsx +65 -0
  9. package/src/blocks/form.tsx +234 -0
  10. package/src/blocks/gallery.tsx +230 -0
  11. package/src/blocks/index.ts +32 -0
  12. package/src/blocks/newsletter.tsx +100 -0
  13. package/src/blocks/pricing.tsx +120 -0
  14. package/src/blocks/primitives.tsx +109 -0
  15. package/src/blocks/testimonials.tsx +89 -0
  16. package/src/catalogs/author/catalog.tsx +496 -0
  17. package/src/catalogs/author/index.ts +9 -0
  18. package/src/catalogs/author/templates.ts +133 -0
  19. package/src/catalogs/publisher/catalog.tsx +308 -0
  20. package/src/catalogs/publisher/index.ts +10 -0
  21. package/src/catalogs/publisher/templates.ts +158 -0
  22. package/src/editable.tsx +76 -0
  23. package/src/editor/BlockLibrary.tsx +110 -0
  24. package/src/editor/Canvas.tsx +128 -0
  25. package/src/editor/ErrorBoundary.tsx +54 -0
  26. package/src/editor/InsertPoint.tsx +31 -0
  27. package/src/editor/Inspector.tsx +341 -0
  28. package/src/editor/PageBuilder.tsx +217 -0
  29. package/src/editor/SectionFrame.tsx +127 -0
  30. package/src/editor/SectionsPanel.tsx +149 -0
  31. package/src/editor/TemplateGallery.tsx +98 -0
  32. package/src/editor/ThemePanel.tsx +131 -0
  33. package/src/editor/Toolbar.tsx +162 -0
  34. package/src/editor/dnd.tsx +61 -0
  35. package/src/editor/edit-primitives.tsx +164 -0
  36. package/src/editor/index.tsx +8 -0
  37. package/src/editor/shortcuts.ts +106 -0
  38. package/src/editor/ui-context.tsx +32 -0
  39. package/src/index.ts +11 -0
  40. package/src/lib/cn.ts +8 -0
  41. package/src/model.ts +116 -0
  42. package/src/registry.ts +138 -0
  43. package/src/renderer/index.tsx +55 -0
  44. package/src/store.ts +216 -0
  45. package/src/theme.ts +85 -0
@@ -0,0 +1,217 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Top-level editor. The app wires it with a `catalog`, an `initialDocument`,
5
+ * and capability callbacks (autosave via `onChange`, `onPublish`, `uploadImage`,
6
+ * optional `templates`). Owns the store, editor UI state, and the layout:
7
+ * top toolbar · [design panel] · canvas · [inspector] · overlays (library,
8
+ * templates).
9
+ */
10
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
11
+ import { List, Palette, Plus, Sliders, X } from "lucide-react";
12
+ import type { PageDocument } from "../model";
13
+ import type { Catalog } from "../registry";
14
+ import { BuilderProvider, useBuilder, useCreateBuilderStore } from "../store";
15
+ import { EditorCapsContext } from "./edit-primitives";
16
+ import { EditorUiContext, type Device, type EditorUi, type MobilePanel, useEditorUi } from "./ui-context";
17
+ import { Toolbar } from "./Toolbar";
18
+ import { Canvas } from "./Canvas";
19
+ import { Inspector } from "./Inspector";
20
+ import { BlockLibrary } from "./BlockLibrary";
21
+ import { ThemePanel } from "./ThemePanel";
22
+ import { SectionsPanel } from "./SectionsPanel";
23
+ import { TemplateGallery, type TemplateMeta } from "./TemplateGallery";
24
+ import { useEditorShortcuts } from "./shortcuts";
25
+
26
+ export interface PageBuilderProps {
27
+ catalog: Catalog;
28
+ initialDocument: PageDocument;
29
+ /** Fires (debounced upstream) whenever the document changes — for autosave. */
30
+ onChange?: (doc: PageDocument) => void;
31
+ onPublish?: (doc: PageDocument) => void | Promise<void>;
32
+ /** Upload a picked file, return its public URL (R2 / Convex storage). */
33
+ uploadImage?: (file: File) => Promise<string>;
34
+ templates?: TemplateMeta[];
35
+ saving?: "idle" | "saving" | "saved";
36
+ /** Header gate content (e.g. exit link + site name), pinned far left. */
37
+ leading?: ReactNode;
38
+ /** "View live site" link target. */
39
+ viewUrl?: string;
40
+ }
41
+
42
+ export function PageBuilder(props: PageBuilderProps) {
43
+ const store = useCreateBuilderStore(props.initialDocument);
44
+ const caps = useMemo(() => ({ uploadImage: props.uploadImage }), [props.uploadImage]);
45
+ return (
46
+ <BuilderProvider store={store}>
47
+ <EditorCapsContext.Provider value={caps}>
48
+ <PageBuilderInner {...props} />
49
+ </EditorCapsContext.Provider>
50
+ </BuilderProvider>
51
+ );
52
+ }
53
+
54
+ function PageBuilderInner({
55
+ catalog,
56
+ onChange,
57
+ onPublish,
58
+ templates,
59
+ saving,
60
+ leading,
61
+ viewUrl,
62
+ }: PageBuilderProps) {
63
+ const doc = useBuilder((s) => s.doc);
64
+ const [device, setDevice] = useState<Device>("desktop");
65
+ const [libraryIndex, setLibraryIndex] = useState<number | null>(null);
66
+ const [inspectorOpen, setInspectorOpen] = useState(true);
67
+ const [templatesOpen, setTemplatesOpen] = useState(false);
68
+ const [designOpen, setDesignOpen] = useState(false);
69
+ const [mobilePanel, setMobilePanel] = useState<MobilePanel>(null);
70
+
71
+ const ui: EditorUi = useMemo(
72
+ () => ({
73
+ device,
74
+ setDevice,
75
+ libraryIndex,
76
+ openLibrary: setLibraryIndex,
77
+ closeLibrary: () => setLibraryIndex(null),
78
+ inspectorOpen,
79
+ setInspectorOpen,
80
+ templatesOpen,
81
+ setTemplatesOpen,
82
+ mobilePanel,
83
+ setMobilePanel,
84
+ }),
85
+ [device, libraryIndex, inspectorOpen, templatesOpen, mobilePanel],
86
+ );
87
+
88
+ // Autosave: emit document changes (skip the initial render).
89
+ const onChangeRef = useRef(onChange);
90
+ onChangeRef.current = onChange;
91
+ const firstRender = useRef(true);
92
+ useEffect(() => {
93
+ if (firstRender.current) {
94
+ firstRender.current = false;
95
+ return;
96
+ }
97
+ onChangeRef.current?.(doc);
98
+ }, [doc]);
99
+
100
+ // Keyboard shortcuts (⌘Z/⌘⇧Z, Delete, ⌘D, Esc, ⌘/, ⌘⇧P, ↑/↓).
101
+ // `ui` is passed in (not read from context) because this hook runs in
102
+ // PageBuilderInner, which sits ABOVE its own <EditorUiContext.Provider>.
103
+ useEditorShortcuts({ ui, onPublish: onPublish ? () => onPublish(doc) : undefined });
104
+
105
+ return (
106
+ <EditorUiContext.Provider value={ui}>
107
+ <div data-websitekit-editor className="flex h-full min-h-0 flex-col bg-muted">
108
+ <Toolbar
109
+ onPublish={onPublish ? () => onPublish(doc) : undefined}
110
+ saving={saving}
111
+ designOpen={designOpen}
112
+ onToggleDesign={() => setDesignOpen((v) => !v)}
113
+ hasTemplates={!!templates && templates.length > 0}
114
+ leading={leading}
115
+ viewUrl={viewUrl}
116
+ />
117
+ <div className="flex min-h-0 flex-1">
118
+ <aside className="hidden w-64 shrink-0 flex-col border-r border-border bg-card md:flex">
119
+ {designOpen ? (
120
+ <>
121
+ <div className="border-b border-border px-3 py-2.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
122
+ Design
123
+ </div>
124
+ <div className="min-h-0 flex-1 overflow-auto">
125
+ <ThemePanel />
126
+ </div>
127
+ </>
128
+ ) : (
129
+ <SectionsPanel catalog={catalog} />
130
+ )}
131
+ </aside>
132
+ <Canvas catalog={catalog} />
133
+ {/* Desktop inspector rail */}
134
+ <aside className="hidden w-80 shrink-0 flex-col border-l border-border bg-card md:flex">
135
+ <Inspector catalog={catalog} />
136
+ </aside>
137
+ </div>
138
+ <MobileBar />
139
+ <MobileSheet catalog={catalog} designOpen={designOpen} />
140
+ <BlockLibrary catalog={catalog} />
141
+ {templates && templates.length > 0 && <TemplateGallery templates={templates} />}
142
+ <style>{`[data-websitekit-editor] [data-pb-field]:empty::before{content:attr(data-pb-placeholder);opacity:.4;pointer-events:none;}`}</style>
143
+ </div>
144
+ </EditorUiContext.Provider>
145
+ );
146
+ }
147
+
148
+ /** Mobile bottom tab bar — the only way to reach panels on < md. */
149
+ function MobileBar() {
150
+ const ui = useEditorUi();
151
+ const sections = useBuilder((s) => s.doc.sections);
152
+ const tab = (p: MobilePanel, label: string, icon: ReactNode) => (
153
+ <button
154
+ type="button"
155
+ onClick={() => {
156
+ if (p === "inspector") ui.setInspectorOpen(true);
157
+ ui.setMobilePanel(ui.mobilePanel === p ? null : p);
158
+ }}
159
+ className={`flex flex-1 flex-col items-center gap-0.5 py-1.5 text-[10px] ${ui.mobilePanel === p ? "text-foreground" : "text-muted-foreground"}`}
160
+ >
161
+ {icon}
162
+ <span>{label}</span>
163
+ </button>
164
+ );
165
+ return (
166
+ <nav className="flex shrink-0 items-stretch border-t border-border bg-card px-1 pb-[env(safe-area-inset-bottom)] md:hidden">
167
+ {tab("sections", "Sections", <List className="size-5" />)}
168
+ <button
169
+ type="button"
170
+ onClick={() => ui.openLibrary(sections.length)}
171
+ className="flex flex-1 flex-col items-center gap-0.5 py-1.5 text-[10px] text-muted-foreground"
172
+ >
173
+ <Plus className="size-5" />
174
+ <span>Add</span>
175
+ </button>
176
+ {tab("design", "Design", <Palette className="size-5" />)}
177
+ {tab("inspector", "Section", <Sliders className="size-5" />)}
178
+ </nav>
179
+ );
180
+ }
181
+
182
+ /** Bottom-sheet overlay rendering the active mobile panel. */
183
+ function MobileSheet({ catalog, designOpen }: { catalog: Catalog; designOpen: boolean }) {
184
+ const ui = useEditorUi();
185
+ if (ui.mobilePanel === null) return null;
186
+ return (
187
+ <div className="fixed inset-0 z-50 flex flex-col justify-end md:hidden">
188
+ <button
189
+ aria-label="Close panel"
190
+ onClick={() => ui.setMobilePanel(null)}
191
+ className="absolute inset-0 bg-black/40"
192
+ />
193
+ <div className="relative max-h-[70vh] overflow-auto rounded-t-2xl border-t border-border bg-card pb-[env(safe-area-inset-bottom)]">
194
+ <div className="flex items-center justify-between border-b border-border px-4 py-2.5">
195
+ <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
196
+ {ui.mobilePanel === "sections" ? "Sections" : ui.mobilePanel === "design" ? "Design" : "Section"}
197
+ </span>
198
+ <button
199
+ onClick={() => ui.setMobilePanel(null)}
200
+ className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
201
+ >
202
+ <X className="size-4" />
203
+ </button>
204
+ </div>
205
+ <div className="min-h-[30vh]">
206
+ {ui.mobilePanel === "sections" && <SectionsPanel catalog={catalog} />}
207
+ {ui.mobilePanel === "design" && <ThemePanel />}
208
+ {ui.mobilePanel === "inspector" && (
209
+ <div className="p-4">
210
+ <Inspector catalog={catalog} />
211
+ </div>
212
+ )}
213
+ </div>
214
+ </div>
215
+ </div>
216
+ );
217
+ }
@@ -0,0 +1,127 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Wraps one rendered section with editor chrome: selection outline, a floating
5
+ * toolbar (drag handle · change layout · duplicate · delete), and drop-target
6
+ * wiring. Renders the catalog Component with `edit` injected so inline fields
7
+ * are editable.
8
+ */
9
+ import { createElement, useRef, useState, type ComponentType } from "react";
10
+ import { Copy, EyeOff, GripVertical, LayoutTemplate, Monitor, Smartphone, Tablet, Trash2 } from "lucide-react";
11
+ import type { Section } from "../model";
12
+ import { type SectionDefinition, resolveProps } from "../registry";
13
+ import { useBuilder } from "../store";
14
+ import { cn } from "../lib/cn";
15
+ import { EditSectionContext, editPrimitives } from "./edit-primitives";
16
+ import { useDraggable, useDropTarget } from "./dnd";
17
+ import { useEditorUi } from "./ui-context";
18
+
19
+ export function SectionFrame({
20
+ section,
21
+ index,
22
+ def,
23
+ }: {
24
+ section: Section;
25
+ index: number;
26
+ def: SectionDefinition;
27
+ }) {
28
+ const ref = useRef<HTMLDivElement>(null);
29
+ const handleRef = useRef<HTMLButtonElement>(null);
30
+ const [over, setOver] = useState(false);
31
+ const selected = useBuilder((s) => s.selectedId === section.id);
32
+ const select = useBuilder((s) => s.select);
33
+ const duplicate = useBuilder((s) => s.duplicateSection);
34
+ const remove = useBuilder((s) => s.removeSection);
35
+ const ui = useEditorUi();
36
+
37
+ useDraggable(ref, { kind: "section", id: section.id, index }, handleRef);
38
+ useDropTarget(ref, { id: section.id, index }, setOver);
39
+
40
+ const props = resolveProps(def, section.props);
41
+ const Comp = def.Component as ComponentType<Record<string, unknown>>;
42
+ const hasLayouts = (def.variants?.length ?? 0) > 1;
43
+
44
+ const stop = (fn: () => void) => (e: { stopPropagation: () => void }) => {
45
+ e.stopPropagation();
46
+ fn();
47
+ };
48
+
49
+ return (
50
+ <EditSectionContext.Provider value={section.id}>
51
+ <div
52
+ ref={ref}
53
+ onClick={stop(() => {
54
+ select(section.id);
55
+ ui.setInspectorOpen(true);
56
+ })}
57
+ className="group/sec relative"
58
+ >
59
+ <div
60
+ aria-hidden
61
+ className={cn(
62
+ "pointer-events-none absolute inset-0 z-10 ring-inset transition",
63
+ selected
64
+ ? "ring-2 ring-sky-500"
65
+ : "ring-1 ring-transparent group-hover/sec:ring-2 group-hover/sec:ring-sky-300",
66
+ over && "ring-2 ring-sky-400",
67
+ )}
68
+ />
69
+ <div
70
+ className={cn(
71
+ "absolute right-3 top-3 z-20 flex items-center gap-0.5 rounded-lg border border-black/10 bg-background/95 p-1 text-foreground shadow-sm backdrop-blur transition",
72
+ selected
73
+ ? "opacity-100"
74
+ : "pointer-events-none opacity-0 group-hover/sec:pointer-events-auto group-hover/sec:opacity-100",
75
+ )}
76
+ >
77
+ <button
78
+ ref={handleRef}
79
+ title="Drag to reorder"
80
+ className="cursor-grab rounded p-1.5 hover:bg-accent active:cursor-grabbing"
81
+ >
82
+ <GripVertical className="size-4" />
83
+ </button>
84
+ {hasLayouts && (
85
+ <button
86
+ title="Change layout"
87
+ onClick={stop(() => {
88
+ select(section.id);
89
+ ui.setInspectorOpen(true);
90
+ })}
91
+ className="rounded p-1.5 hover:bg-accent"
92
+ >
93
+ <LayoutTemplate className="size-4" />
94
+ </button>
95
+ )}
96
+ <button
97
+ title="Duplicate"
98
+ onClick={stop(() => duplicate(section.id))}
99
+ className="rounded p-1.5 hover:bg-accent"
100
+ >
101
+ <Copy className="size-4" />
102
+ </button>
103
+ <button
104
+ title="Delete"
105
+ onClick={stop(() => remove(section.id))}
106
+ className="rounded p-1.5 text-red-600 hover:bg-red-50"
107
+ >
108
+ <Trash2 className="size-4" />
109
+ </button>
110
+ </div>
111
+ {section.hideOn && section.hideOn.length > 0 && (
112
+ <div
113
+ title={`Hidden on ${section.hideOn.join(", ")}`}
114
+ className="absolute left-3 top-3 z-20 flex items-center gap-1 rounded-md border border-black/10 bg-background/95 px-1.5 py-1 text-foreground/70 shadow-sm backdrop-blur"
115
+ >
116
+ <EyeOff className="size-3.5" />
117
+ {section.hideOn.map((bp) => {
118
+ const Icon = bp === "mobile" ? Smartphone : bp === "tablet" ? Tablet : Monitor;
119
+ return <Icon key={bp} className="size-3.5" />;
120
+ })}
121
+ </div>
122
+ )}
123
+ {createElement(Comp, { ...props, edit: editPrimitives })}
124
+ </div>
125
+ </EditSectionContext.Provider>
126
+ );
127
+ }
@@ -0,0 +1,149 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Persistent left rail: a visible, drag-to-reorder list of the page's sections
5
+ * (the "blocks UI"). Click a row to select + edit it; drag the handle to
6
+ * reorder; the Add button opens the block library. Reorder rides the same
7
+ * pragmatic-drag-and-drop monitor as the canvas (kind "section").
8
+ */
9
+ import { useRef, useState } from "react";
10
+ import { ChevronDown, ChevronUp, GripVertical, Plus, Trash2 } from "lucide-react";
11
+ import { type Catalog, getDefinition } from "../registry";
12
+ import { useBuilder } from "../store";
13
+ import { useEditorUi } from "./ui-context";
14
+ import { useDraggable, useDropTarget } from "./dnd";
15
+ import { cn } from "../lib/cn";
16
+
17
+ export function SectionsPanel({ catalog }: { catalog: Catalog }) {
18
+ const sections = useBuilder((s) => s.doc.sections);
19
+ const ui = useEditorUi();
20
+
21
+ return (
22
+ <div className="flex h-full min-h-0 flex-col">
23
+ <div className="flex items-center justify-between border-b border-border px-3 py-2.5">
24
+ <span className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
25
+ Sections
26
+ </span>
27
+ <button
28
+ onClick={() => ui.openLibrary(sections.length)}
29
+ className="flex items-center gap-1 rounded-md bg-secondary px-2 py-1 text-xs font-medium text-secondary-foreground transition hover:opacity-90"
30
+ >
31
+ <Plus className="size-3.5" /> Add
32
+ </button>
33
+ </div>
34
+ <div className="min-h-0 flex-1 overflow-auto p-2">
35
+ {sections.length === 0 ? (
36
+ <button
37
+ onClick={() => ui.openLibrary(0)}
38
+ className="flex w-full flex-col items-center gap-1.5 rounded-lg border border-dashed border-border px-3 py-8 text-center text-xs text-muted-foreground transition hover:border-foreground/30 hover:text-foreground"
39
+ >
40
+ <Plus className="size-5" />
41
+ Add your first section
42
+ </button>
43
+ ) : (
44
+ <ul className="space-y-0.5">
45
+ {sections.map((s, i) => (
46
+ <SectionRow
47
+ key={s.id}
48
+ catalog={catalog}
49
+ id={s.id}
50
+ type={s.type}
51
+ index={i}
52
+ total={sections.length}
53
+ />
54
+ ))}
55
+ </ul>
56
+ )}
57
+ </div>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ function SectionRow({
63
+ catalog,
64
+ id,
65
+ type,
66
+ index,
67
+ total,
68
+ }: {
69
+ catalog: Catalog;
70
+ id: string;
71
+ type: string;
72
+ index: number;
73
+ total: number;
74
+ }) {
75
+ const ref = useRef<HTMLLIElement>(null);
76
+ const handleRef = useRef<HTMLButtonElement>(null);
77
+ const [over, setOver] = useState(false);
78
+ const selected = useBuilder((s) => s.selectedId === id);
79
+ const select = useBuilder((s) => s.select);
80
+ const remove = useBuilder((s) => s.removeSection);
81
+ const move = useBuilder((s) => s.moveSection);
82
+ const ui = useEditorUi();
83
+ const def = getDefinition(catalog, type);
84
+
85
+ useDraggable(ref, { kind: "section", id, index }, handleRef);
86
+ useDropTarget(ref, { id, index }, setOver);
87
+
88
+ const stop = (fn: () => void) => (e: { stopPropagation: () => void }) => {
89
+ e.stopPropagation();
90
+ fn();
91
+ };
92
+
93
+ return (
94
+ <li
95
+ ref={ref}
96
+ onClick={() => {
97
+ select(id);
98
+ ui.setInspectorOpen(true);
99
+ }}
100
+ className={cn(
101
+ "group/row relative flex items-center gap-1.5 rounded-md px-1.5 py-2 text-sm transition",
102
+ selected
103
+ ? "bg-secondary text-secondary-foreground"
104
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
105
+ over && "ring-1 ring-foreground/40",
106
+ )}
107
+ >
108
+ <button
109
+ ref={handleRef}
110
+ title="Drag to reorder"
111
+ onClick={(e) => e.stopPropagation()}
112
+ className="cursor-grab text-muted-foreground/50 transition hover:text-foreground active:cursor-grabbing"
113
+ >
114
+ <GripVertical className="size-4" />
115
+ </button>
116
+ <span className="flex-1 truncate">{def?.label ?? type}</span>
117
+ <div
118
+ className={cn(
119
+ "flex items-center opacity-0 transition group-hover/row:opacity-100",
120
+ selected && "opacity-100",
121
+ )}
122
+ >
123
+ <button
124
+ title="Move up"
125
+ disabled={index === 0}
126
+ onClick={stop(() => move(index, index - 1))}
127
+ className="rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
128
+ >
129
+ <ChevronUp className="size-3.5" />
130
+ </button>
131
+ <button
132
+ title="Move down"
133
+ disabled={index === total - 1}
134
+ onClick={stop(() => move(index, index + 1))}
135
+ className="rounded p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-30"
136
+ >
137
+ <ChevronDown className="size-3.5" />
138
+ </button>
139
+ <button
140
+ title="Delete section"
141
+ onClick={stop(() => remove(id))}
142
+ className="rounded p-0.5 text-muted-foreground hover:text-red-500"
143
+ >
144
+ <Trash2 className="size-3.5" />
145
+ </button>
146
+ </div>
147
+ </li>
148
+ );
149
+ }
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ /** Full-screen template picker. Solid opaque surface (bg-card — the app paints a
4
+ * translucent body, so bg-background would show the canvas through). */
5
+ import { useEffect, useState } from "react";
6
+ import { createPortal } from "react-dom";
7
+ import { X } from "lucide-react";
8
+ import type { PageDocument, TemplateMeta } from "../model";
9
+ import { useBuilder } from "../store";
10
+ import { useEditorUi } from "./ui-context";
11
+ import { cn } from "../lib/cn";
12
+
13
+ export type { TemplateMeta } from "../model";
14
+
15
+ export function TemplateGallery({ templates }: { templates: TemplateMeta[] }) {
16
+ const ui = useEditorUi();
17
+ const replace = useBuilder((s) => s.replaceDocument);
18
+ const cats = Array.from(new Set(templates.map((t) => t.category)));
19
+ const [active, setActive] = useState(cats[0] ?? "");
20
+ const [mounted, setMounted] = useState(false);
21
+ useEffect(() => setMounted(true), []);
22
+
23
+ if (!ui.templatesOpen || !mounted) return null;
24
+ const items = templates.filter((t) => t.category === active);
25
+
26
+ return createPortal(
27
+ <div className="fixed inset-0 z-[100] overflow-auto bg-card text-foreground">
28
+ <div className="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-card px-6 py-4">
29
+ <div>
30
+ <h2 className="text-lg font-semibold">Choose a template</h2>
31
+ <p className="text-xs text-muted-foreground">
32
+ Starts a fresh page — replaces your current one. Your theme is kept.
33
+ </p>
34
+ </div>
35
+ <button
36
+ onClick={() => ui.setTemplatesOpen(false)}
37
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
38
+ >
39
+ <X className="size-5" />
40
+ </button>
41
+ </div>
42
+ <div className="mx-auto max-w-6xl px-6 py-8">
43
+ {cats.length > 1 && (
44
+ <div className="mb-6 flex flex-wrap gap-2">
45
+ {cats.map((c) => (
46
+ <button
47
+ key={c}
48
+ onClick={() => setActive(c)}
49
+ className={cn(
50
+ "rounded-full px-4 py-1.5 text-sm transition",
51
+ active === c
52
+ ? "bg-secondary text-secondary-foreground ring-1 ring-border"
53
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
54
+ )}
55
+ >
56
+ {c}
57
+ </button>
58
+ ))}
59
+ </div>
60
+ )}
61
+ <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
62
+ {items.map((t) => (
63
+ <button
64
+ key={t.id}
65
+ onClick={() => {
66
+ replace(t.build());
67
+ ui.setTemplatesOpen(false);
68
+ }}
69
+ className="group overflow-hidden rounded-xl border border-border bg-background text-left transition hover:border-foreground/30 hover:shadow-lg"
70
+ >
71
+ <div className="aspect-[4/3] overflow-hidden bg-muted">
72
+ {t.thumb ? (
73
+ // eslint-disable-next-line @next/next/no-img-element
74
+ <img
75
+ src={t.thumb}
76
+ alt={t.name}
77
+ className="size-full object-cover transition duration-300 group-hover:scale-105"
78
+ />
79
+ ) : (
80
+ <div className="flex size-full items-center justify-center text-sm text-muted-foreground/50">
81
+ {t.name}
82
+ </div>
83
+ )}
84
+ </div>
85
+ <div className="p-4">
86
+ <h3 className="font-medium">{t.name}</h3>
87
+ {t.description && (
88
+ <p className="mt-1 text-sm text-muted-foreground">{t.description}</p>
89
+ )}
90
+ </div>
91
+ </button>
92
+ ))}
93
+ </div>
94
+ </div>
95
+ </div>,
96
+ document.body,
97
+ );
98
+ }