@drawnagency/primitives 0.1.65 → 0.1.67
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/dist/chunk-4VMAZZXT.js +968 -0
- package/dist/{chunk-QYQ3DQHI.js → chunk-KM6HOW4H.js} +127 -40
- package/dist/{chunk-CBWK3KPV.js → chunk-VU3XR362.js} +4 -0
- package/dist/{chunk-FECCYOPJ.js → chunk-WJ7RZ6CC.js} +12 -3
- package/dist/{chunk-VMDKWUMT.js → chunk-XNGG5AGI.js} +74 -17
- package/dist/components/primitives/ResolvedMedia.d.ts +3 -1
- package/dist/components/primitives/ResolvedMedia.d.ts.map +1 -1
- package/dist/components/primitives/defineHeadingSection.d.ts +2 -0
- package/dist/components/primitives/defineHeadingSection.d.ts.map +1 -1
- package/dist/components/primitives/index.js +3 -3
- package/dist/components/sections/Button/index.d.ts.map +1 -1
- package/dist/components/sections/Colors/index.d.ts.map +1 -1
- package/dist/components/sections/Colors/schema.d.ts.map +1 -1
- package/dist/components/sections/Container/index.d.ts.map +1 -1
- package/dist/components/sections/DoDontMedia/index.d.ts.map +1 -1
- package/dist/components/sections/IconList/IconList.d.ts +2 -1
- package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
- package/dist/components/sections/IconList/IconListSettings.d.ts +2 -1
- package/dist/components/sections/IconList/IconListSettings.d.ts.map +1 -1
- package/dist/components/sections/IconList/index.d.ts +1 -0
- package/dist/components/sections/IconList/index.d.ts.map +1 -1
- package/dist/components/sections/LinkHeading/index.d.ts.map +1 -1
- package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -1
- package/dist/components/sections/Media/index.d.ts.map +1 -1
- package/dist/components/sections/Prose/index.d.ts.map +1 -1
- package/dist/components/sections/Spacer/index.d.ts.map +1 -1
- package/dist/components/sections/SubHeading/index.d.ts.map +1 -1
- package/dist/components/sections/SubSubHeading/index.d.ts.map +1 -1
- package/dist/components/sections/all-sections.d.ts +1 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.d.ts +1 -0
- package/dist/components/sections/brand-guide.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +3 -3
- package/dist/components/sections/register-schemas.js +12 -942
- package/dist/components/sections/register.js +36 -0
- package/dist/components/shell/EditorLoading.d.ts +13 -0
- package/dist/components/shell/EditorLoading.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
- package/dist/hooks/useResolvedMedia.d.ts +2 -0
- package/dist/hooks/useResolvedMedia.d.ts.map +1 -1
- package/dist/index.js +21 -21
- package/dist/lib/index.js +2 -2
- package/dist/lib/insert-menu.d.ts +19 -0
- package/dist/lib/insert-menu.d.ts.map +1 -0
- package/dist/lib/registry.d.ts +23 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/media/index.js +1 -1
- package/dist/media/resolve.d.ts.map +1 -1
- package/dist/types/database.d.ts +248 -34
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/primitives/ResolvedMedia.tsx +51 -7
- package/src/components/primitives/defineHeadingSection.tsx +18 -2
- package/src/components/sections/Button/index.tsx +10 -3
- package/src/components/sections/Colors/index.tsx +15 -7
- package/src/components/sections/Colors/schema.ts +5 -2
- package/src/components/sections/Container/index.tsx +19 -3
- package/src/components/sections/DoDontMedia/index.tsx +18 -7
- package/src/components/sections/IconList/IconList.tsx +43 -19
- package/src/components/sections/IconList/IconListSettings.tsx +58 -11
- package/src/components/sections/IconList/index.tsx +51 -7
- package/src/components/sections/LinkHeading/index.tsx +2 -0
- package/src/components/sections/Media/MediaBlock.tsx +7 -0
- package/src/components/sections/Media/index.tsx +15 -6
- package/src/components/sections/Prose/index.tsx +9 -1
- package/src/components/sections/Spacer/index.tsx +2 -0
- package/src/components/sections/SubHeading/index.tsx +2 -0
- package/src/components/sections/SubSubHeading/index.tsx +2 -0
- package/src/components/shared/PasswordInput.tsx +3 -3
- package/src/components/shell/EditorLoading.tsx +37 -0
- package/src/components/shell/EditorShell.tsx +15 -20
- package/src/components/shell/SiteSettingsDisplay.tsx +28 -2
- package/src/hooks/useResolvedMedia.ts +9 -3
- package/src/lib/insert-menu.ts +42 -0
- package/src/lib/registry.ts +24 -0
- package/src/media/resolve.ts +4 -0
- package/src/types/database.ts +236 -36
- package/dist/{chunk-ZYXLNJ5M.js → chunk-AFK45HX2.js} +3 -3
|
@@ -1,45 +1,89 @@
|
|
|
1
1
|
import { useState } from "react";
|
|
2
|
+
import { Check, X } from "lucide-react";
|
|
2
3
|
import { Checkbox } from "../../shared/Checkbox";
|
|
3
4
|
import { FormLabel } from "../../shared/FormLabel";
|
|
4
5
|
import { IconPicker } from "../../primitives/IconPicker";
|
|
5
6
|
import { getIcon } from "../../../lib/icons";
|
|
7
|
+
import { cn } from "../../../lib/cn";
|
|
6
8
|
|
|
7
9
|
export function IconListSettings({
|
|
8
10
|
icon: initialIcon,
|
|
9
11
|
showLabel: initialShowLabel,
|
|
10
12
|
stackText: initialStackText,
|
|
13
|
+
dodont: initialDodont,
|
|
11
14
|
onChange,
|
|
12
15
|
}: {
|
|
13
16
|
icon: string | null;
|
|
14
17
|
showLabel: boolean;
|
|
15
18
|
stackText: boolean;
|
|
19
|
+
dodont?: "do" | "dont";
|
|
16
20
|
onChange: (result: { content: Record<string, unknown>; options: Record<string, unknown> }) => void;
|
|
17
21
|
}) {
|
|
18
22
|
const [icon, setIcon] = useState(initialIcon);
|
|
19
23
|
const [showLabel, setShowLabel] = useState(initialShowLabel);
|
|
20
24
|
const [stackText, setStackText] = useState(initialStackText);
|
|
25
|
+
const [dodont, setDodont] = useState<"do" | "dont" | undefined>(initialDodont);
|
|
21
26
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
22
27
|
|
|
23
|
-
const emit = (
|
|
24
|
-
|
|
28
|
+
const emit = (
|
|
29
|
+
overrides: Partial<{ icon: string | null; showLabel: boolean; stackText: boolean; dodont: "do" | "dont" | undefined }>,
|
|
30
|
+
) => onChange({ content: {}, options: { icon, showLabel, stackText, dodont, ...overrides } });
|
|
25
31
|
|
|
26
|
-
const showIcons = icon !== null;
|
|
27
32
|
const iconEntry = icon ? getIcon(icon) : null;
|
|
33
|
+
const dodontActive = dodont === "do" || dodont === "dont";
|
|
34
|
+
|
|
35
|
+
const tile = (value: "do" | "dont" | undefined, label: string, aria: string, glyph: React.ReactNode, tint: string) => (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
aria-label={aria}
|
|
39
|
+
aria-pressed={dodont === value}
|
|
40
|
+
className={cn(
|
|
41
|
+
"cursor-pointer flex flex-col items-center gap-1 rounded border py-2 transition-colors",
|
|
42
|
+
tint,
|
|
43
|
+
dodont === value ? "border-primary bg-base-accent" : "border-base-200",
|
|
44
|
+
)}
|
|
45
|
+
onClick={() => { setDodont(value); emit({ dodont: value }); }}
|
|
46
|
+
>
|
|
47
|
+
{glyph}
|
|
48
|
+
<span className="text-xs font-medium">{label}</span>
|
|
49
|
+
</button>
|
|
50
|
+
);
|
|
28
51
|
|
|
29
52
|
return (
|
|
30
53
|
<div className="space-y-4">
|
|
54
|
+
<div>
|
|
55
|
+
<FormLabel>Do / Don't list</FormLabel>
|
|
56
|
+
<div className="grid grid-cols-3 gap-2">
|
|
57
|
+
{tile(
|
|
58
|
+
undefined,
|
|
59
|
+
"None",
|
|
60
|
+
"No do/don't",
|
|
61
|
+
<span className="flex h-5 w-5 items-center justify-center rounded-sm border border-dashed border-base-contrast-light/40" />,
|
|
62
|
+
"text-base-contrast-light",
|
|
63
|
+
)}
|
|
64
|
+
{tile("do", "Do", "Do list", <Check size={20} />, "text-green-600")}
|
|
65
|
+
{tile("dont", "Don't", "Don't list", <X size={20} />, "text-red-600")}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
31
69
|
<div className="relative">
|
|
32
70
|
<FormLabel htmlFor="icon-picker-btn">Default Icon</FormLabel>
|
|
33
71
|
<div className="flex items-center gap-2">
|
|
34
72
|
<button
|
|
35
73
|
id="icon-picker-btn"
|
|
36
|
-
|
|
37
|
-
|
|
74
|
+
disabled={dodontActive}
|
|
75
|
+
className={cn(
|
|
76
|
+
"flex items-center gap-2 rounded border border-base-200 px-3 py-1.5 text-sm text-base-contrast-light",
|
|
77
|
+
dodontActive
|
|
78
|
+
? "cursor-not-allowed opacity-50"
|
|
79
|
+
: "cursor-pointer hover:border-primary hover:text-primary",
|
|
80
|
+
)}
|
|
81
|
+
onClick={() => { if (!dodontActive) setPickerOpen((prev) => !prev); }}
|
|
38
82
|
>
|
|
39
83
|
{iconEntry && <iconEntry.icon size={16} />}
|
|
40
84
|
<span>{iconEntry?.label ?? "None"}</span>
|
|
41
85
|
</button>
|
|
42
|
-
{icon && (
|
|
86
|
+
{icon && !dodontActive && (
|
|
43
87
|
<button
|
|
44
88
|
className="cursor-pointer text-xs text-base-contrast-light hover:text-base-contrast"
|
|
45
89
|
onClick={() => { setIcon(null); emit({ icon: null }); }}
|
|
@@ -48,15 +92,17 @@ export function IconListSettings({
|
|
|
48
92
|
</button>
|
|
49
93
|
)}
|
|
50
94
|
</div>
|
|
51
|
-
{
|
|
95
|
+
{dodontActive && (
|
|
96
|
+
<p className="mt-1 text-xs text-base-contrast-light/70">
|
|
97
|
+
Whole-list Do/Don't overrides the default icon.
|
|
98
|
+
</p>
|
|
99
|
+
)}
|
|
100
|
+
{pickerOpen && !dodontActive && (
|
|
52
101
|
<div className="absolute top-full left-0 z-50 mt-1">
|
|
53
102
|
<IconPicker
|
|
54
103
|
selected={icon}
|
|
55
104
|
onSelect={(newIcon) => {
|
|
56
|
-
if (newIcon !== null) {
|
|
57
|
-
setIcon(newIcon);
|
|
58
|
-
emit({ icon: newIcon });
|
|
59
|
-
}
|
|
105
|
+
if (newIcon !== null) { setIcon(newIcon); emit({ icon: newIcon }); }
|
|
60
106
|
setPickerOpen(false);
|
|
61
107
|
}}
|
|
62
108
|
onClose={() => setPickerOpen(false)}
|
|
@@ -64,6 +110,7 @@ export function IconListSettings({
|
|
|
64
110
|
</div>
|
|
65
111
|
)}
|
|
66
112
|
</div>
|
|
113
|
+
|
|
67
114
|
<Checkbox
|
|
68
115
|
checked={showLabel}
|
|
69
116
|
onChange={(v) => { setShowLabel(v); emit({ showLabel: v }); }}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { defineSection } from "../../../lib/registry";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { List } from "lucide-react";
|
|
3
|
+
import { List, Columns2 } from "lucide-react";
|
|
4
|
+
import type { SectionPreset } from "../../../lib/registry";
|
|
5
|
+
import type { SectionContent } from "../../../schemas/sections";
|
|
4
6
|
import IconList from "./IconList";
|
|
5
7
|
import { IconListSettings } from "./IconListSettings";
|
|
6
8
|
import { pluralize } from "../../../lib/text";
|
|
@@ -16,27 +18,68 @@ export type IconListItem = z.infer<typeof IconListItemSchema>;
|
|
|
16
18
|
const schema = z.object({
|
|
17
19
|
type: z.literal("icon_list"),
|
|
18
20
|
content: z.object({
|
|
19
|
-
items: z
|
|
21
|
+
items: z
|
|
22
|
+
.array(IconListItemSchema)
|
|
23
|
+
.describe(
|
|
24
|
+
"The list entries. Each item has a label, descriptive text, an optional icon, and an optional do/don't marker.",
|
|
25
|
+
),
|
|
20
26
|
}),
|
|
21
|
-
options: z
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
27
|
+
options: z
|
|
28
|
+
.object({
|
|
29
|
+
icon: z
|
|
30
|
+
.string()
|
|
31
|
+
.nullable()
|
|
32
|
+
.describe("Default icon name applied to items that don't specify their own; null for no icon.")
|
|
33
|
+
.optional(),
|
|
34
|
+
showLabel: z.boolean().describe("When true, render each item's label.").optional(),
|
|
35
|
+
stackText: z
|
|
36
|
+
.boolean()
|
|
37
|
+
.describe("When true, stack the label and text vertically instead of inline.")
|
|
38
|
+
.optional(),
|
|
39
|
+
dodont: z
|
|
40
|
+
.enum(["do", "dont"])
|
|
41
|
+
.describe("Whole-list do/don't mode: render every item with a green check (do) or red x (don't).")
|
|
42
|
+
.optional(),
|
|
43
|
+
})
|
|
44
|
+
.optional(),
|
|
26
45
|
});
|
|
27
46
|
|
|
47
|
+
// One-click "Do / Don't list": a 2-column container holding a green-check "Do"
|
|
48
|
+
// icon_list and a red-x "Don't" icon_list, using the whole-list options.dodont
|
|
49
|
+
// default. Same shape the legacy do_dont migration produces. Lives here (brand-guide
|
|
50
|
+
// layer); it reaches the core Add-menu only via SectionDefinition.presets.
|
|
51
|
+
const doDontListPreset: SectionPreset = {
|
|
52
|
+
id: "do_dont_list",
|
|
53
|
+
label: "Do / Don't list",
|
|
54
|
+
icon: <Columns2 size={18} />,
|
|
55
|
+
build: (newId): SectionContent => ({
|
|
56
|
+
type: "container",
|
|
57
|
+
content: {
|
|
58
|
+
columns: 2,
|
|
59
|
+
flow: "row",
|
|
60
|
+
children: [
|
|
61
|
+
{ id: newId(), type: "icon_list", content: { items: [{ label: "", text: "" }] }, options: { dodont: "do" } },
|
|
62
|
+
{ id: newId(), type: "icon_list", content: { items: [{ label: "", text: "" }] }, options: { dodont: "dont" } },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
|
|
28
68
|
export default defineSection({
|
|
29
69
|
type: "icon_list",
|
|
30
70
|
category: "brand-guide",
|
|
31
71
|
label: "Icon List",
|
|
32
72
|
icon: <List size={18} />,
|
|
33
73
|
schema,
|
|
74
|
+
authoringNote:
|
|
75
|
+
"A list of labeled items, optionally with per-item icons and do/don't markers. Use for feature lists, guidelines, or do/don't guidance.",
|
|
34
76
|
component: ({ content, options, onChange }) => (
|
|
35
77
|
<IconList
|
|
36
78
|
items={content.content.items}
|
|
37
79
|
icon={options?.icon as string | null}
|
|
38
80
|
showLabel={options?.showLabel as boolean}
|
|
39
81
|
stackText={options?.stackText as boolean}
|
|
82
|
+
dodont={options?.dodont as "do" | "dont" | undefined}
|
|
40
83
|
onChange={onChange ? (c) => onChange(c as typeof content) : undefined}
|
|
41
84
|
/>
|
|
42
85
|
),
|
|
@@ -46,4 +89,5 @@ export default defineSection({
|
|
|
46
89
|
}),
|
|
47
90
|
getLabel: (content) => pluralize(content.content.items.length, "icon"),
|
|
48
91
|
settingsForm: IconListSettings,
|
|
92
|
+
presets: [doDontListPreset],
|
|
49
93
|
});
|
|
@@ -9,4 +9,6 @@ export default defineHeadingSection({
|
|
|
9
9
|
tag: "h2",
|
|
10
10
|
placeholder: "Section heading",
|
|
11
11
|
defaultHeading: "New Section",
|
|
12
|
+
authoringNote:
|
|
13
|
+
"A top-level section heading (renders an <h2>, reported to navigation as h1). Use it to begin a major named section of the guide; its text becomes a navigation entry.",
|
|
12
14
|
});
|
|
@@ -33,12 +33,18 @@ export function MediaBlock({ refValue, options, link, isEditMode, onRefChange }:
|
|
|
33
33
|
// placeholder. In edit mode these are absent and ResolvedMedia falls back to the
|
|
34
34
|
// live manifest via context. (Mirrors a3e8913, which did this for MediaGrid/SplitContent.)
|
|
35
35
|
const baked = refValue as Record<string, unknown>;
|
|
36
|
+
const bakedWidth = typeof baked.width === "number" ? baked.width : undefined;
|
|
37
|
+
const bakedHeight = typeof baked.height === "number" ? baked.height : undefined;
|
|
38
|
+
const aspectRatio =
|
|
39
|
+
bakedWidth && bakedHeight && !options.square ? `${bakedWidth} / ${bakedHeight}` : undefined;
|
|
36
40
|
const resolved = (
|
|
37
41
|
<ResolvedMedia
|
|
38
42
|
imageId={refValue.imageId || undefined}
|
|
39
43
|
src={baked.src as string | undefined}
|
|
40
44
|
srcset={baked.srcset as string | undefined}
|
|
41
45
|
alt={baked.alt as string | undefined}
|
|
46
|
+
width={bakedWidth}
|
|
47
|
+
height={bakedHeight}
|
|
42
48
|
className="h-full w-full"
|
|
43
49
|
imgClassName={fitClass}
|
|
44
50
|
invertFrom={refValue.invertFrom}
|
|
@@ -75,6 +81,7 @@ export function MediaBlock({ refValue, options, link, isEditMode, onRefChange }:
|
|
|
75
81
|
options.square && "aspect-square",
|
|
76
82
|
onImageClick && "cursor-pointer",
|
|
77
83
|
)}
|
|
84
|
+
style={aspectRatio ? { aspectRatio } : undefined}
|
|
78
85
|
onClick={onImageClick}
|
|
79
86
|
onPointerDown={onImageClick ? (e) => e.stopPropagation() : undefined}
|
|
80
87
|
>
|
|
@@ -8,15 +8,22 @@ import { MediaBlock } from "./MediaBlock";
|
|
|
8
8
|
const schema = z.object({
|
|
9
9
|
type: z.literal("media"),
|
|
10
10
|
content: z.object({
|
|
11
|
-
ref: SingleMediaReferenceSchema
|
|
12
|
-
|
|
11
|
+
ref: SingleMediaReferenceSchema.describe(
|
|
12
|
+
"The image or video asset to display (a media reference by id). Required.",
|
|
13
|
+
),
|
|
14
|
+
link: LinkValueSchema.describe(
|
|
15
|
+
"Optional link wrapping the media — an internal page or a safe external URL.",
|
|
16
|
+
).optional(),
|
|
13
17
|
}),
|
|
14
18
|
options: z
|
|
15
19
|
.object({
|
|
16
|
-
square: z.boolean().optional(),
|
|
17
|
-
showCaption: z.boolean().optional(),
|
|
18
|
-
border: z.boolean().optional(),
|
|
19
|
-
objectFit: z
|
|
20
|
+
square: z.boolean().describe("When true, crop the media to a 1:1 square aspect ratio.").optional(),
|
|
21
|
+
showCaption: z.boolean().describe("When true, render the asset's caption beneath it.").optional(),
|
|
22
|
+
border: z.boolean().describe("When true, draw a border around the media.").optional(),
|
|
23
|
+
objectFit: z
|
|
24
|
+
.enum(["cover", "contain"])
|
|
25
|
+
.describe("How the image fills its box: 'contain' (fit whole image) or 'cover' (fill and crop).")
|
|
26
|
+
.optional(),
|
|
20
27
|
})
|
|
21
28
|
.default({}),
|
|
22
29
|
});
|
|
@@ -26,6 +33,8 @@ export default defineSection({
|
|
|
26
33
|
label: "Media",
|
|
27
34
|
icon: <ImageIcon size={18} />,
|
|
28
35
|
schema,
|
|
36
|
+
authoringNote:
|
|
37
|
+
"A single image or video with optional caption and link. Use to show one asset; for several assets side by side, place multiple Media blocks inside a Container.",
|
|
29
38
|
component: ({ content, options, onChange }) => (
|
|
30
39
|
<MediaBlock
|
|
31
40
|
refValue={content.content.ref}
|
|
@@ -6,7 +6,13 @@ import { stripHtmlToPlainText, truncate } from "../../../lib/text";
|
|
|
6
6
|
|
|
7
7
|
const schema = z.object({
|
|
8
8
|
type: z.literal("prose"),
|
|
9
|
-
content: z.object({
|
|
9
|
+
content: z.object({
|
|
10
|
+
body: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe(
|
|
13
|
+
"Rich-text HTML for the paragraph block. Author with the editor; stored as sanitized HTML.",
|
|
14
|
+
),
|
|
15
|
+
}),
|
|
10
16
|
});
|
|
11
17
|
|
|
12
18
|
export default defineSection({
|
|
@@ -14,6 +20,8 @@ export default defineSection({
|
|
|
14
20
|
label: "Prose",
|
|
15
21
|
icon: <AlignLeft size={18} />,
|
|
16
22
|
schema,
|
|
23
|
+
authoringNote:
|
|
24
|
+
"A block of rich-text body copy (paragraphs, lists, inline emphasis, links). Use for any explanatory prose; author with the editor, not raw HTML.",
|
|
17
25
|
richTextFields: ["body"],
|
|
18
26
|
component: ({ content, onChange }) => (
|
|
19
27
|
<Prose body={content.content.body} onChange={onChange ? (c) => onChange(c as typeof content) : undefined} />
|
|
@@ -13,6 +13,8 @@ export default defineSection({
|
|
|
13
13
|
label: "Spacer",
|
|
14
14
|
icon: <SquareDashed size={18} />,
|
|
15
15
|
schema,
|
|
16
|
+
authoringNote:
|
|
17
|
+
"Vertical whitespace between sections. Use sparingly to add breathing room where default section spacing is insufficient.",
|
|
16
18
|
component: () => <Spacer />,
|
|
17
19
|
defaults: () => ({ type: "spacer" as const, content: {} }),
|
|
18
20
|
});
|
|
@@ -10,4 +10,6 @@ export default defineHeadingSection({
|
|
|
10
10
|
placeholder: "Sub heading",
|
|
11
11
|
defaultHeading: "New Sub Heading",
|
|
12
12
|
navExclusion: true,
|
|
13
|
+
authoringNote:
|
|
14
|
+
"A second-level heading (renders <h3>, nav level h2). Use under a Link Heading to divide a section; toggle Exclude from navigation to keep it out of the nav tree.",
|
|
13
15
|
});
|
|
@@ -10,4 +10,6 @@ export default defineHeadingSection({
|
|
|
10
10
|
placeholder: "Sub sub heading",
|
|
11
11
|
defaultHeading: "New Sub Sub Heading",
|
|
12
12
|
navExclusion: true,
|
|
13
|
+
authoringNote:
|
|
14
|
+
"A third-level heading (renders <h4>, nav level h3). Use for the finest-grained labeled subsection; toggle Exclude from navigation to omit it from the nav tree.",
|
|
13
15
|
});
|
|
@@ -43,15 +43,15 @@ export function PasswordInput({
|
|
|
43
43
|
className={cn(
|
|
44
44
|
"w-full rounded border border-base-200 bg-base px-3 py-2 text-sm text-base-contrast",
|
|
45
45
|
"focus:border-base-contrast focus:outline-none focus:ring-1 focus:ring-base-contrast",
|
|
46
|
-
showToggle && "pr-
|
|
46
|
+
showToggle && "pr-10",
|
|
47
47
|
disabled && "cursor-not-allowed opacity-50",
|
|
48
48
|
)}
|
|
49
49
|
/>
|
|
50
50
|
{showToggle && (
|
|
51
51
|
<IconButton
|
|
52
|
-
icon={revealed ? <EyeOff size={
|
|
52
|
+
icon={revealed ? <EyeOff size={20} /> : <Eye size={20} />}
|
|
53
53
|
label={revealed ? "Hide password" : "Show password"}
|
|
54
|
-
size="
|
|
54
|
+
size="md"
|
|
55
55
|
aria-pressed={revealed}
|
|
56
56
|
disabled={disabled}
|
|
57
57
|
onClick={() => setRevealed((v) => !v)}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { Loader2 } from "lucide-react";
|
|
3
|
+
|
|
4
|
+
interface EditorLoadingProps {
|
|
5
|
+
/** Delay before the spinner appears, to avoid a flash on instant cache hits. */
|
|
6
|
+
delayMs?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Full-screen loading state shown while EditorShell hydrates and loads content.
|
|
11
|
+
* Keeps the existing aria-busy contract on the wrapper at all times; the visible
|
|
12
|
+
* spinner is delayed so a fast Dexie cache hit doesn't flash it. SSR-safe: the
|
|
13
|
+
* initial render shows the wrapper with no spinner, matching the server output.
|
|
14
|
+
*/
|
|
15
|
+
export function EditorLoading({ delayMs = 200 }: EditorLoadingProps) {
|
|
16
|
+
const [showSpinner, setShowSpinner] = useState(false);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const timer = setTimeout(() => setShowSpinner(true), delayMs);
|
|
20
|
+
return () => clearTimeout(timer);
|
|
21
|
+
}, [delayMs]);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className="flex min-h-screen items-center justify-center"
|
|
26
|
+
aria-busy="true"
|
|
27
|
+
aria-label="Loading content"
|
|
28
|
+
>
|
|
29
|
+
{showSpinner && (
|
|
30
|
+
<Loader2
|
|
31
|
+
className="size-8 animate-spin text-primary"
|
|
32
|
+
data-testid="editor-loading-spinner"
|
|
33
|
+
/>
|
|
34
|
+
)}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -27,6 +27,7 @@ import { SectionTypePicker } from "./SectionTypePicker";
|
|
|
27
27
|
import { ensureSectionsRegistered } from "../sections/register";
|
|
28
28
|
import { getSection, getAllSections } from "../../lib/registry";
|
|
29
29
|
import { childInsertableTypes, insertChildAt } from "../../lib/container-ops";
|
|
30
|
+
import { buildInsertMenuOptions, resolveInsertedContent } from "../../lib/insert-menu";
|
|
30
31
|
|
|
31
32
|
ensureSectionsRegistered();
|
|
32
33
|
import { BugReportFAB } from "./BugReportFAB";
|
|
@@ -75,6 +76,7 @@ import { ImageIcon, ListOrderedIcon, FilesIcon, X } from "lucide-react";
|
|
|
75
76
|
import { ErrorBoundary } from "../shared/ErrorBoundary";
|
|
76
77
|
import { HistoryToolbar } from "./HistoryToolbar";
|
|
77
78
|
import { RestoreModal } from "./RestoreModal";
|
|
79
|
+
import { EditorLoading } from "./EditorLoading";
|
|
78
80
|
import ViewRenderer from "../sections/ViewRenderer";
|
|
79
81
|
|
|
80
82
|
export { useMediaLibrary } from "./MediaLibraryContext";
|
|
@@ -590,13 +592,16 @@ export default function EditorShell({
|
|
|
590
592
|
);
|
|
591
593
|
|
|
592
594
|
const onAddSection = useCallback(
|
|
593
|
-
(insertIndex: number,
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const
|
|
595
|
+
(insertIndex: number, selection: string) => {
|
|
596
|
+
if (!resolvedActivePage) return;
|
|
597
|
+
const newId = () =>
|
|
598
|
+
typeof crypto !== "undefined" && typeof crypto.randomUUID === "function"
|
|
599
|
+
? crypto.randomUUID()
|
|
600
|
+
: `_id_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
|
601
|
+
const content = resolveInsertedContent(selection, getAllSections(), newId);
|
|
602
|
+
if (!content) return;
|
|
603
|
+
const id = newId();
|
|
604
|
+
const meta = { type: content.type, status: "draft" as const, access: [] as string[] };
|
|
600
605
|
const newSection: LoadedSection = { section: { id, ...content } as LoadedSection["section"], meta };
|
|
601
606
|
|
|
602
607
|
setSections((prev) => [...prev, newSection]);
|
|
@@ -808,7 +813,7 @@ export default function EditorShell({
|
|
|
808
813
|
// --- Render ---
|
|
809
814
|
|
|
810
815
|
if (shellState.phase === "loading-content") {
|
|
811
|
-
return <
|
|
816
|
+
return <EditorLoading />;
|
|
812
817
|
}
|
|
813
818
|
|
|
814
819
|
if (shellState.phase === "error") {
|
|
@@ -1188,7 +1193,7 @@ function EditorContent({
|
|
|
1188
1193
|
dirtySectionIds: Set<string>;
|
|
1189
1194
|
isPublishing: boolean;
|
|
1190
1195
|
onSectionChange: (sectionId: string, content: SectionContent) => void;
|
|
1191
|
-
onAddSection: (insertIndex: number,
|
|
1196
|
+
onAddSection: (insertIndex: number, selection: string) => void;
|
|
1192
1197
|
onDeleteSection: (sectionId: string) => void;
|
|
1193
1198
|
onReorderSections: (fromIndex: number, toIndex: number) => void;
|
|
1194
1199
|
onBlockMove: (move: BlockMove) => void;
|
|
@@ -1204,17 +1209,7 @@ function EditorContent({
|
|
|
1204
1209
|
const [pendingInsertIndex, setPendingInsertIndex] = useState<number | null>(null);
|
|
1205
1210
|
const dismissPendingInsert = useCallback(() => setPendingInsertIndex(null), []);
|
|
1206
1211
|
|
|
1207
|
-
const typeOptions = useMemo(
|
|
1208
|
-
() =>
|
|
1209
|
-
getAllSections()
|
|
1210
|
-
.filter((def) => def.type !== "spacer")
|
|
1211
|
-
.map((def) => ({
|
|
1212
|
-
type: def.type,
|
|
1213
|
-
label: def.label,
|
|
1214
|
-
icon: def.icon,
|
|
1215
|
-
})),
|
|
1216
|
-
[],
|
|
1217
|
-
);
|
|
1212
|
+
const typeOptions = useMemo(() => buildInsertMenuOptions(getAllSections()), []);
|
|
1218
1213
|
|
|
1219
1214
|
const editingEnabled = isEditMode && !isPublishing;
|
|
1220
1215
|
|
|
@@ -48,8 +48,15 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
function handleColorChange(color: string) {
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
update({ primaryColor: color });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function handleContrastChange(color: string) {
|
|
55
|
+
update({ primaryContrast: color });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resetContrastToAuto() {
|
|
59
|
+
update({ primaryContrast: deriveContrast(siteConfig.primaryColor) });
|
|
53
60
|
}
|
|
54
61
|
|
|
55
62
|
function handleFontChange(field: "headingFont" | "bodyFont", family: string) {
|
|
@@ -79,6 +86,25 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
|
|
|
79
86
|
</div>
|
|
80
87
|
</div>
|
|
81
88
|
|
|
89
|
+
<div>
|
|
90
|
+
<FormLabel>Accent (button & highlight text)</FormLabel>
|
|
91
|
+
<div className="flex items-center gap-3">
|
|
92
|
+
<ColorPicker
|
|
93
|
+
value={siteConfig.primaryContrast}
|
|
94
|
+
onChange={handleContrastChange}
|
|
95
|
+
label="Accent color"
|
|
96
|
+
/>
|
|
97
|
+
<span className="text-sm text-base-contrast-light">{siteConfig.primaryContrast}</span>
|
|
98
|
+
<Button type="button" variant="ghost" size="sm" onClick={resetContrastToAuto}>
|
|
99
|
+
Reset to auto
|
|
100
|
+
</Button>
|
|
101
|
+
</div>
|
|
102
|
+
<p className="mt-1 text-xs text-base-contrast/70">
|
|
103
|
+
Shown as text on primary-colored buttons and as the text-selection color. "Reset to auto" derives a
|
|
104
|
+
legible light or dark value from your primary brand color.
|
|
105
|
+
</p>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
82
108
|
<div>
|
|
83
109
|
<FormLabel>Favicon</FormLabel>
|
|
84
110
|
<div className="flex items-center gap-3">
|
|
@@ -8,6 +8,8 @@ export interface ResolvedMediaResult {
|
|
|
8
8
|
poster: string | undefined;
|
|
9
9
|
alt: string;
|
|
10
10
|
kind: "image" | "animated" | "video";
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export function useResolvedMedia(imageId: string | undefined): ResolvedMediaResult {
|
|
@@ -26,10 +28,12 @@ export function useResolvedMedia(imageId: string | undefined): ResolvedMediaResu
|
|
|
26
28
|
const item = manifestItem ?? pendingItem;
|
|
27
29
|
const alt = item?.alt ?? "";
|
|
28
30
|
const kind = item?.kind ?? "image";
|
|
31
|
+
const width = item?.width;
|
|
32
|
+
const height = item?.height;
|
|
29
33
|
|
|
30
34
|
const localUrl = pendingLocalUrls[imageId];
|
|
31
35
|
if (localUrl) {
|
|
32
|
-
return { src: localUrl, srcset: undefined, poster: undefined, alt, kind };
|
|
36
|
+
return { src: localUrl, srcset: undefined, poster: undefined, alt, kind, width, height };
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
if (manifestItem) {
|
|
@@ -42,13 +46,15 @@ export function useResolvedMedia(imageId: string | undefined): ResolvedMediaResu
|
|
|
42
46
|
poster: undefined,
|
|
43
47
|
alt,
|
|
44
48
|
kind,
|
|
49
|
+
width: resolved.width,
|
|
50
|
+
height: resolved.height,
|
|
45
51
|
};
|
|
46
52
|
}
|
|
47
53
|
if (resolved && resolved.tag === "video") {
|
|
48
|
-
return { src: resolved.src, srcset: undefined, poster: resolved.poster, alt, kind };
|
|
54
|
+
return { src: resolved.src, srcset: undefined, poster: resolved.poster, alt, kind, width: resolved.width, height: resolved.height };
|
|
49
55
|
}
|
|
50
56
|
}
|
|
51
57
|
|
|
52
|
-
return { src: undefined, srcset: undefined, poster: undefined, alt, kind };
|
|
58
|
+
return { src: undefined, srcset: undefined, poster: undefined, alt, kind, width, height };
|
|
53
59
|
}, [imageId, ctx]);
|
|
54
60
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { SectionDefinition } from "./registry";
|
|
3
|
+
import type { SectionContent } from "../schemas/sections";
|
|
4
|
+
|
|
5
|
+
/** Namespacing prefix for preset selections in the Add-section menu. */
|
|
6
|
+
export const PRESET_PREFIX = "__preset:";
|
|
7
|
+
|
|
8
|
+
/** One Add-section menu entry: a section `type`, or `__preset:<presetId>`. */
|
|
9
|
+
export interface InsertMenuOption {
|
|
10
|
+
type: string;
|
|
11
|
+
label: string;
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Every non-spacer section type, followed by presets contributed by registered
|
|
16
|
+
* sections. Presets appear only when their owning section is registered, so a
|
|
17
|
+
* core-only build (no brand-guide sections) shows no brand-guide presets. */
|
|
18
|
+
export function buildInsertMenuOptions(sections: SectionDefinition[]): InsertMenuOption[] {
|
|
19
|
+
const typeOptions = sections
|
|
20
|
+
.filter((def) => def.type !== "spacer")
|
|
21
|
+
.map((def) => ({ type: def.type, label: def.label, icon: def.icon }));
|
|
22
|
+
const presetOptions = sections
|
|
23
|
+
.flatMap((def) => def.presets ?? [])
|
|
24
|
+
.map((p) => ({ type: `${PRESET_PREFIX}${p.id}`, label: p.label, icon: p.icon }));
|
|
25
|
+
return [...typeOptions, ...presetOptions];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Resolve an Add-menu selection to ready-to-insert content. `newId` mints ids for
|
|
29
|
+
* any nested children a preset creates. Returns null for an unknown type/preset. */
|
|
30
|
+
export function resolveInsertedContent(
|
|
31
|
+
selection: string,
|
|
32
|
+
sections: SectionDefinition[],
|
|
33
|
+
newId: () => string,
|
|
34
|
+
): SectionContent | null {
|
|
35
|
+
if (selection.startsWith(PRESET_PREFIX)) {
|
|
36
|
+
const id = selection.slice(PRESET_PREFIX.length);
|
|
37
|
+
const preset = sections.flatMap((def) => def.presets ?? []).find((p) => p.id === id);
|
|
38
|
+
return preset ? preset.build(newId) : null;
|
|
39
|
+
}
|
|
40
|
+
const def = sections.find((d) => d.type === selection);
|
|
41
|
+
return def ? (def.defaults() as SectionContent) : null;
|
|
42
|
+
}
|
package/src/lib/registry.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { ZodType, z } from "zod";
|
|
|
2
2
|
import type { ComponentType, ReactNode } from "react";
|
|
3
3
|
import type { Audience } from "../schemas/auth";
|
|
4
4
|
import type { LinkValue } from "../schemas/link";
|
|
5
|
+
import type { SectionContent } from "../schemas/sections";
|
|
5
6
|
|
|
6
7
|
// --- Settings field types ---
|
|
7
8
|
|
|
@@ -106,6 +107,19 @@ export interface WrapperProps {
|
|
|
106
107
|
|
|
107
108
|
// --- Section definition ---
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* A ready-to-insert composite (e.g. a container of preconfigured child sections),
|
|
112
|
+
* surfaced in the Add-section menu. Contributed by the section definition that owns
|
|
113
|
+
* the composition, so it rides the section registry and disappears in a core-only
|
|
114
|
+
* build that never registers that section. `newId` mints unique ids for nested children.
|
|
115
|
+
*/
|
|
116
|
+
export interface SectionPreset {
|
|
117
|
+
id: string;
|
|
118
|
+
label: string;
|
|
119
|
+
icon?: ReactNode;
|
|
120
|
+
build(newId: () => string): SectionContent;
|
|
121
|
+
}
|
|
122
|
+
|
|
109
123
|
export interface SectionDefinition<T = unknown> {
|
|
110
124
|
type: string;
|
|
111
125
|
/** Grouping label. `"brand-guide"` marks opinionated sections separable from the
|
|
@@ -136,6 +150,14 @@ export interface SectionDefinition<T = unknown> {
|
|
|
136
150
|
* into a trailing 'Other' tab.
|
|
137
151
|
*/
|
|
138
152
|
settingsTabs?: { label: string; fields: string[] }[];
|
|
153
|
+
/** Composite blueprints for the Add-section menu (see SectionPreset). */
|
|
154
|
+
presets?: SectionPreset[];
|
|
155
|
+
/**
|
|
156
|
+
* Human/agent-facing prose describing when to use this section type and how to
|
|
157
|
+
* author it well. Surfaced by the get_section_schema tool as the JSON Schema
|
|
158
|
+
* top-level `description`. Presentation/metadata only — not validated.
|
|
159
|
+
*/
|
|
160
|
+
authoringNote?: string;
|
|
139
161
|
}
|
|
140
162
|
|
|
141
163
|
// --- defineSection ---
|
|
@@ -162,6 +184,8 @@ type DefineSectionInput<S extends ZodType> = {
|
|
|
162
184
|
* into a trailing 'Other' tab.
|
|
163
185
|
*/
|
|
164
186
|
settingsTabs?: { label: string; fields: string[] }[];
|
|
187
|
+
presets?: SectionPreset[];
|
|
188
|
+
authoringNote?: string;
|
|
165
189
|
};
|
|
166
190
|
|
|
167
191
|
export function defineSection<S extends ZodType>(
|