@drawnagency/primitives 0.1.56 → 0.1.57
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-KGYWQDBB.js → chunk-ICLXLWQ5.js} +9 -72
- package/dist/chunk-NSCT3AMV.js +32 -0
- package/dist/{chunk-EU6NZ4GS.js → chunk-PRKUXM7E.js} +23 -9
- package/dist/{chunk-7IAWF7LE.js → chunk-PYWS3MOJ.js} +12 -2
- package/dist/chunk-TG43X7JO.js +123 -0
- package/dist/chunk-VKAGMEKE.js +90 -0
- package/dist/components/editor/ChildBlockWrapper.d.ts +19 -0
- package/dist/components/editor/ChildBlockWrapper.d.ts.map +1 -0
- package/dist/components/editor/ColSpanControl.d.ts +9 -0
- package/dist/components/editor/ColSpanControl.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/SettingsForm.d.ts +5 -1
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/primitives/EditableGrid.d.ts.map +1 -1
- package/dist/components/primitives/IconPicker.d.ts +7 -1
- package/dist/components/primitives/IconPicker.d.ts.map +1 -1
- package/dist/components/sections/Container/Container.d.ts +20 -0
- package/dist/components/sections/Container/Container.d.ts.map +1 -0
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts +17 -0
- package/dist/components/sections/Container/ContainerSettingsForm.d.ts.map +1 -0
- package/dist/components/sections/Container/index.d.ts +11 -0
- package/dist/components/sections/Container/index.d.ts.map +1 -0
- package/dist/components/sections/IconList/IconList.d.ts +1 -0
- package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
- package/dist/components/sections/IconList/IconListSettings.d.ts +3 -4
- 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/Media/MediaBlock.d.ts +19 -0
- package/dist/components/sections/Media/MediaBlock.d.ts.map +1 -0
- package/dist/components/sections/{MediaGrid → Media}/index.d.ts +15 -25
- package/dist/components/sections/Media/index.d.ts.map +1 -0
- package/dist/components/sections/Prose/index.d.ts.map +1 -1
- package/dist/components/sections/Spacer/Spacer.d.ts +2 -0
- package/dist/components/sections/Spacer/Spacer.d.ts.map +1 -0
- package/dist/components/sections/Spacer/index.d.ts +6 -0
- package/dist/components/sections/Spacer/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +29 -103
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/register-schemas.d.ts.map +1 -1
- package/dist/components/shared/Tabs.d.ts +24 -0
- package/dist/components/shared/Tabs.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/SiteSettingsModal.d.ts.map +1 -1
- package/dist/components/shell/blockMoveDispatch.d.ts +21 -0
- package/dist/components/shell/blockMoveDispatch.d.ts.map +1 -0
- package/dist/hooks/useBlockDnd.d.ts +48 -0
- package/dist/hooks/useBlockDnd.d.ts.map +1 -0
- package/dist/index.js +56 -48
- package/dist/lib/block-dnd.d.ts +42 -0
- package/dist/lib/block-dnd.d.ts.map +1 -0
- package/dist/lib/block-move.d.ts +31 -0
- package/dist/lib/block-move.d.ts.map +1 -0
- package/dist/lib/container-grid.d.ts +29 -0
- package/dist/lib/container-grid.d.ts.map +1 -0
- package/dist/lib/container-ops.d.ts +44 -0
- package/dist/lib/container-ops.d.ts.map +1 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +13 -0
- package/dist/lib/index.js +10 -7
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/migrate-sections-transform.d.ts +12 -0
- package/dist/lib/migrate-sections-transform.d.ts.map +1 -0
- package/dist/lib/migrate-sections-transform.js +6 -0
- package/dist/lib/registry.d.ts +39 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/registry.js +26 -0
- package/dist/schemas/block.d.ts +20 -0
- package/dist/schemas/block.d.ts.map +1 -0
- package/dist/schemas/block.js +14 -0
- package/dist/schemas/index.js +8 -2
- package/dist/schemas/link.d.ts +7 -0
- package/dist/schemas/link.d.ts.map +1 -1
- package/dist/schemas/rich-text.d.ts +9 -0
- package/dist/schemas/rich-text.d.ts.map +1 -0
- package/dist/schemas/sections.d.ts +2 -0
- package/dist/schemas/sections.d.ts.map +1 -1
- package/dist/schemas/shared.d.ts +30 -0
- package/dist/schemas/shared.d.ts.map +1 -1
- package/package.json +13 -1
- package/src/components/brandguide/Colors.tsx +35 -33
- package/src/components/editor/ChildBlockWrapper.tsx +108 -0
- package/src/components/editor/ColSpanControl.tsx +56 -0
- package/src/components/editor/SectionWrapper.tsx +44 -20
- package/src/components/editor/SettingsForm.tsx +100 -73
- package/src/components/primitives/EditableGrid.tsx +40 -36
- package/src/components/primitives/IconPicker.tsx +116 -26
- package/src/components/sections/Container/Container.tsx +354 -0
- package/src/components/sections/Container/ContainerSettingsForm.tsx +113 -0
- package/src/components/sections/Container/index.tsx +51 -0
- package/src/components/sections/IconList/IconList.tsx +113 -43
- package/src/components/sections/IconList/IconListSettings.tsx +2 -2
- package/src/components/sections/IconList/index.tsx +1 -1
- package/src/components/sections/Media/MediaBlock.tsx +103 -0
- package/src/components/sections/Media/index.tsx +85 -0
- package/src/components/sections/Prose/index.tsx +1 -0
- package/src/components/sections/Spacer/Spacer.tsx +6 -0
- package/src/components/sections/Spacer/index.tsx +18 -0
- package/src/components/sections/all-sections.ts +10 -8
- package/src/components/sections/register-schemas.ts +5 -2
- package/src/components/shared/Tabs.tsx +63 -0
- package/src/components/shell/EditorShell.tsx +105 -13
- package/src/components/shell/SiteSettingsModal.tsx +41 -51
- package/src/components/shell/blockMoveDispatch.ts +40 -0
- package/src/hooks/useBlockDnd.ts +144 -0
- package/src/lib/block-dnd.ts +58 -0
- package/src/lib/block-move.ts +236 -0
- package/src/lib/container-grid.ts +58 -0
- package/src/lib/container-ops.ts +159 -0
- package/src/lib/dexie.ts +22 -0
- package/src/lib/loader.ts +16 -4
- package/src/lib/migrate-sections-transform.ts +147 -0
- package/src/lib/registry.ts +48 -0
- package/src/schemas/block.ts +40 -0
- package/src/schemas/link.ts +19 -1
- package/src/schemas/rich-text.ts +11 -0
- package/src/schemas/sections.ts +5 -1
- package/src/schemas/shared.ts +6 -0
- package/dist/components/brandguide/DoDontList.d.ts +0 -16
- package/dist/components/brandguide/DoDontList.d.ts.map +0 -1
- package/dist/components/brandguide/DoDontMediaGrid.d.ts +0 -16
- package/dist/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
- package/dist/components/primitives/MediaSettingsForms.d.ts +0 -23
- package/dist/components/primitives/MediaSettingsForms.d.ts.map +0 -1
- package/dist/components/sections/DoDontList/index.d.ts +0 -21
- package/dist/components/sections/DoDontList/index.d.ts.map +0 -1
- package/dist/components/sections/DoDontMediaGrid/index.d.ts +0 -55
- package/dist/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
- package/dist/components/sections/MediaGrid/MediaGrid.d.ts +0 -17
- package/dist/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
- package/dist/components/sections/MediaGrid/index.d.ts.map +0 -1
- package/dist/components/sections/SplitContent/SplitContent.d.ts +0 -14
- package/dist/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
- package/dist/components/sections/SplitContent/index.d.ts +0 -13
- package/dist/components/sections/SplitContent/index.d.ts.map +0 -1
- package/src/components/brandguide/DoDontList.d.ts.map +0 -1
- package/src/components/brandguide/DoDontList.tsx +0 -67
- package/src/components/brandguide/DoDontMediaGrid.d.ts.map +0 -1
- package/src/components/brandguide/DoDontMediaGrid.tsx +0 -19
- package/src/components/primitives/MediaSettingsForms.tsx +0 -128
- package/src/components/sections/DoDontList/index.d.ts.map +0 -1
- package/src/components/sections/DoDontList/index.tsx +0 -45
- package/src/components/sections/DoDontMediaGrid/index.d.ts.map +0 -1
- package/src/components/sections/DoDontMediaGrid/index.tsx +0 -63
- package/src/components/sections/MediaGrid/MediaGrid.d.ts.map +0 -1
- package/src/components/sections/MediaGrid/MediaGrid.tsx +0 -239
- package/src/components/sections/MediaGrid/index.d.ts.map +0 -1
- package/src/components/sections/MediaGrid/index.tsx +0 -57
- package/src/components/sections/SplitContent/SplitContent.d.ts.map +0 -1
- package/src/components/sections/SplitContent/SplitContent.tsx +0 -84
- package/src/components/sections/SplitContent/index.d.ts.map +0 -1
- package/src/components/sections/SplitContent/index.tsx +0 -55
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { getSection } from "../../../lib/registry";
|
|
3
|
+
import { SettingsForm } from "../../editor/SettingsForm";
|
|
4
|
+
import { MAX_CONTAINER_COLUMNS } from "../../../lib/container-grid";
|
|
5
|
+
import type { SettingsSchema } from "../../../lib/registry";
|
|
6
|
+
|
|
7
|
+
interface ChildLike {
|
|
8
|
+
type: string;
|
|
9
|
+
options?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ContainerSettingsFormProps {
|
|
13
|
+
columns?: number;
|
|
14
|
+
flow?: string;
|
|
15
|
+
childDefaults?: Record<string, unknown>;
|
|
16
|
+
children?: ChildLike[]; // the container's child blocks (DATA, not React nodes)
|
|
17
|
+
onChange: (result: { content: Record<string, unknown>; options: Record<string, unknown> }) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Union of inheritable option fields across the child types currently present. */
|
|
21
|
+
function inheritableSchema(children: ChildLike[]): SettingsSchema {
|
|
22
|
+
const out: SettingsSchema = {};
|
|
23
|
+
for (const child of children) {
|
|
24
|
+
const def = getSection(child.type);
|
|
25
|
+
if (!def?.settings || !def.inheritableSettings) continue;
|
|
26
|
+
for (const key of def.inheritableSettings) {
|
|
27
|
+
const field = def.settings[key];
|
|
28
|
+
if (field && !out[key]) out[key] = field;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ContainerSettingsForm({
|
|
35
|
+
columns = 1,
|
|
36
|
+
flow = "row",
|
|
37
|
+
childDefaults = {},
|
|
38
|
+
children = [],
|
|
39
|
+
onChange,
|
|
40
|
+
}: ContainerSettingsFormProps) {
|
|
41
|
+
const [layout, setLayout] = useState({ columns, flow });
|
|
42
|
+
const [defaults, setDefaults] = useState<Record<string, unknown>>(childDefaults);
|
|
43
|
+
|
|
44
|
+
const childSchema = inheritableSchema(children);
|
|
45
|
+
const hasChildDefaults = Object.keys(childSchema).length > 0;
|
|
46
|
+
|
|
47
|
+
function emit(
|
|
48
|
+
nextLayout: { columns: number; flow: string },
|
|
49
|
+
nextDefaults: Record<string, unknown>,
|
|
50
|
+
) {
|
|
51
|
+
onChange({ content: { ...nextLayout, childDefaults: nextDefaults }, options: {} });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const layoutSchema: SettingsSchema = {
|
|
55
|
+
columns: {
|
|
56
|
+
type: "select",
|
|
57
|
+
label: "Columns",
|
|
58
|
+
default: String(layout.columns),
|
|
59
|
+
target: "content",
|
|
60
|
+
coerce: "number",
|
|
61
|
+
options: Array.from({ length: MAX_CONTAINER_COLUMNS }, (_, i) => ({
|
|
62
|
+
label: String(i + 1),
|
|
63
|
+
value: String(i + 1),
|
|
64
|
+
})),
|
|
65
|
+
},
|
|
66
|
+
flow: {
|
|
67
|
+
type: "select",
|
|
68
|
+
label: "Flow",
|
|
69
|
+
default: layout.flow,
|
|
70
|
+
target: "content",
|
|
71
|
+
options: [
|
|
72
|
+
{ label: "Rows (wrap across)", value: "row" },
|
|
73
|
+
{ label: "Columns (fill down)", value: "column" },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex flex-col gap-6">
|
|
80
|
+
<fieldset className="flex flex-col gap-4">
|
|
81
|
+
<legend className="mb-2 text-sm font-semibold text-base-contrast">Layout</legend>
|
|
82
|
+
<SettingsForm
|
|
83
|
+
schema={layoutSchema}
|
|
84
|
+
values={{ columns: String(layout.columns), flow: layout.flow }}
|
|
85
|
+
onChange={(result) => {
|
|
86
|
+
const next = {
|
|
87
|
+
columns: Number(result.content.columns ?? layout.columns),
|
|
88
|
+
flow: String(result.content.flow ?? layout.flow),
|
|
89
|
+
};
|
|
90
|
+
setLayout(next);
|
|
91
|
+
emit(next, defaults);
|
|
92
|
+
}}
|
|
93
|
+
/>
|
|
94
|
+
</fieldset>
|
|
95
|
+
|
|
96
|
+
{hasChildDefaults && (
|
|
97
|
+
<fieldset className="flex flex-col gap-4 border-t border-base-200 pt-4">
|
|
98
|
+
<legend className="mb-2 text-sm font-semibold text-base-contrast">Apply to all items</legend>
|
|
99
|
+
<SettingsForm
|
|
100
|
+
schema={childSchema}
|
|
101
|
+
values={defaults}
|
|
102
|
+
onChange={(result) => {
|
|
103
|
+
// inheritable fields are options-typed in their source defs → result.options
|
|
104
|
+
const next = { ...defaults, ...result.options };
|
|
105
|
+
setDefaults(next);
|
|
106
|
+
emit(layout, next);
|
|
107
|
+
}}
|
|
108
|
+
/>
|
|
109
|
+
</fieldset>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defineSection } from "../../../lib/registry";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { LayoutTemplate } from "lucide-react";
|
|
4
|
+
import { getSectionSchema } from "../../../schemas/sections";
|
|
5
|
+
import { MAX_CONTAINER_COLUMNS } from "../../../lib/container-grid";
|
|
6
|
+
import { Container } from "./Container";
|
|
7
|
+
import { ContainerSettingsForm } from "./ContainerSettingsForm";
|
|
8
|
+
|
|
9
|
+
const schema = z.object({
|
|
10
|
+
type: z.literal("container"),
|
|
11
|
+
content: z
|
|
12
|
+
.object({
|
|
13
|
+
columns: z.number().int().min(1).max(MAX_CONTAINER_COLUMNS).default(1),
|
|
14
|
+
flow: z.enum(["row", "column"]).default("row"),
|
|
15
|
+
childDefaults: z.record(z.string(), z.unknown()).optional(),
|
|
16
|
+
// Recursive: children are full blocks. z.lazy defers evaluation to parse
|
|
17
|
+
// time, by which point every section schema (incl. container) is registered.
|
|
18
|
+
children: z.array(z.lazy(() => getSectionSchema())).default([]),
|
|
19
|
+
})
|
|
20
|
+
// Bound each child's colSpan to the available columns (spec §4.2). Non-rejecting
|
|
21
|
+
// and idempotent: a hand-edited/migrated file can't persist a phantom span, and
|
|
22
|
+
// a column-count reduction auto-clamps on the next save.
|
|
23
|
+
.transform((c) => ({
|
|
24
|
+
...c,
|
|
25
|
+
children: c.children.map((child) =>
|
|
26
|
+
(child as { layout?: { colSpan?: number } }).layout?.colSpan &&
|
|
27
|
+
(child as { layout?: { colSpan?: number } }).layout!.colSpan! > c.columns
|
|
28
|
+
? { ...child, layout: { ...(child as { layout?: { colSpan?: number } }).layout, colSpan: c.columns } }
|
|
29
|
+
: child,
|
|
30
|
+
),
|
|
31
|
+
})),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export default defineSection({
|
|
35
|
+
type: "container",
|
|
36
|
+
label: "Container",
|
|
37
|
+
icon: <LayoutTemplate size={18} />,
|
|
38
|
+
schema,
|
|
39
|
+
component: ({ content, onChange, isEditMode, openModal }) => (
|
|
40
|
+
<Container content={content as never} isEditMode={!!isEditMode} onChange={onChange as never} openModal={openModal} />
|
|
41
|
+
),
|
|
42
|
+
defaults: () => ({
|
|
43
|
+
type: "container" as const,
|
|
44
|
+
content: { columns: 1, flow: "row" as const, children: [] },
|
|
45
|
+
}),
|
|
46
|
+
getLabel: (content) => {
|
|
47
|
+
const n = content.content.children.length;
|
|
48
|
+
return `Container (${n} item${n === 1 ? "" : "s"})`;
|
|
49
|
+
},
|
|
50
|
+
settingsForm: ContainerSettingsForm,
|
|
51
|
+
});
|
|
@@ -5,6 +5,7 @@ import { getIcon } from "../../../lib/icons";
|
|
|
5
5
|
import { useEditableCollection } from "../../primitives/useEditableCollection";
|
|
6
6
|
import { EditablePlainText } from "../../primitives/EditablePlainText";
|
|
7
7
|
import { DragHandle, DeleteIcon, AddIcon } from "../../shared/icons";
|
|
8
|
+
import { CopyPlus, Pencil } from "lucide-react";
|
|
8
9
|
import { IconButton } from "../../shared/IconButton";
|
|
9
10
|
import { IconPicker } from "../../primitives/IconPicker";
|
|
10
11
|
|
|
@@ -12,6 +13,7 @@ interface IconListItem {
|
|
|
12
13
|
label: string;
|
|
13
14
|
text: string;
|
|
14
15
|
icon?: string;
|
|
16
|
+
dodont?: "do" | "dont";
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
interface Props {
|
|
@@ -84,10 +86,20 @@ export default function IconList({
|
|
|
84
86
|
// ---------------------------------------------------------------------------
|
|
85
87
|
|
|
86
88
|
function resolveIcon(item: IconListItem, defaultIcon: string | null | undefined) {
|
|
89
|
+
// dodont WINS over item.icon and the section default — forces the DoDontList glyph.
|
|
90
|
+
if (item.dodont === "do") return getIcon("check") ?? null;
|
|
91
|
+
if (item.dodont === "dont") return getIcon("x") ?? null;
|
|
87
92
|
const id = item.icon ?? defaultIcon;
|
|
88
93
|
return id ? getIcon(id) : null;
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
// Per-item icon color: green for "do", red for "dont", else the section-wide class.
|
|
97
|
+
function resolveIconColor(item: IconListItem, iconClassName: string) {
|
|
98
|
+
if (item.dodont === "do") return "text-green-600";
|
|
99
|
+
if (item.dodont === "dont") return "text-red-600";
|
|
100
|
+
return iconClassName;
|
|
101
|
+
}
|
|
102
|
+
|
|
91
103
|
// ---------------------------------------------------------------------------
|
|
92
104
|
// View mode
|
|
93
105
|
// ---------------------------------------------------------------------------
|
|
@@ -115,6 +127,7 @@ function ViewIconList({
|
|
|
115
127
|
<div className="grid gap-4 pb-4">
|
|
116
128
|
{items.map((item, i) => {
|
|
117
129
|
const iconEntry = resolveIcon(item, defaultIcon);
|
|
130
|
+
const iconColor = resolveIconColor(item, iconClassName);
|
|
118
131
|
return (
|
|
119
132
|
<div
|
|
120
133
|
key={i}
|
|
@@ -126,7 +139,7 @@ function ViewIconList({
|
|
|
126
139
|
{hasAnyIcon && (
|
|
127
140
|
<div
|
|
128
141
|
data-testid="icon-list-icon"
|
|
129
|
-
className={cn("flex items-start pt-0.5",
|
|
142
|
+
className={cn("flex items-start pt-0.5", iconColor)}
|
|
130
143
|
>
|
|
131
144
|
{iconEntry && <iconEntry.icon size={18} />}
|
|
132
145
|
</div>
|
|
@@ -191,7 +204,22 @@ function EditIconList({
|
|
|
191
204
|
[wrappedItems, onItemsChange],
|
|
192
205
|
);
|
|
193
206
|
|
|
194
|
-
const
|
|
207
|
+
const duplicateItem = useCallback(
|
|
208
|
+
(index: number) => {
|
|
209
|
+
const copy = { ...wrappedItems[index].data };
|
|
210
|
+
const next = [
|
|
211
|
+
...wrappedItems.slice(0, index + 1).map((w) => w.data),
|
|
212
|
+
copy,
|
|
213
|
+
...wrappedItems.slice(index + 1).map((w) => w.data),
|
|
214
|
+
];
|
|
215
|
+
onItemsChange(next);
|
|
216
|
+
},
|
|
217
|
+
[wrappedItems, onItemsChange],
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
// In edit mode the icon column always renders — even items with no icon need
|
|
221
|
+
// the always-present picker trigger (icon + Do/Don't tagging) in the icon cell.
|
|
222
|
+
const hasAnyIcon = true;
|
|
195
223
|
|
|
196
224
|
return (
|
|
197
225
|
<div className="group/list relative grid gap-4 pb-4">
|
|
@@ -213,6 +241,7 @@ function EditIconList({
|
|
|
213
241
|
onReorder={onReorder}
|
|
214
242
|
onRemove={onRemove}
|
|
215
243
|
onUpdateItem={(patch) => updateItem(index, patch)}
|
|
244
|
+
onDuplicate={() => duplicateItem(index)}
|
|
216
245
|
/>
|
|
217
246
|
))}
|
|
218
247
|
|
|
@@ -252,6 +281,7 @@ function EditableRow({
|
|
|
252
281
|
onReorder,
|
|
253
282
|
onRemove,
|
|
254
283
|
onUpdateItem,
|
|
284
|
+
onDuplicate,
|
|
255
285
|
}: {
|
|
256
286
|
id: string;
|
|
257
287
|
index: number;
|
|
@@ -268,14 +298,17 @@ function EditableRow({
|
|
|
268
298
|
onReorder: (from: number, to: number) => void;
|
|
269
299
|
onRemove: (id: string) => void;
|
|
270
300
|
onUpdateItem: (patch: Partial<IconListItem>) => void;
|
|
301
|
+
onDuplicate: () => void;
|
|
271
302
|
}) {
|
|
272
303
|
const rowRef = useRef<HTMLDivElement>(null);
|
|
273
304
|
const handleRef = useRef<HTMLButtonElement>(null);
|
|
274
305
|
const [iconHover, setIconHover] = useState(false);
|
|
275
306
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
307
|
+
const [editingSuppressed, setEditingSuppressed] = useState(false);
|
|
276
308
|
|
|
277
309
|
const iconEntry = resolveIcon(item, defaultIcon);
|
|
278
310
|
const effectiveIconId = item.icon ?? defaultIcon ?? null;
|
|
311
|
+
const iconColor = resolveIconColor(item, iconClassName);
|
|
279
312
|
|
|
280
313
|
useEffect(() => {
|
|
281
314
|
const row = rowRef.current;
|
|
@@ -324,6 +357,13 @@ function EditableRow({
|
|
|
324
357
|
|
|
325
358
|
const isDropTarget = dragState.targetId === id && dragState.sourceId !== id;
|
|
326
359
|
|
|
360
|
+
const onEditableActivity = (e: React.SyntheticEvent) => {
|
|
361
|
+
const t = e.target as HTMLElement;
|
|
362
|
+
if (t.matches?.("input, textarea, [contenteditable], [contenteditable='true']")) {
|
|
363
|
+
setEditingSuppressed(true);
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
|
|
327
367
|
return (
|
|
328
368
|
<div
|
|
329
369
|
ref={rowRef}
|
|
@@ -332,55 +372,60 @@ function EditableRow({
|
|
|
332
372
|
hasAnyIcon ? "grid-cols-[24px_1fr]" : "grid-cols-1",
|
|
333
373
|
isDropTarget && "border-t-2 border-primary",
|
|
334
374
|
)}
|
|
375
|
+
onFocusCapture={onEditableActivity}
|
|
376
|
+
onInput={onEditableActivity}
|
|
377
|
+
onMouseMove={() => setEditingSuppressed(false)}
|
|
335
378
|
>
|
|
336
|
-
{/* Drag handle — absolute in left gutter, no layout shift */}
|
|
337
|
-
<IconButton
|
|
338
|
-
ref={handleRef}
|
|
339
|
-
icon={<DragHandle size={16} />}
|
|
340
|
-
label="Drag to reorder"
|
|
341
|
-
size="sm"
|
|
342
|
-
className={cn(
|
|
343
|
-
"absolute -left-7 top-0 shrink-0 cursor-grab rounded-md text-base-contrast-light/80 hover:bg-base-contrast-light/10 hover:text-base-contrast active:cursor-grabbing",
|
|
344
|
-
iconHover
|
|
345
|
-
? "opacity-0 pointer-events-none"
|
|
346
|
-
: "opacity-0 group-hover/row:opacity-100 no-hover:opacity-100",
|
|
347
|
-
)}
|
|
348
|
-
tabIndex={-1}
|
|
349
|
-
/>
|
|
350
379
|
|
|
351
|
-
{/* Icon — matches ViewIconList layout: 24px column, flex items-start pt-0.5
|
|
380
|
+
{/* Icon — matches ViewIconList layout: 24px column, flex items-start pt-0.5.
|
|
381
|
+
A SINGLE always-present trigger shows the current effective glyph
|
|
382
|
+
(resolveIcon returns the forced check/x for tagged items) and opens
|
|
383
|
+
the IconPicker, which carries both the curated grid (None mode) and
|
|
384
|
+
the Do/Don't tiles (Do-Don't mode). Reachable for every item, tagged
|
|
385
|
+
or not, so tags can be edited or cleared via the picker. */}
|
|
352
386
|
{hasAnyIcon && (
|
|
353
387
|
<div
|
|
354
|
-
|
|
388
|
+
data-testid="icon-list-icon"
|
|
389
|
+
className={cn("relative flex items-start pt-0.5", iconColor)}
|
|
355
390
|
onMouseEnter={() => setIconHover(true)}
|
|
356
391
|
onMouseLeave={() => setIconHover(false)}
|
|
357
392
|
>
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
393
|
+
<button
|
|
394
|
+
className="cursor-pointer transition-opacity hover:opacity-70"
|
|
395
|
+
aria-label="Change icon"
|
|
396
|
+
onClick={() => setPickerOpen((prev) => !prev)}
|
|
397
|
+
>
|
|
398
|
+
{iconHover && !pickerOpen ? (
|
|
399
|
+
<Pencil size={18} className="text-base-contrast-light" />
|
|
400
|
+
) : iconEntry ? (
|
|
364
401
|
<iconEntry.icon size={18} />
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
<span className="pointer-events-none absolute right-full mr-3 top-0.5 whitespace-nowrap text-xs text-base-contrast-light/80">
|
|
370
|
-
Edit
|
|
371
|
-
</span>
|
|
372
|
-
)}
|
|
402
|
+
) : (
|
|
403
|
+
<span className="flex h-[18px] w-[18px] items-center justify-center rounded-sm border border-dashed border-base-contrast-light/40" />
|
|
404
|
+
)}
|
|
405
|
+
</button>
|
|
373
406
|
|
|
374
407
|
{pickerOpen && (
|
|
375
408
|
<div className="absolute top-full left-0 z-50 mt-1">
|
|
376
409
|
<IconPicker
|
|
377
410
|
selected={effectiveIconId}
|
|
378
|
-
showRemove={
|
|
411
|
+
showRemove={true}
|
|
412
|
+
allowDoDont={true}
|
|
413
|
+
dodont={item.dodont}
|
|
379
414
|
onSelect={(newIcon) => {
|
|
380
|
-
|
|
415
|
+
onUpdateItem({ icon: newIcon ?? undefined, dodont: undefined });
|
|
381
416
|
setPickerOpen(false);
|
|
382
417
|
setIconHover(false);
|
|
383
418
|
}}
|
|
419
|
+
onSelectDoDont={(v) => {
|
|
420
|
+
onUpdateItem({ dodont: v });
|
|
421
|
+
// Switching to None mode (v === undefined) clears the tag but
|
|
422
|
+
// keeps the picker open so the grid is reachable; choosing a
|
|
423
|
+
// Do/Don't tile commits and closes.
|
|
424
|
+
if (v !== undefined) {
|
|
425
|
+
setPickerOpen(false);
|
|
426
|
+
setIconHover(false);
|
|
427
|
+
}
|
|
428
|
+
}}
|
|
384
429
|
onClose={() => {
|
|
385
430
|
setPickerOpen(false);
|
|
386
431
|
setIconHover(false);
|
|
@@ -413,15 +458,40 @@ function EditableRow({
|
|
|
413
458
|
/>
|
|
414
459
|
</div>
|
|
415
460
|
|
|
416
|
-
{/*
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
461
|
+
{/* Per-row controls — drag grip + duplicate + delete, overlaid at top-right of the text.
|
|
462
|
+
Hidden while the user is actively editing this row's text; restored on cursor movement. */}
|
|
463
|
+
<div
|
|
464
|
+
className={cn(
|
|
465
|
+
"absolute right-1 top-1 z-10 flex items-center gap-1",
|
|
466
|
+
editingSuppressed
|
|
467
|
+
? "opacity-0 pointer-events-none"
|
|
468
|
+
: "opacity-0 group-hover/row:opacity-100 no-hover:opacity-100",
|
|
469
|
+
)}
|
|
470
|
+
>
|
|
471
|
+
<IconButton
|
|
472
|
+
ref={handleRef}
|
|
473
|
+
icon={<DragHandle size={14} />}
|
|
474
|
+
label="Drag to reorder"
|
|
475
|
+
size="sm"
|
|
476
|
+
className="cursor-grab rounded bg-base/80 p-1 shadow-sm text-base-contrast-light/80 hover:text-base-contrast active:cursor-grabbing"
|
|
477
|
+
tabIndex={-1}
|
|
478
|
+
/>
|
|
479
|
+
<button
|
|
480
|
+
aria-label="Duplicate item"
|
|
481
|
+
onClick={onDuplicate}
|
|
482
|
+
className="cursor-pointer rounded bg-base/80 p-1 shadow-sm text-base-contrast-light hover:text-primary"
|
|
483
|
+
>
|
|
484
|
+
<CopyPlus size={14} />
|
|
485
|
+
</button>
|
|
486
|
+
<IconButton
|
|
487
|
+
icon={<DeleteIcon size={14} />}
|
|
488
|
+
label="Delete item"
|
|
489
|
+
size="sm"
|
|
490
|
+
intent="destructive"
|
|
491
|
+
onClick={() => onRemove(id)}
|
|
492
|
+
className="bg-base/80 shadow-sm"
|
|
493
|
+
/>
|
|
494
|
+
</div>
|
|
425
495
|
</div>
|
|
426
496
|
);
|
|
427
497
|
}
|
|
@@ -13,7 +13,7 @@ export function IconListSettings({
|
|
|
13
13
|
icon: string | null;
|
|
14
14
|
showLabel: boolean;
|
|
15
15
|
stackText: boolean;
|
|
16
|
-
onChange: (
|
|
16
|
+
onChange: (result: { content: Record<string, unknown>; options: Record<string, unknown> }) => void;
|
|
17
17
|
}) {
|
|
18
18
|
const [icon, setIcon] = useState(initialIcon);
|
|
19
19
|
const [showLabel, setShowLabel] = useState(initialShowLabel);
|
|
@@ -21,7 +21,7 @@ export function IconListSettings({
|
|
|
21
21
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
22
22
|
|
|
23
23
|
const emit = (overrides: Partial<{ icon: string | null; showLabel: boolean; stackText: boolean }>) =>
|
|
24
|
-
onChange({ icon, showLabel, stackText, ...overrides });
|
|
24
|
+
onChange({ content: {}, options: { icon, showLabel, stackText, ...overrides } });
|
|
25
25
|
|
|
26
26
|
const showIcons = icon !== null;
|
|
27
27
|
const iconEntry = icon ? getIcon(icon) : null;
|
|
@@ -7,7 +7,7 @@ import { IconListSettings } from "./IconListSettings";
|
|
|
7
7
|
const schema = z.object({
|
|
8
8
|
type: z.literal("icon_list"),
|
|
9
9
|
content: z.object({
|
|
10
|
-
items: z.array(z.object({ label: z.string(), text: z.string(), icon: z.string().optional() })),
|
|
10
|
+
items: z.array(z.object({ label: z.string(), text: z.string(), icon: z.string().optional(), dodont: z.enum(["do", "dont"]).optional() })),
|
|
11
11
|
}),
|
|
12
12
|
options: z.object({
|
|
13
13
|
icon: z.string().nullable().optional(),
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { SingleMediaReference } from "../../../schemas/shared";
|
|
2
|
+
import type { LinkValue } from "../../../schemas/link";
|
|
3
|
+
import { Check, X } from "lucide-react";
|
|
4
|
+
import { cn } from "../../../lib/cn";
|
|
5
|
+
import { ResolvedMedia } from "../../primitives/ResolvedMedia";
|
|
6
|
+
import { ImageDropZone } from "../../primitives/ImageDropZone";
|
|
7
|
+
import { EditablePlainText } from "../../primitives/EditablePlainText";
|
|
8
|
+
import { useMediaLibrary } from "../../shell/MediaLibraryContext";
|
|
9
|
+
|
|
10
|
+
export interface MediaBlockProps {
|
|
11
|
+
refValue: SingleMediaReference;
|
|
12
|
+
options: { square?: boolean; showCaption?: boolean; border?: boolean; objectFit?: "cover" | "contain" };
|
|
13
|
+
/** Per-item link (viewer: already resolved to kind:"external" upstream). */
|
|
14
|
+
link?: LinkValue;
|
|
15
|
+
/** Per-item do/dont badge. */
|
|
16
|
+
dodont?: "do" | "dont";
|
|
17
|
+
isEditMode: boolean;
|
|
18
|
+
onRefChange?: (ref: SingleMediaReference) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function MediaBlock({ refValue, options, link, dodont, isEditMode, onRefChange }: MediaBlockProps) {
|
|
22
|
+
const mediaLibrary = useMediaLibrary();
|
|
23
|
+
// Per-item ref value overrides the inherited option (childDefaults); default contain.
|
|
24
|
+
const fit = refValue.objectFit ?? options.objectFit;
|
|
25
|
+
const fitClass = fit === "cover" ? "object-cover" : "object-contain";
|
|
26
|
+
const showBorder = refValue.border ?? options.border;
|
|
27
|
+
const captionStr = refValue.caption
|
|
28
|
+
? Array.isArray(refValue.caption) ? refValue.caption.join("\n") : refValue.caption
|
|
29
|
+
: "";
|
|
30
|
+
|
|
31
|
+
const replace = onRefChange ? (imageId: string) => onRefChange({ ...refValue, imageId }) : undefined;
|
|
32
|
+
|
|
33
|
+
const resolved = (
|
|
34
|
+
<ResolvedMedia
|
|
35
|
+
imageId={refValue.imageId || undefined}
|
|
36
|
+
className="h-full w-full"
|
|
37
|
+
imgClassName={fitClass}
|
|
38
|
+
invertFrom={refValue.invertFrom}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
let media = isEditMode && replace ? (
|
|
43
|
+
<ImageDropZone onImageSelected={replace} className="h-full w-full">
|
|
44
|
+
{resolved}
|
|
45
|
+
</ImageDropZone>
|
|
46
|
+
) : resolved;
|
|
47
|
+
|
|
48
|
+
// Viewer link wrapper (CTAButton pattern). resolveInternalLinks has collapsed
|
|
49
|
+
// internal links to kind:"external" before render. No wrapper in edit mode.
|
|
50
|
+
if (!isEditMode && link && link.kind === "external" && link.href) {
|
|
51
|
+
media = (
|
|
52
|
+
<a href={link.href} target={link.target} className="group block">
|
|
53
|
+
{media}
|
|
54
|
+
</a>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const onImageClick = isEditMode && replace && mediaLibrary
|
|
59
|
+
? () => mediaLibrary.openSelectModal((imageId) => replace(imageId))
|
|
60
|
+
: undefined;
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<figure>
|
|
64
|
+
{dodont && (
|
|
65
|
+
<div className="flex h-10 items-center justify-center pb-1">
|
|
66
|
+
{dodont === "do"
|
|
67
|
+
? <Check size={28} className="text-green-600" />
|
|
68
|
+
: <X size={28} className="text-red-600" />}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
<div
|
|
72
|
+
data-testid="media-open-surface"
|
|
73
|
+
className={cn(
|
|
74
|
+
"overflow-hidden rounded-md",
|
|
75
|
+
showBorder && "border border-base-200",
|
|
76
|
+
options.square && "aspect-square",
|
|
77
|
+
onImageClick && "cursor-pointer",
|
|
78
|
+
)}
|
|
79
|
+
onClick={onImageClick}
|
|
80
|
+
onPointerDown={onImageClick ? (e) => e.stopPropagation() : undefined}
|
|
81
|
+
>
|
|
82
|
+
{media}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
{options.showCaption && (
|
|
86
|
+
<figcaption className="mt-2 min-h-[1em] text-sm text-base-contrast-light">
|
|
87
|
+
{isEditMode && onRefChange ? (
|
|
88
|
+
<EditablePlainText
|
|
89
|
+
tag="span"
|
|
90
|
+
value={captionStr}
|
|
91
|
+
onChange={(caption) => onRefChange({ ...refValue, caption: caption || undefined })}
|
|
92
|
+
isEditMode={true}
|
|
93
|
+
placeholder="Caption"
|
|
94
|
+
className="block min-h-[1em]"
|
|
95
|
+
/>
|
|
96
|
+
) : (
|
|
97
|
+
captionStr || " "
|
|
98
|
+
)}
|
|
99
|
+
</figcaption>
|
|
100
|
+
)}
|
|
101
|
+
</figure>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { defineSection } from "../../../lib/registry";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { Image as ImageIcon } from "lucide-react";
|
|
4
|
+
import { SingleMediaReferenceSchema } from "../../../schemas/shared";
|
|
5
|
+
import { LinkValueSchema, DEFAULT_LINK } from "../../../schemas/link";
|
|
6
|
+
import { MediaBlock } from "./MediaBlock";
|
|
7
|
+
|
|
8
|
+
const schema = z.object({
|
|
9
|
+
type: z.literal("media"),
|
|
10
|
+
content: z.object({
|
|
11
|
+
ref: SingleMediaReferenceSchema,
|
|
12
|
+
link: LinkValueSchema.optional(),
|
|
13
|
+
dodont: z.enum(["do", "dont"]).optional(),
|
|
14
|
+
}),
|
|
15
|
+
options: z
|
|
16
|
+
.object({
|
|
17
|
+
square: z.boolean().optional(),
|
|
18
|
+
showCaption: z.boolean().optional(),
|
|
19
|
+
border: z.boolean().optional(),
|
|
20
|
+
objectFit: z.enum(["cover", "contain"]).optional(),
|
|
21
|
+
})
|
|
22
|
+
.default({}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export default defineSection({
|
|
26
|
+
type: "media",
|
|
27
|
+
label: "Media",
|
|
28
|
+
icon: <ImageIcon size={18} />,
|
|
29
|
+
schema,
|
|
30
|
+
component: ({ content, options, onChange }) => (
|
|
31
|
+
<MediaBlock
|
|
32
|
+
refValue={content.content.ref}
|
|
33
|
+
options={(options ?? {}) as { square?: boolean; showCaption?: boolean; border?: boolean; objectFit?: "cover" | "contain" }}
|
|
34
|
+
link={content.content.link}
|
|
35
|
+
dodont={content.content.dodont}
|
|
36
|
+
isEditMode={!!onChange}
|
|
37
|
+
onRefChange={
|
|
38
|
+
onChange ? (ref) => onChange({ ...content, content: { ...content.content, ref } }) : undefined
|
|
39
|
+
}
|
|
40
|
+
/>
|
|
41
|
+
),
|
|
42
|
+
defaults: () => ({
|
|
43
|
+
type: "media" as const,
|
|
44
|
+
content: { ref: { type: "image" as const, imageId: "" } },
|
|
45
|
+
options: {},
|
|
46
|
+
}),
|
|
47
|
+
getLabel: () => "Media",
|
|
48
|
+
getThumbnails: (content) =>
|
|
49
|
+
content.content.ref.imageId
|
|
50
|
+
? [{ type: "image" as const, src: content.content.ref.imageId }]
|
|
51
|
+
: [],
|
|
52
|
+
settings: {
|
|
53
|
+
square: { type: "checkbox", label: "Square aspect ratio", default: false },
|
|
54
|
+
showCaption: { type: "checkbox", label: "Show caption", default: false },
|
|
55
|
+
border: { type: "checkbox", label: "Border", default: false },
|
|
56
|
+
objectFit: {
|
|
57
|
+
type: "select",
|
|
58
|
+
label: "Image fit",
|
|
59
|
+
default: "contain",
|
|
60
|
+
options: [
|
|
61
|
+
{ label: "Contain", value: "contain" },
|
|
62
|
+
{ label: "Cover", value: "cover" },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
link: { type: "link", label: "Link", default: DEFAULT_LINK, target: "content" },
|
|
66
|
+
dodont: {
|
|
67
|
+
type: "select",
|
|
68
|
+
label: "Do / Don't",
|
|
69
|
+
target: "content",
|
|
70
|
+
default: "",
|
|
71
|
+
emptyIsUndefined: true,
|
|
72
|
+
options: [
|
|
73
|
+
{ label: "None", value: "" },
|
|
74
|
+
{ label: "Do", value: "do" },
|
|
75
|
+
{ label: "Don't", value: "dont" },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
inheritableSettings: ["square", "showCaption", "border", "objectFit"],
|
|
80
|
+
settingsTabs: [
|
|
81
|
+
{ label: "Display", fields: ["square", "showCaption", "border", "objectFit"] },
|
|
82
|
+
{ label: "Link", fields: ["link"] },
|
|
83
|
+
{ label: "Do / Don't", fields: ["dodont"] },
|
|
84
|
+
],
|
|
85
|
+
});
|
|
@@ -14,6 +14,7 @@ export default defineSection({
|
|
|
14
14
|
label: "Prose",
|
|
15
15
|
icon: <AlignLeft size={18} />,
|
|
16
16
|
schema,
|
|
17
|
+
richTextFields: ["body"],
|
|
17
18
|
component: ({ content, onChange }) => (
|
|
18
19
|
<Prose body={content.content.body} onChange={onChange ? (c) => onChange(c as typeof content) : undefined} />
|
|
19
20
|
),
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Spacer view cell — deliberately empty. It occupies its grid column so the gap is
|
|
2
|
+
// preserved on publish. The hide-on-collapse class (which depends on the parent's column
|
|
3
|
+
// count) is applied by the Container to the cell wrapper, not here.
|
|
4
|
+
export default function Spacer() {
|
|
5
|
+
return <div data-spacer aria-hidden="true" className="h-full" />;
|
|
6
|
+
}
|