@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,131 @@
1
+ "use client";
2
+
3
+ /** Theme controls: curated font pairings + palettes + spacing/radius. */
4
+ import { FONT_PAIRS, PALETTES } from "../theme";
5
+ import { useBuilder } from "../store";
6
+ import type { ThemeTokens } from "../model";
7
+ import { cn } from "../lib/cn";
8
+
9
+ export function ThemePanel() {
10
+ const theme = useBuilder((s) => s.doc.theme);
11
+ const setTheme = useBuilder((s) => s.setTheme);
12
+
13
+ return (
14
+ <div className="space-y-6 p-4">
15
+ <Section title="Fonts">
16
+ <div className="space-y-2">
17
+ {FONT_PAIRS.map((p) => (
18
+ <button
19
+ key={p.id}
20
+ onClick={() => setTheme({ fontHeading: p.heading, fontBody: p.body })}
21
+ className={cn(
22
+ "flex w-full items-baseline justify-between gap-2 rounded-md border px-3 py-2 text-left",
23
+ theme.fontHeading === p.heading
24
+ ? "border-foreground/30 bg-secondary"
25
+ : "border-border hover:border-foreground/30",
26
+ )}
27
+ >
28
+ <span style={{ fontFamily: `'${p.heading}', serif` }} className="text-lg">
29
+ {p.label}
30
+ </span>
31
+ <span className="truncate text-[10px] text-muted-foreground/70">
32
+ {p.heading} / {p.body}
33
+ </span>
34
+ </button>
35
+ ))}
36
+ </div>
37
+ </Section>
38
+
39
+ <Section title="Palette">
40
+ <div className="grid grid-cols-2 gap-2">
41
+ {PALETTES.map((p) => (
42
+ <button
43
+ key={p.id}
44
+ onClick={() => setTheme({ colors: p.colors })}
45
+ className={cn(
46
+ "flex items-center gap-2 rounded-md border px-3 py-2",
47
+ theme.colors.accent === p.colors.accent && theme.colors.bg === p.colors.bg
48
+ ? "border-foreground/30 bg-secondary"
49
+ : "border-border hover:border-foreground/30",
50
+ )}
51
+ >
52
+ <span className="flex gap-1">
53
+ {[p.colors.bg, p.colors.text, p.colors.accent].map((c, i) => (
54
+ <span
55
+ key={i}
56
+ className="size-4 rounded-full border border-black/10"
57
+ style={{ background: c }}
58
+ />
59
+ ))}
60
+ </span>
61
+ <span className="text-xs">{p.label}</span>
62
+ </button>
63
+ ))}
64
+ </div>
65
+ </Section>
66
+
67
+ <Section title="Spacing">
68
+ <Segmented
69
+ options={[
70
+ { value: "tight", label: "Tight" },
71
+ { value: "normal", label: "Normal" },
72
+ { value: "roomy", label: "Roomy" },
73
+ ]}
74
+ value={theme.spacing}
75
+ onChange={(v) => setTheme({ spacing: v as ThemeTokens["spacing"] })}
76
+ />
77
+ </Section>
78
+
79
+ <Section title="Corners">
80
+ <Segmented
81
+ options={[
82
+ { value: "none", label: "None" },
83
+ { value: "sm", label: "S" },
84
+ { value: "md", label: "M" },
85
+ { value: "lg", label: "L" },
86
+ ]}
87
+ value={theme.radius}
88
+ onChange={(v) => setTheme({ radius: v as ThemeTokens["radius"] })}
89
+ />
90
+ </Section>
91
+ </div>
92
+ );
93
+ }
94
+
95
+ function Section({ title, children }: { title: string; children: React.ReactNode }) {
96
+ return (
97
+ <div>
98
+ <h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
99
+ {title}
100
+ </h4>
101
+ {children}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ function Segmented({
107
+ options,
108
+ value,
109
+ onChange,
110
+ }: {
111
+ options: { value: string; label: string }[];
112
+ value: string;
113
+ onChange: (v: string) => void;
114
+ }) {
115
+ return (
116
+ <div className="inline-flex rounded-md border border-border p-0.5">
117
+ {options.map((o) => (
118
+ <button
119
+ key={o.value}
120
+ onClick={() => onChange(o.value)}
121
+ className={cn(
122
+ "rounded px-3 py-1 text-xs",
123
+ value === o.value ? "bg-foreground text-background" : "text-muted-foreground hover:bg-muted",
124
+ )}
125
+ >
126
+ {o.label}
127
+ </button>
128
+ ))}
129
+ </div>
130
+ );
131
+ }
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ /** Single cohesive editor header: gate (exit + site name) · design · templates ·
4
+ * device preview · undo/redo · view · save status · publish. Theme-optimized
5
+ * (bg-card surface, semantic tokens). */
6
+ import type { ReactNode } from "react";
7
+ import {
8
+ ExternalLink,
9
+ LayoutGrid,
10
+ Monitor,
11
+ Palette,
12
+ Redo2,
13
+ Smartphone,
14
+ Tablet,
15
+ Undo2,
16
+ } from "lucide-react";
17
+ import { useTemporal } from "../store";
18
+ import { type Device, useEditorUi } from "./ui-context";
19
+ import { cn } from "../lib/cn";
20
+
21
+ const DEVICES: { id: Device; icon: typeof Monitor; label: string }[] = [
22
+ { id: "desktop", icon: Monitor, label: "Desktop" },
23
+ { id: "tablet", icon: Tablet, label: "Tablet" },
24
+ { id: "mobile", icon: Smartphone, label: "Mobile" },
25
+ ];
26
+
27
+ export function Toolbar({
28
+ onPublish,
29
+ saving = "idle",
30
+ designOpen,
31
+ onToggleDesign,
32
+ hasTemplates,
33
+ leading,
34
+ viewUrl,
35
+ }: {
36
+ onPublish?: () => void;
37
+ saving?: "idle" | "saving" | "saved";
38
+ designOpen: boolean;
39
+ onToggleDesign: () => void;
40
+ hasTemplates?: boolean;
41
+ /** Gate content (exit + site name), pinned far left. */
42
+ leading?: ReactNode;
43
+ /** "View live site" link target. */
44
+ viewUrl?: string;
45
+ }) {
46
+ const ui = useEditorUi();
47
+ const undo = useTemporal((s) => s.undo);
48
+ const redo = useTemporal((s) => s.redo);
49
+ const canUndo = useTemporal((s) => s.pastStates.length > 0);
50
+ const canRedo = useTemporal((s) => s.futureStates.length > 0);
51
+
52
+ return (
53
+ <header className="relative z-40 flex h-12 shrink-0 items-center gap-1 border-b border-border bg-card px-2 text-foreground sm:px-3">
54
+ {leading && (
55
+ <div className="flex min-w-0 items-center gap-1.5 pr-1.5 sm:border-r sm:border-border">
56
+ {leading}
57
+ </div>
58
+ )}
59
+ <div className="flex items-center gap-0.5 pl-0.5">
60
+ <ToolButton active={designOpen} onClick={onToggleDesign} title="Design">
61
+ <Palette className="size-4" />
62
+ <span className="hidden text-xs md:inline">Design</span>
63
+ </ToolButton>
64
+ {hasTemplates && (
65
+ <ToolButton onClick={() => ui.setTemplatesOpen(true)} title="Templates">
66
+ <LayoutGrid className="size-4" />
67
+ <span className="hidden text-xs md:inline">Templates</span>
68
+ </ToolButton>
69
+ )}
70
+ </div>
71
+
72
+ <div className="absolute left-1/2 flex -translate-x-1/2 items-center rounded-lg border border-border bg-background p-0.5">
73
+ {DEVICES.map((d) => {
74
+ const Icon = d.icon;
75
+ return (
76
+ <button
77
+ key={d.id}
78
+ title={d.label}
79
+ onClick={() => ui.setDevice(d.id)}
80
+ className={cn(
81
+ "rounded-md p-1.5 transition",
82
+ ui.device === d.id
83
+ ? "bg-secondary text-secondary-foreground"
84
+ : "text-muted-foreground hover:bg-muted",
85
+ )}
86
+ >
87
+ <Icon className="size-4" />
88
+ </button>
89
+ );
90
+ })}
91
+ </div>
92
+
93
+ <div className="ml-auto flex items-center gap-0.5">
94
+ <button
95
+ onClick={() => undo()}
96
+ disabled={!canUndo}
97
+ title="Undo"
98
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
99
+ >
100
+ <Undo2 className="size-4" />
101
+ </button>
102
+ <button
103
+ onClick={() => redo()}
104
+ disabled={!canRedo}
105
+ title="Redo"
106
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-muted disabled:opacity-30"
107
+ >
108
+ <Redo2 className="size-4" />
109
+ </button>
110
+ {viewUrl && (
111
+ <a
112
+ href={viewUrl}
113
+ target="_blank"
114
+ rel="noreferrer"
115
+ title="View live site"
116
+ className="ml-1 hidden items-center gap-1 rounded-md px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted hover:text-foreground lg:flex"
117
+ >
118
+ <ExternalLink className="size-3.5" /> View
119
+ </a>
120
+ )}
121
+ <span className="mx-1 w-12 text-right text-[11px] text-muted-foreground/70">
122
+ {saving === "saving" ? "Saving…" : saving === "saved" ? "Saved" : ""}
123
+ </span>
124
+ {onPublish && (
125
+ <button
126
+ onClick={onPublish}
127
+ className="rounded-lg bg-primary px-3.5 py-1.5 text-sm font-medium text-primary-foreground shadow-sm transition hover:opacity-90 sm:px-4"
128
+ >
129
+ Publish
130
+ </button>
131
+ )}
132
+ </div>
133
+ </header>
134
+ );
135
+ }
136
+
137
+ function ToolButton({
138
+ active,
139
+ onClick,
140
+ title,
141
+ children,
142
+ }: {
143
+ active?: boolean;
144
+ onClick: () => void;
145
+ title: string;
146
+ children: React.ReactNode;
147
+ }) {
148
+ return (
149
+ <button
150
+ onClick={onClick}
151
+ title={title}
152
+ className={cn(
153
+ "flex items-center gap-1.5 rounded-md px-2 py-1.5 sm:px-2.5",
154
+ active
155
+ ? "bg-secondary text-secondary-foreground"
156
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
157
+ )}
158
+ >
159
+ {children}
160
+ </button>
161
+ );
162
+ }
@@ -0,0 +1,61 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Drag wiring on @atlaskit/pragmatic-drag-and-drop. Sections are draggable +
5
+ * drop targets; block-library variants are draggable. A single monitor in the
6
+ * Canvas resolves drops (reorder existing / insert from library) using the
7
+ * pointer position vs. the target's mid-line to pick an edge.
8
+ */
9
+ import { useEffect, type RefObject } from "react";
10
+ import {
11
+ draggable,
12
+ dropTargetForElements,
13
+ } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
14
+
15
+ export type DragData =
16
+ | { kind: "section"; id: string; index: number }
17
+ | { kind: "lib-variant"; type: string; variant?: string };
18
+
19
+ /** Make an element draggable, optionally via a handle. */
20
+ export function useDraggable(
21
+ elementRef: RefObject<HTMLElement | null>,
22
+ data: DragData,
23
+ handleRef?: RefObject<HTMLElement | null>,
24
+ ) {
25
+ const key = JSON.stringify(data);
26
+ useEffect(() => {
27
+ const element = elementRef.current;
28
+ if (!element) return;
29
+ return draggable({
30
+ element,
31
+ dragHandle: handleRef?.current ?? undefined,
32
+ getInitialData: () => data as unknown as Record<string, unknown>,
33
+ });
34
+ // eslint-disable-next-line react-hooks/exhaustive-deps
35
+ }, [key]);
36
+ }
37
+
38
+ /** Mark an element as a drop target carrying its section index. */
39
+ export function useDropTarget(
40
+ elementRef: RefObject<HTMLElement | null>,
41
+ data: { id: string; index: number },
42
+ onOver?: (isOver: boolean) => void,
43
+ ) {
44
+ const key = `${data.id}:${data.index}`;
45
+ useEffect(() => {
46
+ const element = elementRef.current;
47
+ if (!element) return;
48
+ return dropTargetForElements({
49
+ element,
50
+ getData: () => ({ targetIndex: data.index }),
51
+ onDragEnter: () => onOver?.(true),
52
+ onDragLeave: () => onOver?.(false),
53
+ onDrop: () => onOver?.(false),
54
+ });
55
+ // eslint-disable-next-line react-hooks/exhaustive-deps
56
+ }, [key]);
57
+ }
58
+
59
+ export {
60
+ monitorForElements,
61
+ } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
@@ -0,0 +1,164 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Editor-side editable primitives. The canvas injects `editPrimitives` into each
5
+ * section's `edit` prop; blocks render them via `renderText`/`renderImage`.
6
+ * Plain text is edited in place (contentEditable, commit on blur); images open a
7
+ * file picker wired to the app's `uploadImage` capability.
8
+ */
9
+ import {
10
+ createContext,
11
+ createElement,
12
+ useContext,
13
+ useEffect,
14
+ useRef,
15
+ useState,
16
+ type ChangeEvent,
17
+ type ElementType,
18
+ type FocusEvent as ReactFocusEvent,
19
+ type KeyboardEvent as ReactKeyboardEvent,
20
+ type MouseEvent as ReactMouseEvent,
21
+ type RefObject,
22
+ } from "react";
23
+ import type { EditPrimitives, ImageEditProps, TextEditProps } from "../editable";
24
+ import { useBuilder } from "../store";
25
+ import { cn } from "../lib/cn";
26
+
27
+ /** Id of the section currently being rendered on the canvas. */
28
+ export const EditSectionContext = createContext<string | null>(null);
29
+
30
+ export interface EditorCapabilities {
31
+ uploadImage?: (file: File) => Promise<string>;
32
+ }
33
+ export const EditorCapsContext = createContext<EditorCapabilities>({});
34
+
35
+ function EditableText({
36
+ field,
37
+ value,
38
+ as,
39
+ className,
40
+ style,
41
+ placeholder,
42
+ multiline,
43
+ }: TextEditProps) {
44
+ const Tag = (as ?? "span") as ElementType;
45
+ const sectionId = useContext(EditSectionContext);
46
+ const updateProps = useBuilder((s) => s.updateProps);
47
+ const ref = useRef<HTMLElement>(null) as RefObject<HTMLElement>;
48
+
49
+ // Set textContent imperatively — never let React reconcile children here,
50
+ // because a parent re-render mid-keystroke would reset the DOM and lose the
51
+ // cursor + any uncommitted characters. Skip while focused (user owns the DOM).
52
+ useEffect(() => {
53
+ const el = ref.current;
54
+ if (!el) return;
55
+ if (typeof document !== "undefined" && document.activeElement === el) return;
56
+ const next = value ?? "";
57
+ if (el.textContent !== next) el.textContent = next;
58
+ }, [value]);
59
+
60
+ return createElement(
61
+ Tag,
62
+ {
63
+ ref,
64
+ className: cn("outline-none focus:ring-2 focus:ring-sky-400/60 rounded-sm", className),
65
+ style,
66
+ contentEditable: true,
67
+ suppressContentEditableWarning: true,
68
+ spellCheck: false,
69
+ "data-pb-field": field,
70
+ "data-pb-placeholder": placeholder ?? "",
71
+ onClick: (e: ReactMouseEvent) => e.stopPropagation(),
72
+ onBlur: (e: ReactFocusEvent<HTMLElement>) => {
73
+ if (!sectionId) return;
74
+ const text = e.currentTarget.textContent ?? "";
75
+ if (text !== value) updateProps(sectionId, { [field]: text });
76
+ },
77
+ onKeyDown: (e: ReactKeyboardEvent<HTMLElement>) => {
78
+ if (!multiline && e.key === "Enter") {
79
+ e.preventDefault();
80
+ e.currentTarget.blur();
81
+ }
82
+ },
83
+ },
84
+ // No React-managed children — textContent is set via the effect above so the
85
+ // user's cursor is never clobbered by a re-render.
86
+ );
87
+ }
88
+
89
+ function EditableImage({ field, src, alt, className, style, width, height, priority }: ImageEditProps) {
90
+ const sectionId = useContext(EditSectionContext);
91
+ const updateProps = useBuilder((s) => s.updateProps);
92
+ const caps = useContext(EditorCapsContext);
93
+ const inputRef = useRef<HTMLInputElement>(null);
94
+ const [busy, setBusy] = useState(false);
95
+ // Track object URLs we minted so we can revoke them (avoid leaks on replace/unmount).
96
+ const blobRef = useRef<string | null>(null);
97
+ useEffect(() => {
98
+ return () => {
99
+ if (blobRef.current) URL.revokeObjectURL(blobRef.current);
100
+ };
101
+ }, []);
102
+
103
+ async function onPick(e: ChangeEvent<HTMLInputElement>) {
104
+ const file = e.target.files?.[0];
105
+ e.target.value = "";
106
+ if (!file || !sectionId) return;
107
+ setBusy(true);
108
+ try {
109
+ const usedFallback = !caps.uploadImage;
110
+ const url = caps.uploadImage
111
+ ? await caps.uploadImage(file)
112
+ : URL.createObjectURL(file);
113
+ if (usedFallback) {
114
+ if (blobRef.current) URL.revokeObjectURL(blobRef.current);
115
+ blobRef.current = url;
116
+ }
117
+ updateProps(sectionId, { [field]: url });
118
+ } finally {
119
+ setBusy(false);
120
+ }
121
+ }
122
+
123
+ const open = (e: ReactMouseEvent) => {
124
+ e.stopPropagation();
125
+ inputRef.current?.click();
126
+ };
127
+
128
+ return (
129
+ <>
130
+ {src ? (
131
+ // eslint-disable-next-line @next/next/no-img-element
132
+ <img
133
+ src={src}
134
+ alt={alt ?? ""}
135
+ width={width}
136
+ height={height}
137
+ loading={priority ? "eager" : "lazy"}
138
+ fetchPriority={priority ? "high" : "auto"}
139
+ className={cn("cursor-pointer outline-2 outline-offset-2 outline-transparent hover:outline-sky-400", className)}
140
+ style={style}
141
+ title={busy ? "Uploading…" : "Click to replace"}
142
+ onClick={open}
143
+ />
144
+ ) : (
145
+ <div
146
+ className={cn(
147
+ "flex cursor-pointer items-center justify-center bg-black/[0.04] text-[11px] uppercase tracking-wider opacity-50 outline-2 outline-dashed outline-black/15 hover:outline-sky-400",
148
+ className,
149
+ )}
150
+ style={style}
151
+ onClick={open}
152
+ >
153
+ {busy ? "Uploading…" : "Add image"}
154
+ </div>
155
+ )}
156
+ <input ref={inputRef} type="file" accept="image/*" hidden onChange={onPick} />
157
+ </>
158
+ );
159
+ }
160
+
161
+ export const editPrimitives: EditPrimitives = {
162
+ Text: EditableText,
163
+ Image: EditableImage,
164
+ };
@@ -0,0 +1,8 @@
1
+ "use client";
2
+
3
+ /** Client editor entry. Production rendering lives at
4
+ * "@jimmy_harika/websitekit/renderer"; the model/registry at the package root. */
5
+ export { PageBuilder, type PageBuilderProps } from "./PageBuilder";
6
+ export { type TemplateMeta } from "./TemplateGallery";
7
+ export { type Device } from "./ui-context";
8
+ export { type EditorCapabilities } from "./edit-primitives";
@@ -0,0 +1,106 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Editor keyboard shortcuts. Binds one keydown listener (refs keep it current
5
+ * without re-binding on every render). Ignored while typing in an input /
6
+ * textarea / contenteditable, EXCEPT global escapes (⌘⇧P publish, Esc).
7
+ *
8
+ * ⌘Z / ⌘⇧Z undo / redo
9
+ * Delete remove the selected section
10
+ * ⌘D duplicate the selected section
11
+ * Esc close the library / deselect
12
+ * ⌘/ toggle the block library
13
+ * ⌘⇧P publish
14
+ * ↑ / ↓ move the selected section up / down
15
+ */
16
+ import { useEffect, useRef } from "react";
17
+ import { useBuilder, useTemporal } from "../store";
18
+ import type { EditorUi } from "./ui-context";
19
+
20
+ function isTypingTarget(target: EventTarget | null): boolean {
21
+ if (!(target instanceof HTMLElement)) return false;
22
+ const tag = target.tagName;
23
+ return (
24
+ tag === "INPUT" ||
25
+ tag === "TEXTAREA" ||
26
+ tag === "SELECT" ||
27
+ target.isContentEditable
28
+ );
29
+ }
30
+
31
+ export function useEditorShortcuts({ ui, onPublish }: { ui: EditorUi; onPublish?: () => void }) {
32
+ const selectedId = useBuilder((s) => s.selectedId);
33
+ const sections = useBuilder((s) => s.doc.sections);
34
+ const select = useBuilder((s) => s.select);
35
+ const removeSection = useBuilder((s) => s.removeSection);
36
+ const duplicateSection = useBuilder((s) => s.duplicateSection);
37
+ const moveSection = useBuilder((s) => s.moveSection);
38
+ const undo = useTemporal((t) => t.undo);
39
+ const redo = useTemporal((t) => t.redo);
40
+
41
+ // Refs so the listener binds once and always reads current values.
42
+ const ref = useRef({ selectedId, sections, select, removeSection, duplicateSection, moveSection, undo, redo, ui, onPublish });
43
+ ref.current = { selectedId, sections, select, removeSection, duplicateSection, moveSection, undo, redo, ui, onPublish };
44
+
45
+ useEffect(() => {
46
+ const onKey = (e: KeyboardEvent) => {
47
+ const s = ref.current;
48
+ const mod = e.metaKey || e.ctrlKey;
49
+ const typing = isTypingTarget(e.target);
50
+ const key = e.key;
51
+
52
+ // ⌘⇧P — publish (global; works even while typing).
53
+ if (mod && e.shiftKey && key.toLowerCase() === "p") {
54
+ if (s.onPublish) {
55
+ e.preventDefault();
56
+ s.onPublish();
57
+ }
58
+ return;
59
+ }
60
+ // Esc — close library, else deselect. Always available.
61
+ if (key === "Escape") {
62
+ if (s.ui.libraryIndex !== null) s.ui.closeLibrary();
63
+ else if (s.selectedId) s.select(null);
64
+ return;
65
+ }
66
+ // Everything else is ignored while typing.
67
+ if (typing) return;
68
+
69
+ if (mod && key.toLowerCase() === "z") {
70
+ e.preventDefault();
71
+ if (e.shiftKey) s.redo();
72
+ else s.undo();
73
+ return;
74
+ }
75
+ if (mod && key === "/") {
76
+ e.preventDefault();
77
+ if (s.ui.libraryIndex !== null) s.ui.closeLibrary();
78
+ else s.ui.openLibrary(0);
79
+ return;
80
+ }
81
+ if (mod && key.toLowerCase() === "d") {
82
+ if (!s.selectedId) return;
83
+ e.preventDefault();
84
+ s.duplicateSection(s.selectedId);
85
+ return;
86
+ }
87
+ if ((key === "Delete" || key === "Backspace") && s.selectedId) {
88
+ e.preventDefault();
89
+ s.removeSection(s.selectedId);
90
+ return;
91
+ }
92
+ if (key === "ArrowUp" || key === "ArrowDown") {
93
+ if (!s.selectedId) return;
94
+ const idx = s.sections.findIndex((sec) => sec.id === s.selectedId);
95
+ if (idx === -1) return;
96
+ const to = key === "ArrowUp" ? idx - 1 : idx + 1;
97
+ if (to < 0 || to >= s.sections.length) return;
98
+ e.preventDefault();
99
+ s.moveSection(idx, to);
100
+ return;
101
+ }
102
+ };
103
+ window.addEventListener("keydown", onKey);
104
+ return () => window.removeEventListener("keydown", onKey);
105
+ }, []);
106
+ }
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+
5
+ export type Device = "desktop" | "tablet" | "mobile";
6
+
7
+ /** Which panel is shown as a bottom sheet on mobile (< md). Null = none. */
8
+ export type MobilePanel = "sections" | "design" | "inspector" | null;
9
+
10
+ /** Cross-component editor UI state (not part of the document / history). */
11
+ export interface EditorUi {
12
+ device: Device;
13
+ setDevice(d: Device): void;
14
+ /** Insert index when the block library is open; null = library closed. */
15
+ libraryIndex: number | null;
16
+ openLibrary(index: number | null): void;
17
+ closeLibrary(): void;
18
+ inspectorOpen: boolean;
19
+ setInspectorOpen(open: boolean): void;
20
+ templatesOpen: boolean;
21
+ setTemplatesOpen(open: boolean): void;
22
+ mobilePanel: MobilePanel;
23
+ setMobilePanel(p: MobilePanel): void;
24
+ }
25
+
26
+ export const EditorUiContext = createContext<EditorUi | null>(null);
27
+
28
+ export function useEditorUi(): EditorUi {
29
+ const v = useContext(EditorUiContext);
30
+ if (!v) throw new Error("useEditorUi must be used within <PageBuilder>");
31
+ return v;
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @jimmy_harika/websitekit — curated-section website builder engine.
3
+ *
4
+ * Root export = framework-agnostic model + registry + theme (safe in RSC and
5
+ * client). The editor (client-only) is at "@jimmy_harika/websitekit/editor", the
6
+ * renderer (RSC-safe) at "@jimmy_harika/websitekit/renderer", the store at
7
+ * "@jimmy_harika/websitekit/store".
8
+ */
9
+ export * from "./model";
10
+ export * from "./registry";
11
+ export * from "./theme";
package/src/lib/cn.ts ADDED
@@ -0,0 +1,8 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ /** Tailwind-aware className merge. Kept local so the engine has no @pixbox/ui
5
+ * dependency and stays independently publishable. */
6
+ export function cn(...inputs: ClassValue[]): string {
7
+ return twMerge(clsx(inputs));
8
+ }