@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,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
|
+
}
|