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