@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,128 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The editing surface. Renders the document inside a device-width frame, with
5
+ * insert points between sections and a single drag monitor that resolves both
6
+ * reorders (drag a section) and inserts (drag a block-library variant), picking
7
+ * the edge from the pointer position vs. the drop target's mid-line.
8
+ */
9
+ import { useEffect } from "react";
10
+ import { Plus } from "lucide-react";
11
+ import { createSectionId } from "../model";
12
+ import {
13
+ type Catalog,
14
+ defaultPropsFor,
15
+ getDefinition,
16
+ } from "../registry";
17
+ import { themeVars } from "../theme";
18
+ import { useBuilder } from "../store";
19
+ import { SectionFrame } from "./SectionFrame";
20
+ import { SectionErrorBoundary } from "./ErrorBoundary";
21
+ import { InsertPoint } from "./InsertPoint";
22
+ import { monitorForElements } from "./dnd";
23
+ import { useEditorUi } from "./ui-context";
24
+
25
+ const DEVICE_WIDTH: Record<string, string> = {
26
+ desktop: "100%",
27
+ tablet: "834px",
28
+ mobile: "390px",
29
+ };
30
+
31
+ export function Canvas({ catalog }: { catalog: Catalog }) {
32
+ const sections = useBuilder((s) => s.doc.sections);
33
+ const theme = useBuilder((s) => s.doc.theme);
34
+ const moveSection = useBuilder((s) => s.moveSection);
35
+ const insertSection = useBuilder((s) => s.insertSection);
36
+ const setProps = useBuilder((s) => s.setProps);
37
+ const select = useBuilder((s) => s.select);
38
+ const ui = useEditorUi();
39
+
40
+ useEffect(() => {
41
+ return monitorForElements({
42
+ canMonitor: ({ source }) =>
43
+ source.data.kind === "section" || source.data.kind === "lib-variant",
44
+ onDrop: ({ source, location }) => {
45
+ const target = location.current.dropTargets[0];
46
+ if (!target) return;
47
+ const overIndex = target.data.targetIndex as number;
48
+ const rect = (target.element as HTMLElement).getBoundingClientRect();
49
+ const after = location.current.input.clientY > rect.top + rect.height / 2;
50
+ const destIndex = after ? overIndex + 1 : overIndex;
51
+
52
+ if (source.data.kind === "section") {
53
+ const from = source.data.index as number;
54
+ let to = destIndex;
55
+ if (from < to) to -= 1;
56
+ if (from !== to) moveSection(from, to);
57
+ } else if (source.data.kind === "lib-variant") {
58
+ const def = getDefinition(catalog, source.data.type as string);
59
+ if (!def) return;
60
+ const variant = source.data.variant as string | undefined;
61
+ insertSection(
62
+ {
63
+ id: createSectionId(),
64
+ type: def.type,
65
+ variant,
66
+ props: defaultPropsFor(def, variant),
67
+ },
68
+ destIndex,
69
+ );
70
+ }
71
+ },
72
+ });
73
+ }, [catalog, moveSection, insertSection]);
74
+
75
+ return (
76
+ <div
77
+ className="flex-1 overflow-auto bg-muted p-4 sm:p-6"
78
+ onClick={() => select(null)}
79
+ >
80
+ <div
81
+ className="mx-auto bg-background shadow-sm ring-1 ring-border transition-[width] duration-200"
82
+ style={{ width: DEVICE_WIDTH[ui.device], maxWidth: "100%" }}
83
+ >
84
+ <div style={themeVars(theme)} className="min-h-[60vh]">
85
+ {sections.length === 0 ? (
86
+ <button
87
+ type="button"
88
+ onClick={(e) => {
89
+ e.stopPropagation();
90
+ ui.openLibrary(0);
91
+ }}
92
+ className="flex min-h-[60vh] w-full flex-col items-center justify-center gap-3 text-muted-foreground/70 transition hover:text-sky-500"
93
+ >
94
+ <span className="flex size-12 items-center justify-center rounded-full border-2 border-dashed border-current">
95
+ <Plus className="size-6" />
96
+ </span>
97
+ <span className="text-sm font-medium">Add your first section</span>
98
+ </button>
99
+ ) : (
100
+ <>
101
+ <InsertPoint index={0} />
102
+ {sections.map((section, i) => {
103
+ const def = getDefinition(catalog, section.type);
104
+ return (
105
+ <div key={section.id}>
106
+ {def ? (
107
+ <SectionErrorBoundary
108
+ resetKey={section.id}
109
+ onReset={() => setProps(section.id, defaultPropsFor(def))}
110
+ >
111
+ <SectionFrame section={section} index={i} def={def} />
112
+ </SectionErrorBoundary>
113
+ ) : (
114
+ <div className="px-6 py-8 text-center text-xs opacity-40">
115
+ Unknown section: {section.type}
116
+ </div>
117
+ )}
118
+ <InsertPoint index={i + 1} />
119
+ </div>
120
+ );
121
+ })}
122
+ </>
123
+ )}
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,54 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Per-section error boundary. A throwing block Component used to white-screen
5
+ * the whole canvas; this isolates the failure to one section and offers a
6
+ * reset to defaults. Auto-clears when `resetKey` changes (e.g. the section is
7
+ * swapped for another).
8
+ */
9
+ import { Component, type ReactNode } from "react";
10
+
11
+ interface Props {
12
+ resetKey: string;
13
+ onReset?: () => void;
14
+ children: ReactNode;
15
+ }
16
+
17
+ interface State {
18
+ error: Error | null;
19
+ }
20
+
21
+ export class SectionErrorBoundary extends Component<Props, State> {
22
+ state: State = { error: null };
23
+
24
+ static getDerivedStateFromError(error: Error): State {
25
+ return { error };
26
+ }
27
+
28
+ componentDidUpdate(prev: Props) {
29
+ if (this.state.error && prev.resetKey !== this.props.resetKey) {
30
+ this.setState({ error: null });
31
+ }
32
+ }
33
+
34
+ render() {
35
+ const { error } = this.state;
36
+ if (!error) return this.props.children;
37
+ return (
38
+ <div className="m-6 rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
39
+ <p className="font-semibold text-destructive">Section failed to render.</p>
40
+ <pre className="mt-2 max-h-32 overflow-auto whitespace-pre-wrap text-xs text-destructive/80">
41
+ {String(error.message ?? error)}
42
+ </pre>
43
+ {this.props.onReset && (
44
+ <button
45
+ onClick={this.props.onReset}
46
+ className="mt-3 rounded-md border border-destructive/30 px-3 py-1.5 text-xs text-destructive hover:bg-destructive/10"
47
+ >
48
+ Reset to defaults
49
+ </button>
50
+ )}
51
+ </div>
52
+ );
53
+ }
54
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { Plus } from "lucide-react";
4
+ import { useEditorUi } from "./ui-context";
5
+
6
+ /** A zero-height hover zone between sections; reveals a centered "+" that opens
7
+ * the block library to insert at this index. */
8
+ export function InsertPoint({ index }: { index: number }) {
9
+ const ui = useEditorUi();
10
+ return (
11
+ <div className="group/ins relative z-30 h-0">
12
+ {/* Always visible on touch/small screens (group-hover doesn't fire on touch);
13
+ hover-reveal on desktop. Without this a mobile user can't add a 2nd section. */}
14
+ <div className="pointer-events-none absolute inset-x-0 -top-4 flex h-8 items-center justify-center px-6 opacity-100 transition group-hover/ins:opacity-100 md:opacity-0 md:group-hover/ins:opacity-100">
15
+ <div className="h-px flex-1 bg-sky-400/70" />
16
+ <button
17
+ type="button"
18
+ onClick={(e) => {
19
+ e.stopPropagation();
20
+ ui.openLibrary(index);
21
+ }}
22
+ className="pointer-events-auto mx-2 flex size-7 items-center justify-center rounded-full bg-sky-500 text-white shadow-md transition hover:scale-105 hover:bg-sky-600"
23
+ title="Add section here"
24
+ >
25
+ <Plus className="size-4" />
26
+ </button>
27
+ <div className="h-px flex-1 bg-sky-400/70" />
28
+ </div>
29
+ </div>
30
+ );
31
+ }
@@ -0,0 +1,341 @@
1
+ "use client";
2
+
3
+ /** Right-rail inspector: layout-variant switcher + the selected section's
4
+ * non-inline fields (driven by `def.fields`), plus duplicate/delete. */
5
+ import { useContext, useEffect, useRef, useState, type ChangeEvent } from "react";
6
+ import { Monitor, Smartphone, Tablet, X } from "lucide-react";
7
+ import { type Catalog, type FieldDescriptor, getDefinition } from "../registry";
8
+ import { useBuilder } from "../store";
9
+ import { useEditorUi } from "./ui-context";
10
+ import { EditorCapsContext } from "./edit-primitives";
11
+ import type { Breakpoint } from "../model";
12
+ import { cn } from "../lib/cn";
13
+
14
+ const BP: { id: Breakpoint; icon: typeof Monitor; label: string }[] = [
15
+ { id: "mobile", icon: Smartphone, label: "Mobile" },
16
+ { id: "tablet", icon: Tablet, label: "Tablet" },
17
+ { id: "desktop", icon: Monitor, label: "Desktop" },
18
+ ];
19
+
20
+ export function Inspector({ catalog }: { catalog: Catalog }) {
21
+ const ui = useEditorUi();
22
+ const selectedId = useBuilder((s) => s.selectedId);
23
+ const section = useBuilder(
24
+ (s) => s.doc.sections.find((x) => x.id === selectedId) ?? null,
25
+ );
26
+ const updateProps = useBuilder((s) => s.updateProps);
27
+ const setVariant = useBuilder((s) => s.setVariant);
28
+ const setHideOn = useBuilder((s) => s.setHideOn);
29
+ const remove = useBuilder((s) => s.removeSection);
30
+ const duplicate = useBuilder((s) => s.duplicateSection);
31
+
32
+ if (!ui.inspectorOpen || !section) return null;
33
+ const def = getDefinition(catalog, section.type);
34
+ if (!def) return null;
35
+
36
+ const hidden = new Set<Breakpoint>(section.hideOn ?? []);
37
+ const toggleBp = (bp: Breakpoint) => {
38
+ const next = new Set(hidden);
39
+ if (next.has(bp)) next.delete(bp);
40
+ else next.add(bp);
41
+ setHideOn(section.id, [...next]);
42
+ };
43
+
44
+ return (
45
+ <div className="flex h-full min-h-0 flex-col">
46
+ <div className="flex items-center justify-between border-b px-4 py-3">
47
+ <div>
48
+ <p className="text-[11px] uppercase tracking-wider text-muted-foreground/70">Section</p>
49
+ <h3 className="text-sm font-semibold">{def.label}</h3>
50
+ </div>
51
+ <button
52
+ onClick={() => ui.setInspectorOpen(false)}
53
+ className="rounded p-1 hover:bg-accent"
54
+ >
55
+ <X className="size-4" />
56
+ </button>
57
+ </div>
58
+ <div className="flex-1 space-y-5 overflow-auto p-4">
59
+ <div>
60
+ <label className="mb-1.5 block text-xs font-medium text-muted-foreground">
61
+ Show on
62
+ </label>
63
+ <div className="grid grid-cols-3 gap-1.5">
64
+ {BP.map((b) => {
65
+ const Icon = b.icon;
66
+ const isHidden = hidden.has(b.id);
67
+ return (
68
+ <button
69
+ key={b.id}
70
+ title={isHidden ? `Show on ${b.label.toLowerCase()}` : `Hide on ${b.label.toLowerCase()}`}
71
+ onClick={() => toggleBp(b.id)}
72
+ className={cn(
73
+ "flex items-center justify-center gap-1 rounded-md border px-1.5 py-1.5 text-[11px]",
74
+ isHidden
75
+ ? "border-border text-muted-foreground/50 line-through opacity-60"
76
+ : "border-foreground/30 bg-secondary text-secondary-foreground",
77
+ )}
78
+ >
79
+ <Icon className="size-3.5" />
80
+ <span className="hidden sm:inline">{b.label}</span>
81
+ </button>
82
+ );
83
+ })}
84
+ </div>
85
+ </div>
86
+ {def.variants && def.variants.length > 1 && (
87
+ <div>
88
+ <label className="mb-1.5 block text-xs font-medium text-muted-foreground">
89
+ Layout
90
+ </label>
91
+ <div className="grid grid-cols-2 gap-2">
92
+ {def.variants.map((v) => (
93
+ <button
94
+ key={v.id}
95
+ onClick={() =>
96
+ setVariant(section.id, v.id, {
97
+ ...def.defaults,
98
+ ...section.props,
99
+ ...(v.props ?? {}),
100
+ })
101
+ }
102
+ className={cn(
103
+ "rounded-md border px-2 py-1.5 text-xs",
104
+ section.variant === v.id
105
+ ? "border-foreground/30 bg-secondary text-secondary-foreground"
106
+ : "border-border hover:border-foreground/30",
107
+ )}
108
+ >
109
+ {v.label}
110
+ </button>
111
+ ))}
112
+ </div>
113
+ </div>
114
+ )}
115
+ {(def.fields ?? []).map((f) => (
116
+ <Field
117
+ key={f.key}
118
+ field={f}
119
+ value={section.props[f.key]}
120
+ allProps={section.props}
121
+ setProp={(key, val) => updateProps(section.id, { [key]: val })}
122
+ />
123
+ ))}
124
+ {(def.fields ?? []).length === 0 &&
125
+ (!def.variants || def.variants.length <= 1) && (
126
+ <p className="text-xs text-muted-foreground/70">
127
+ This section is edited directly on the canvas.
128
+ </p>
129
+ )}
130
+ </div>
131
+ <div className="flex gap-2 border-t p-3">
132
+ <button
133
+ onClick={() => duplicate(section.id)}
134
+ className="flex-1 rounded-md border px-3 py-2 text-xs hover:bg-muted"
135
+ >
136
+ Duplicate
137
+ </button>
138
+ <button
139
+ onClick={() => remove(section.id)}
140
+ className="flex-1 rounded-md border border-red-200 px-3 py-2 text-xs text-red-600 hover:bg-red-50"
141
+ >
142
+ Delete
143
+ </button>
144
+ </div>
145
+ </div>
146
+ );
147
+ }
148
+
149
+ const INPUT =
150
+ "w-full rounded-md border border-border px-2.5 py-1.5 text-sm focus:border-foreground/40 focus:outline-none focus:ring-2 focus:ring-ring/30";
151
+
152
+ /**
153
+ * Hold a local draft while focused; commit on blur. Keeps typing in a text/
154
+ * textarea/number field to ONE undo step (instead of one per keystroke) and
155
+ * matches the canvas EditableText blur-commit behavior. Selects don't need it
156
+ * (they commit once per pick). Syncs from the prop when unfocused so external
157
+ * changes (undo/redo, variant switch) still land.
158
+ */
159
+ function useBlurCommit(value: string, onCommit: (v: string) => void) {
160
+ const [local, setLocal] = useState(value);
161
+ const focused = useRef(false);
162
+ if (!focused.current && local !== value) setLocal(value);
163
+ return {
164
+ value: local,
165
+ onFocus: () => {
166
+ focused.current = true;
167
+ },
168
+ onBlur: () => {
169
+ focused.current = false;
170
+ if (local !== value) onCommit(local);
171
+ },
172
+ set: setLocal,
173
+ };
174
+ }
175
+
176
+ function Field({
177
+ field,
178
+ value,
179
+ allProps,
180
+ setProp,
181
+ }: {
182
+ field: FieldDescriptor;
183
+ value: unknown;
184
+ allProps: Record<string, unknown>;
185
+ setProp: (key: string, val: unknown) => void;
186
+ }) {
187
+ const str = typeof value === "string" ? value : value == null ? "" : String(value);
188
+ const onChange = (val: unknown) => setProp(field.key, val);
189
+ const c = useBlurCommit(str, (v) => onChange(v));
190
+ return (
191
+ <div>
192
+ <label className="mb-1.5 block text-xs font-medium text-muted-foreground">
193
+ {field.label}
194
+ </label>
195
+ {field.widget === "textarea" || field.widget === "list" ? (
196
+ <textarea
197
+ className={cn(INPUT, "min-h-24 resize-y font-mono text-xs")}
198
+ value={c.value}
199
+ onFocus={c.onFocus}
200
+ onChange={(e) => c.set(e.target.value)}
201
+ onBlur={c.onBlur}
202
+ />
203
+ ) : field.widget === "select" ? (
204
+ <select className={INPUT} value={str} onChange={(e) => onChange(e.target.value)}>
205
+ {field.options.map((o) => (
206
+ <option key={o.value} value={o.value}>
207
+ {o.label}
208
+ </option>
209
+ ))}
210
+ </select>
211
+ ) : field.widget === "number" ? (
212
+ <input
213
+ type="number"
214
+ className={INPUT}
215
+ value={c.value}
216
+ min={field.min}
217
+ max={field.max}
218
+ onFocus={c.onFocus}
219
+ onChange={(e) => c.set(e.target.value)}
220
+ onBlur={() => {
221
+ const v = c.value;
222
+ c.onBlur();
223
+ onChange(v === "" ? undefined : Number(v));
224
+ }}
225
+ />
226
+ ) : field.widget === "image" ? (
227
+ <ImageField
228
+ value={str}
229
+ onChange={onChange}
230
+ altKey={field.altKey}
231
+ altValue={
232
+ field.altKey
233
+ ? typeof allProps[field.altKey] === "string"
234
+ ? (allProps[field.altKey] as string)
235
+ : ""
236
+ : undefined
237
+ }
238
+ onAltChange={(alt) => field.altKey && setProp(field.altKey, alt)}
239
+ />
240
+ ) : (
241
+ <input
242
+ type={field.widget === "url" ? "url" : "text"}
243
+ className={INPUT}
244
+ value={c.value}
245
+ placeholder={field.placeholder}
246
+ onFocus={c.onFocus}
247
+ onChange={(e) => c.set(e.target.value)}
248
+ onBlur={c.onBlur}
249
+ />
250
+ )}
251
+ {field.help && <p className="mt-1 text-[11px] text-muted-foreground/70">{field.help}</p>}
252
+ </div>
253
+ );
254
+ }
255
+
256
+ function ImageField({
257
+ value,
258
+ onChange,
259
+ altKey,
260
+ altValue,
261
+ onAltChange,
262
+ }: {
263
+ value: string;
264
+ onChange: (val: unknown) => void;
265
+ altKey?: string;
266
+ altValue?: string;
267
+ onAltChange?: (alt: string) => void;
268
+ }) {
269
+ const caps = useContext(EditorCapsContext);
270
+ const ref = useRef<HTMLInputElement>(null);
271
+ const [busy, setBusy] = useState(false);
272
+ // Track object URLs we minted so we can revoke them (avoid leaks on replace/unmount).
273
+ const blobRef = useRef<string | null>(null);
274
+ useEffect(() => {
275
+ return () => {
276
+ if (blobRef.current) URL.revokeObjectURL(blobRef.current);
277
+ };
278
+ }, []);
279
+ async function pick(e: ChangeEvent<HTMLInputElement>) {
280
+ const file = e.target.files?.[0];
281
+ e.target.value = "";
282
+ if (!file) return;
283
+ setBusy(true);
284
+ try {
285
+ const usedFallback = !caps.uploadImage;
286
+ const url = caps.uploadImage
287
+ ? await caps.uploadImage(file)
288
+ : URL.createObjectURL(file);
289
+ if (usedFallback) {
290
+ if (blobRef.current) URL.revokeObjectURL(blobRef.current);
291
+ blobRef.current = url;
292
+ }
293
+ onChange(url);
294
+ } finally {
295
+ setBusy(false);
296
+ }
297
+ }
298
+ return (
299
+ <div className="space-y-2">
300
+ {value ? (
301
+ // eslint-disable-next-line @next/next/no-img-element
302
+ <img src={value} alt={altValue ?? ""} className="h-24 w-full rounded-md object-cover" />
303
+ ) : (
304
+ <div className="flex h-24 items-center justify-center rounded-md border border-dashed text-xs text-muted-foreground/70">
305
+ No image
306
+ </div>
307
+ )}
308
+ <div className="flex gap-2">
309
+ <button
310
+ onClick={() => ref.current?.click()}
311
+ className="flex-1 rounded-md border px-2 py-1.5 text-xs hover:bg-muted"
312
+ >
313
+ {busy ? "Uploading…" : value ? "Replace" : "Upload"}
314
+ </button>
315
+ {value && (
316
+ <button
317
+ onClick={() => onChange("")}
318
+ className="rounded-md border px-2 py-1.5 text-xs hover:bg-muted"
319
+ >
320
+ Remove
321
+ </button>
322
+ )}
323
+ </div>
324
+ <input ref={ref} type="file" accept="image/*" hidden onChange={pick} />
325
+ <input
326
+ value={value}
327
+ onChange={(e) => onChange(e.target.value)}
328
+ placeholder="or paste image URL"
329
+ className="w-full rounded-md border border-border px-2.5 py-1.5 text-xs"
330
+ />
331
+ {altKey && (
332
+ <input
333
+ value={altValue ?? ""}
334
+ onChange={(e) => onAltChange?.(e.target.value)}
335
+ placeholder={`Alt text (a11y + SEO)`}
336
+ className="w-full rounded-md border border-border px-2.5 py-1.5 text-xs"
337
+ />
338
+ )}
339
+ </div>
340
+ );
341
+ }