@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.
- package/README.md +86 -0
- package/package.json +54 -0
- package/src/blocks/author-bio.tsx +52 -0
- package/src/blocks/author-hero.tsx +99 -0
- package/src/blocks/book-grid.tsx +97 -0
- package/src/blocks/book-spotlight.tsx +109 -0
- package/src/blocks/contact.tsx +64 -0
- package/src/blocks/faq.tsx +65 -0
- package/src/blocks/form.tsx +234 -0
- package/src/blocks/gallery.tsx +230 -0
- package/src/blocks/index.ts +32 -0
- package/src/blocks/newsletter.tsx +100 -0
- package/src/blocks/pricing.tsx +120 -0
- package/src/blocks/primitives.tsx +109 -0
- package/src/blocks/testimonials.tsx +89 -0
- package/src/catalogs/author/catalog.tsx +496 -0
- package/src/catalogs/author/index.ts +9 -0
- package/src/catalogs/author/templates.ts +133 -0
- package/src/catalogs/publisher/catalog.tsx +308 -0
- package/src/catalogs/publisher/index.ts +10 -0
- package/src/catalogs/publisher/templates.ts +158 -0
- package/src/editable.tsx +76 -0
- package/src/editor/BlockLibrary.tsx +110 -0
- package/src/editor/Canvas.tsx +128 -0
- package/src/editor/ErrorBoundary.tsx +54 -0
- package/src/editor/InsertPoint.tsx +31 -0
- package/src/editor/Inspector.tsx +341 -0
- package/src/editor/PageBuilder.tsx +217 -0
- package/src/editor/SectionFrame.tsx +127 -0
- package/src/editor/SectionsPanel.tsx +149 -0
- package/src/editor/TemplateGallery.tsx +98 -0
- package/src/editor/ThemePanel.tsx +131 -0
- package/src/editor/Toolbar.tsx +162 -0
- package/src/editor/dnd.tsx +61 -0
- package/src/editor/edit-primitives.tsx +164 -0
- package/src/editor/index.tsx +8 -0
- package/src/editor/shortcuts.ts +106 -0
- package/src/editor/ui-context.tsx +32 -0
- package/src/index.ts +11 -0
- package/src/lib/cn.ts +8 -0
- package/src/model.ts +116 -0
- package/src/registry.ts +138 -0
- package/src/renderer/index.tsx +55 -0
- package/src/store.ts +216 -0
- 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
|
+
}
|