@drawnagency/primitives 0.1.49 → 0.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/{chunk-P3HO76OS.js → chunk-DLXIYIG2.js} +6 -3
  2. package/dist/{chunk-5XYUO4HP.js → chunk-ICRCH3GI.js} +19 -2
  3. package/dist/{chunk-BJ6FYGYP.js → chunk-ONBJG426.js} +95 -9
  4. package/dist/components/editor/MoveSectionModal.d.ts +12 -0
  5. package/dist/components/editor/MoveSectionModal.d.ts.map +1 -0
  6. package/dist/components/editor/PagesModal.d.ts +18 -0
  7. package/dist/components/editor/PagesModal.d.ts.map +1 -0
  8. package/dist/components/editor/SectionWrapper.d.ts +1 -1
  9. package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
  10. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  11. package/dist/components/sections/Button/CTAButton.d.ts +3 -3
  12. package/dist/components/sections/Button/CTAButton.d.ts.map +1 -1
  13. package/dist/components/sections/Button/index.d.ts +10 -2
  14. package/dist/components/sections/Button/index.d.ts.map +1 -1
  15. package/dist/components/shared/Input.d.ts +1 -0
  16. package/dist/components/shared/Input.d.ts.map +1 -1
  17. package/dist/components/shared/LinkField.d.ts +9 -0
  18. package/dist/components/shared/LinkField.d.ts.map +1 -0
  19. package/dist/components/shared/Navigation.d.ts +4 -3
  20. package/dist/components/shared/Navigation.d.ts.map +1 -1
  21. package/dist/components/shared/PagesContext.d.ts +13 -0
  22. package/dist/components/shared/PagesContext.d.ts.map +1 -0
  23. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  24. package/dist/index.js +17 -3
  25. package/dist/lib/dexie.d.ts.map +1 -1
  26. package/dist/lib/dexie.js +16 -3
  27. package/dist/lib/events.d.ts +3 -1
  28. package/dist/lib/events.d.ts.map +1 -1
  29. package/dist/lib/index.js +2 -2
  30. package/dist/lib/links.d.ts +14 -0
  31. package/dist/lib/links.d.ts.map +1 -0
  32. package/dist/lib/loader.d.ts +2 -2
  33. package/dist/lib/loader.d.ts.map +1 -1
  34. package/dist/lib/nav.d.ts +23 -0
  35. package/dist/lib/nav.d.ts.map +1 -1
  36. package/dist/lib/pages.d.ts +31 -0
  37. package/dist/lib/pages.d.ts.map +1 -0
  38. package/dist/lib/registry.d.ts +7 -0
  39. package/dist/lib/registry.d.ts.map +1 -1
  40. package/dist/schemas/index.d.ts +1 -0
  41. package/dist/schemas/index.d.ts.map +1 -1
  42. package/dist/schemas/index.js +17 -3
  43. package/dist/schemas/link.d.ts +24 -0
  44. package/dist/schemas/link.d.ts.map +1 -0
  45. package/dist/schemas/site-config.d.ts +128 -3
  46. package/dist/schemas/site-config.d.ts.map +1 -1
  47. package/package.json +5 -1
  48. package/src/components/editor/MoveSectionModal.tsx +38 -0
  49. package/src/components/editor/PagesModal.tsx +392 -0
  50. package/src/components/editor/SectionWrapper.tsx +13 -0
  51. package/src/components/editor/SettingsForm.tsx +12 -0
  52. package/src/components/sections/Button/CTAButton.tsx +10 -11
  53. package/src/components/sections/Button/index.tsx +4 -9
  54. package/src/components/shared/Input.tsx +14 -3
  55. package/src/components/shared/LinkField.tsx +87 -0
  56. package/src/components/shared/Navigation.tsx +131 -136
  57. package/src/components/shared/PagesContext.tsx +12 -0
  58. package/src/components/shell/EditorShell.tsx +273 -78
  59. package/src/hooks/useEditorPublish.ts +1 -1
  60. package/src/lib/dexie.ts +18 -5
  61. package/src/lib/events.ts +3 -1
  62. package/src/lib/links.ts +41 -0
  63. package/src/lib/loader.ts +5 -4
  64. package/src/lib/nav.ts +59 -0
  65. package/src/lib/pages.ts +209 -0
  66. package/src/lib/registry.ts +8 -0
  67. package/src/schemas/index.ts +1 -0
  68. package/src/schemas/link.ts +17 -0
  69. package/src/schemas/site-config.ts +113 -11
@@ -0,0 +1,392 @@
1
+ import { useState, useEffect, useRef } from "react";
2
+ import { Check } from "lucide-react";
3
+ import type { SiteIndex, Page } from "../../schemas/site-config";
4
+ import type { Audience } from "../../auth/types";
5
+ import { Button } from "../shared/Button";
6
+ import { Input } from "../shared/Input";
7
+ import { Checkbox } from "../shared/Checkbox";
8
+ import { AudienceIndicator } from "./AudienceIndicator";
9
+ import { DragHandle } from "./DragHandle";
10
+ import { slugifyPageSlug, uniquePageSlug, pageDisplayTitle } from "../../lib/pages";
11
+ import { RESERVED_SLUGS } from "../../schemas/site-config";
12
+ import { cn } from "../../lib/cn";
13
+
14
+ export interface PagesModalProps {
15
+ index: SiteIndex;
16
+ audiences: Audience[];
17
+ onReorder: (fromIndex: number, toIndex: number) => void;
18
+ /** Creates the page and returns its id so the modal can expand the new row. */
19
+ onAddPage: () => string;
20
+ onSetHome: (pageId: string) => void;
21
+ onSetArchived: (pageId: string, archived: boolean) => void;
22
+ onSetFields: (pageId: string, patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
23
+ onSetAudience: (pageId: string, access: string[]) => void;
24
+ onConfirmDelete: (pageId: string) => void;
25
+ /** Navigate the editor to this page (the shell also closes the modal). */
26
+ onNavigate: (pageId: string) => void;
27
+ }
28
+
29
+ export function PagesModal(props: PagesModalProps) {
30
+ const { index, audiences } = props;
31
+ const [expandedId, setExpandedId] = useState<string | null>(null);
32
+ const [deletingId, setDeletingId] = useState<string | null>(null);
33
+ // The page created via "+ Add page" this session: its title gets auto-focus and
34
+ // its slug follows the title until the user edits the slug by hand.
35
+ const [newPageId, setNewPageId] = useState<string | null>(null);
36
+
37
+ if (deletingId) {
38
+ const page = index.pages.find((p) => p.id === deletingId);
39
+ if (page) return <DeleteConfirm page={page} onCancel={() => setDeletingId(null)} onConfirm={() => { props.onConfirmDelete(page.id); setDeletingId(null); }} />;
40
+ }
41
+
42
+ const live = index.pages.filter((p) => p.status === "live");
43
+ const archived = index.pages.filter((p) => p.status === "archived");
44
+
45
+ // Collapsing the new row — or expanding any other — ends its "new page"
46
+ // editing session (auto-focus + slug-follows-title).
47
+ const toggleExpand = (pageId: string) => {
48
+ const next = expandedId === pageId ? null : pageId;
49
+ setExpandedId(next);
50
+ if (newPageId !== null && next !== newPageId) setNewPageId(null);
51
+ };
52
+
53
+ const renderPage = (page: Page, dragIndex?: number) => (
54
+ <PageRow
55
+ key={page.id}
56
+ page={page}
57
+ audiences={audiences}
58
+ expanded={expandedId === page.id}
59
+ isNew={newPageId === page.id}
60
+ otherSlugs={index.pages.filter((p) => p.id !== page.id).map((p) => p.slug)}
61
+ dragIndex={dragIndex}
62
+ onReorder={props.onReorder}
63
+ onToggleExpand={() => toggleExpand(page.id)}
64
+ onNavigate={() => props.onNavigate(page.id)}
65
+ onSetHome={() => props.onSetHome(page.id)}
66
+ onSetArchived={(v) => {
67
+ // Archiving/restoring remounts the row in its new group, so end the
68
+ // "new page" session — its blank-draft state must not survive the move.
69
+ if (newPageId === page.id) setNewPageId(null);
70
+ props.onSetArchived(page.id, v);
71
+ }}
72
+ onSetFields={(patch) => props.onSetFields(page.id, patch)}
73
+ onSetAudience={(access) => props.onSetAudience(page.id, access)}
74
+ onRequestDelete={() =>
75
+ page.order.length === 0 ? props.onConfirmDelete(page.id) : setDeletingId(page.id)
76
+ }
77
+ />
78
+ );
79
+
80
+ return (
81
+ <div className="flex flex-col gap-2 overflow-y-auto">
82
+ <div className="flex flex-col gap-2">
83
+ {live.map((page) => renderPage(page, index.pages.indexOf(page)))}
84
+ </div>
85
+ <Button
86
+ variant="secondary"
87
+ size="md"
88
+ onClick={() => {
89
+ const id = props.onAddPage();
90
+ setNewPageId(id);
91
+ setExpandedId(id);
92
+ }}
93
+ className="mt-2 self-start"
94
+ >
95
+ + Add page
96
+ </Button>
97
+ {archived.length > 0 && (
98
+ <div className="mt-4 border-t border-base-200 pt-3">
99
+ <p className="mb-2 text-xs font-semibold uppercase tracking-wide text-base-contrast/70">Archive</p>
100
+ <div className="flex flex-col gap-2">{archived.map((page) => renderPage(page))}</div>
101
+ </div>
102
+ )}
103
+ </div>
104
+ );
105
+ }
106
+
107
+ function PageRow({
108
+ page, audiences, expanded, isNew, otherSlugs, dragIndex, onReorder, onToggleExpand, onNavigate, onSetHome, onSetArchived, onSetFields, onSetAudience, onRequestDelete,
109
+ }: {
110
+ page: Page; audiences: Audience[]; expanded: boolean;
111
+ isNew: boolean; otherSlugs: string[];
112
+ dragIndex?: number; onReorder: (fromIndex: number, toIndex: number) => void;
113
+ onToggleExpand: () => void; onNavigate: () => void; onSetHome: () => void; onSetArchived: (v: boolean) => void;
114
+ onSetFields: (patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
115
+ onSetAudience: (access: string[]) => void; onRequestDelete: () => void;
116
+ }) {
117
+ const rowRef = useRef<HTMLDivElement>(null);
118
+ const handleRef = useRef<HTMLButtonElement>(null);
119
+ const [isDragging, setIsDragging] = useState(false);
120
+ const [closestEdge, setClosestEdge] = useState<"top" | "bottom" | null>(null);
121
+ const draggable = dragIndex !== undefined;
122
+
123
+ // The slug input edits a local draft; only valid values commit to the index,
124
+ // which must always hold a non-empty, unique, non-reserved slug.
125
+ // Blank draft only while the new page has no title yet; once a slug has been
126
+ // committed it must survive row remounts (e.g. moving between nav groups).
127
+ const [slugDraft, setSlugDraft] = useState(isNew && !page.title ? "" : page.slug);
128
+ const [autoSlug, setAutoSlug] = useState(isNew);
129
+ const [titleDirty, setTitleDirty] = useState(false);
130
+ const [slugDirty, setSlugDirty] = useState(false);
131
+ const [showErrors, setShowErrors] = useState(false);
132
+
133
+ useEffect(() => {
134
+ if (!expanded) return;
135
+ setSlugDraft(isNew && !page.title ? "" : page.slug);
136
+ setAutoSlug(isNew);
137
+ setTitleDirty(false);
138
+ setSlugDirty(false);
139
+ setShowErrors(false);
140
+ // Reset editing state each time the row expands; page.slug is read at that moment.
141
+ // eslint-disable-next-line react-hooks/exhaustive-deps
142
+ }, [expanded]);
143
+
144
+ const committedSlug = slugifyPageSlug(slugDraft);
145
+ const titleError = page.title.trim() === "" ? "Title is required." : null;
146
+ const slugError = page.isHome
147
+ ? null
148
+ : committedSlug === ""
149
+ ? "Slug is required."
150
+ : (RESERVED_SLUGS as readonly string[]).includes(committedSlug)
151
+ ? `"${committedSlug}" is a reserved URL.`
152
+ : otherSlugs.includes(committedSlug)
153
+ ? "Another page already uses this slug."
154
+ : null;
155
+
156
+ const handleTitleChange = (title: string) => {
157
+ setTitleDirty(true);
158
+ if (autoSlug && !page.isHome) {
159
+ const base = slugifyPageSlug(title);
160
+ if (base !== "") {
161
+ const slug = uniquePageSlug(base, otherSlugs);
162
+ setSlugDraft(slug);
163
+ onSetFields({ title, slug });
164
+ return;
165
+ }
166
+ setSlugDraft("");
167
+ }
168
+ onSetFields({ title });
169
+ };
170
+
171
+ const handleSlugChange = (raw: string) => {
172
+ setSlugDirty(true);
173
+ setAutoSlug(false);
174
+ // Normalize for typing but keep a trailing hyphen so "my-page" can be typed.
175
+ const draft = raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+/, "");
176
+ setSlugDraft(draft);
177
+ const slug = slugifyPageSlug(draft);
178
+ if (
179
+ slug !== "" &&
180
+ !(RESERVED_SLUGS as readonly string[]).includes(slug) &&
181
+ !otherSlugs.includes(slug)
182
+ ) {
183
+ onSetFields({ slug });
184
+ }
185
+ };
186
+
187
+ const handleHeaderClick = () => {
188
+ if (expanded && (titleError || slugError)) {
189
+ setShowErrors(true);
190
+ return;
191
+ }
192
+ onToggleExpand();
193
+ };
194
+
195
+ useEffect(() => {
196
+ if (dragIndex === undefined) return;
197
+ const row = rowRef.current;
198
+ const handle = handleRef.current;
199
+ if (!row || !handle) return;
200
+
201
+ let cleanup: (() => void) | undefined;
202
+ let cancelled = false;
203
+
204
+ Promise.all([
205
+ import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
206
+ import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
207
+ ]).then(([{ draggable, dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
208
+ if (cancelled) return;
209
+
210
+ const cleanupDraggable = draggable({
211
+ element: row,
212
+ dragHandle: handle,
213
+ getInitialData: () => ({ dragType: "page-ordering-row", index: dragIndex }),
214
+ onGenerateDragPreview: () => {
215
+ row.style.opacity = "0.4";
216
+ requestAnimationFrame(() => {
217
+ row.style.opacity = "";
218
+ });
219
+ },
220
+ onDragStart: () => setIsDragging(true),
221
+ onDrop: () => setIsDragging(false),
222
+ });
223
+
224
+ const cleanupDropTarget = dropTargetForElements({
225
+ element: row,
226
+ canDrop: ({ source }) => source.data.dragType === "page-ordering-row",
227
+ getData: ({ input, element }) =>
228
+ attachClosestEdge({ index: dragIndex }, { input, element, allowedEdges: ["top", "bottom"] }),
229
+ onDragEnter: ({ self }) => {
230
+ const edge = extractClosestEdge(self.data);
231
+ setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
232
+ },
233
+ onDrag: ({ self }) => {
234
+ const edge = extractClosestEdge(self.data);
235
+ setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
236
+ },
237
+ onDragLeave: () => setClosestEdge(null),
238
+ onDrop: ({ source, self }) => {
239
+ setClosestEdge(null);
240
+ const fromIndex = source.data.index as number;
241
+ const edge = extractClosestEdge(self.data);
242
+ let toIndex = dragIndex;
243
+ if (edge === "bottom") toIndex = dragIndex + 1;
244
+ if (fromIndex < toIndex) toIndex--;
245
+ if (fromIndex !== toIndex) {
246
+ onReorder(fromIndex, toIndex);
247
+ }
248
+ },
249
+ });
250
+
251
+ cleanup = () => {
252
+ cleanupDraggable();
253
+ cleanupDropTarget();
254
+ };
255
+ });
256
+
257
+ return () => {
258
+ cancelled = true;
259
+ cleanup?.();
260
+ };
261
+ }, [dragIndex, onReorder]);
262
+
263
+ return (
264
+ <div
265
+ ref={rowRef}
266
+ className={cn("relative rounded-md border border-base-200", isDragging && "opacity-50")}
267
+ >
268
+ {closestEdge === "top" && (
269
+ <div className="absolute top-0 right-0 left-0 z-10 h-0.5 -translate-y-1/2 bg-primary" />
270
+ )}
271
+ {closestEdge === "bottom" && (
272
+ <div className="absolute right-0 bottom-0 left-0 z-10 h-0.5 translate-y-1/2 bg-primary" />
273
+ )}
274
+ <div className="flex items-center gap-2 px-3 py-2">
275
+ {draggable && (
276
+ <div className="shrink-0 [&_[role=tooltip]]:hidden">
277
+ <DragHandle ref={handleRef} />
278
+ </div>
279
+ )}
280
+ <button
281
+ type="button"
282
+ aria-label={`Go to ${pageDisplayTitle(page.title)}`}
283
+ onClick={onNavigate}
284
+ className="group min-w-0 flex-1 cursor-pointer text-left"
285
+ >
286
+ <div className="flex items-center gap-2">
287
+ <span className={cn("truncate text-sm font-medium text-base-contrast transition-colors group-hover:text-primary", !page.title && "italic text-base-contrast/60")}>
288
+ {pageDisplayTitle(page.title)}
289
+ </span>
290
+ {page.isHome && <span className="rounded bg-primary px-1.5 py-0.5 text-xs font-medium text-primary-contrast">Home page</span>}
291
+ {!page.showInNav && <span className="rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">Hidden</span>}
292
+ {page.status === "archived" && <span className="rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">Archived</span>}
293
+ </div>
294
+ <div className="text-xs text-base-contrast/70 transition-colors group-hover:text-primary">{page.isHome ? "/" : `/${page.slug}`}</div>
295
+ </button>
296
+ <button
297
+ type="button"
298
+ aria-label={`Edit settings for ${pageDisplayTitle(page.title)}`}
299
+ onClick={handleHeaderClick}
300
+ className="cursor-pointer rounded px-2 py-1 text-sm text-base-contrast/70 hover:text-primary"
301
+ >
302
+ {expanded ? "Done" : "Edit"}
303
+ </button>
304
+ </div>
305
+
306
+ {expanded && (
307
+ <div className="flex flex-col gap-3 border-t border-base-200 px-3 py-3">
308
+ <Input
309
+ label="Title"
310
+ value={page.title}
311
+ placeholder="Page title"
312
+ autoFocus={isNew}
313
+ error={(titleDirty || showErrors) && titleError ? titleError : undefined}
314
+ onChange={handleTitleChange}
315
+ />
316
+ {!page.isHome && (
317
+ <Input
318
+ label="Slug"
319
+ value={slugDraft}
320
+ placeholder="page-url"
321
+ error={(slugDirty || showErrors) && slugError ? slugError : undefined}
322
+ onChange={handleSlugChange}
323
+ />
324
+ )}
325
+ <Checkbox label="Show in nav" align="center" checked={page.showInNav} onChange={(v) => onSetFields({ showInNav: v })} />
326
+ <div>
327
+ <label className="mb-1 block text-sm text-base-contrast">Audience</label>
328
+ <AudienceIndicator access={page.access} audiences={audiences} onChange={onSetAudience} />
329
+ {page.access.length === 0 && (
330
+ <p className="mt-1 text-xs text-base-contrast/70">
331
+ Viewers see this page; sections still apply their own audience rules.
332
+ </p>
333
+ )}
334
+ </div>
335
+ <div className="flex items-center gap-2">
336
+ <Button
337
+ variant="secondary"
338
+ size="sm"
339
+ disabled={page.isHome}
340
+ onClick={onSetHome}
341
+ className={cn(
342
+ page.isHome &&
343
+ "cursor-default gap-1 border-primary bg-primary text-primary-contrast opacity-100 hover:bg-primary hover:opacity-90",
344
+ )}
345
+ >
346
+ {page.isHome && <Check size={12} strokeWidth={3} aria-hidden="true" />}
347
+ {page.isHome ? "Home page" : "Set as home"}
348
+ </Button>
349
+ <Button
350
+ variant="secondary"
351
+ size="sm"
352
+ disabled={page.isHome}
353
+ onClick={() => onSetArchived(page.status !== "archived")}
354
+ aria-label={page.status === "archived" ? "Restore" : "Archive"}
355
+ >
356
+ {page.status === "archived" ? "Restore" : "Archive"}
357
+ </Button>
358
+ <Button variant="destructive" size="sm" disabled={page.isHome} aria-label="Delete page" onClick={onRequestDelete} className="ml-auto">
359
+ Delete
360
+ </Button>
361
+ </div>
362
+ </div>
363
+ )}
364
+ </div>
365
+ );
366
+ }
367
+
368
+ function DeleteConfirm({ page, onCancel, onConfirm }: { page: Page; onCancel: () => void; onConfirm: () => void }) {
369
+ const [typed, setTyped] = useState("");
370
+ const n = page.order.length;
371
+ // Compare against the display title: a blank title must not leave the
372
+ // type-the-name guard pre-satisfied ("" === "").
373
+ const confirmName = pageDisplayTitle(page.title);
374
+ return (
375
+ <div className="flex flex-col gap-3">
376
+ <p className="text-sm text-base-contrast">
377
+ Deleting <strong>{confirmName}</strong> permanently removes the page and its{" "}
378
+ <strong>{n} section{n === 1 ? "" : "s"}</strong>. This is recoverable only via GitHub version history.
379
+ </p>
380
+ <Input
381
+ label={`Type the page name "${confirmName}" to confirm`}
382
+ value={typed}
383
+ onChange={setTyped}
384
+ placeholder={confirmName}
385
+ />
386
+ <div className="flex justify-end gap-3">
387
+ <Button variant="secondary" size="md" onClick={onCancel}>Cancel</Button>
388
+ <Button variant="destructive" size="md" disabled={typed !== confirmName} onClick={onConfirm}>Delete</Button>
389
+ </div>
390
+ </div>
391
+ );
392
+ }
@@ -1,4 +1,5 @@
1
1
  import { useState, useRef, useEffect } from "react";
2
+ import { ArrowRightLeft } from "lucide-react";
2
3
  import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
3
4
  import { DragHandle } from "./DragHandle";
4
5
  import { InsertButton } from "./InsertButton";
@@ -29,6 +30,7 @@ export function SectionWrapper({
29
30
  onReorder,
30
31
  onRequestInsert,
31
32
  onDelete,
33
+ onMoveSection,
32
34
  mainStatus,
33
35
  contentDiffersFromMain,
34
36
  isLocalOnly,
@@ -286,6 +288,17 @@ export function SectionWrapper({
286
288
  <SettingsButton onClick={handleSettingsClick} />
287
289
  )}
288
290
 
291
+ {onMoveSection && (
292
+ <button
293
+ type="button"
294
+ onClick={onMoveSection}
295
+ aria-label="Move to page"
296
+ className="pointer-events-auto cursor-pointer rounded p-1 text-base-contrast-light hover:text-primary"
297
+ >
298
+ <ArrowRightLeft size={16} />
299
+ </button>
300
+ )}
301
+
289
302
  {onDelete && <DeleteButton onDelete={onDelete} />}
290
303
  </div>
291
304
 
@@ -4,6 +4,8 @@ import { Input } from "../shared/Input";
4
4
  import { Select } from "../shared/Select";
5
5
  import { Checkbox } from "../shared/Checkbox";
6
6
  import { FormLabel } from "../shared/FormLabel";
7
+ import { LinkField } from "../shared/LinkField";
8
+ import type { LinkValue } from "../../schemas/link";
7
9
  import { cn } from "../../lib/cn";
8
10
 
9
11
  export interface SettingsFormResult {
@@ -150,6 +152,16 @@ export function SettingsForm({ schema, values, onChange }: SettingsFormProps) {
150
152
  />
151
153
  );
152
154
 
155
+ case "link":
156
+ return (
157
+ <LinkField
158
+ key={key}
159
+ label={field.label}
160
+ value={(value as LinkValue) ?? field.default}
161
+ onChange={(v) => handleFieldChange(key, v)}
162
+ />
163
+ );
164
+
153
165
  default:
154
166
  return null;
155
167
  }
@@ -2,16 +2,16 @@ import { cn } from "../../../lib/cn";
2
2
  import { Download } from "lucide-react";
3
3
  import { EditablePlainText } from "../../primitives/EditablePlainText";
4
4
  import type { SectionContent } from "../../../schemas/sections";
5
+ import type { LinkValue } from "../../../schemas/link";
5
6
 
6
7
  interface Props {
7
8
  text: string;
8
- href?: string;
9
- target?: string;
9
+ link?: LinkValue;
10
10
  download?: boolean;
11
11
  onChange?: (content: SectionContent) => void;
12
12
  }
13
13
 
14
- export default function Button({ text, href, target, download, onChange }: Props) {
14
+ export default function Button({ text, link, download, onChange }: Props) {
15
15
  const base = "inline-block rounded-md px-6 py-3 font-bold transition-colors";
16
16
  const variant = "border-2 border-primary text-primary hover:bg-primary hover:text-primary-contrast";
17
17
 
@@ -23,10 +23,7 @@ export default function Button({ text, href, target, download, onChange }: Props
23
23
  tag="span"
24
24
  value={text}
25
25
  onChange={(newText) =>
26
- onChange({
27
- type: "button",
28
- content: { text: newText, href, target, download },
29
- })
26
+ onChange({ type: "button", content: { text: newText, link, download } })
30
27
  }
31
28
  isEditMode={true}
32
29
  placeholder="Button text"
@@ -35,16 +32,18 @@ export default function Button({ text, href, target, download, onChange }: Props
35
32
  );
36
33
  }
37
34
 
35
+ // Viewer: links are resolved to { kind: "external", href, target } upstream.
36
+ const href = link && link.kind === "external" ? link.href : "";
37
+ const target = link?.target;
38
38
  if (href) {
39
39
  return (
40
- <a href={href} target={target} download={download ? "" : undefined} className={cn(base, variant, download && "inline-flex items-center gap-2")}>
40
+ <a href={href} target={target} download={download ? "" : undefined}
41
+ className={cn(base, variant, download && "inline-flex items-center gap-2")}>
41
42
  {download && <Download size={16} className="shrink-0" />}
42
43
  {text}
43
44
  </a>
44
45
  );
45
46
  }
46
47
 
47
- return (
48
- <button className={cn("cursor-pointer", base, variant)}>{text}</button>
49
- );
48
+ return <button className={cn("cursor-pointer", base, variant)}>{text}</button>;
50
49
  }
@@ -1,14 +1,14 @@
1
1
  import { defineSection } from "../../../lib/registry";
2
2
  import { z } from "zod";
3
3
  import { RectangleHorizontal } from "lucide-react";
4
+ import { LinkValueSchema, DEFAULT_LINK } from "../../../schemas/link";
4
5
  import CTAButton from "./CTAButton";
5
6
 
6
7
  const schema = z.object({
7
8
  type: z.literal("button"),
8
9
  content: z.object({
9
10
  text: z.string(),
10
- href: z.string().optional(),
11
- target: z.string().optional(),
11
+ link: LinkValueSchema.optional(),
12
12
  download: z.boolean().optional(),
13
13
  }),
14
14
  });
@@ -21,8 +21,7 @@ export default defineSection({
21
21
  component: ({ content, onChange }) => (
22
22
  <CTAButton
23
23
  text={content.content.text}
24
- href={content.content.href}
25
- target={content.content.target}
24
+ link={content.content.link}
26
25
  download={content.content.download}
27
26
  onChange={onChange ? (c) => onChange(c as typeof content) : undefined}
28
27
  />
@@ -30,11 +29,7 @@ export default defineSection({
30
29
  defaults: () => ({ type: "button" as const, content: { text: "Button" } }),
31
30
  getLabel: (content) => content.content.text,
32
31
  settings: {
33
- href: { type: "text", label: "URL", default: "", target: "content", placeholder: "https://..." },
34
- target: {
35
- type: "select", label: "Target", default: "_self", target: "content",
36
- options: [{ label: "Same tab (_self)", value: "_self" }, { label: "New tab (_blank)", value: "_blank" }],
37
- },
32
+ link: { type: "link", label: "Link", default: DEFAULT_LINK, target: "content" },
38
33
  download: { type: "checkbox", label: "Download link", default: false, target: "content" },
39
34
  },
40
35
  });
@@ -5,13 +5,15 @@ import { FormLabel } from "./FormLabel";
5
5
  interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "onChange"> {
6
6
  label: string;
7
7
  onChange: (value: string) => void;
8
+ error?: string;
8
9
  }
9
10
 
10
11
  export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
11
- { label, value, onChange, type = "text", className, disabled, ...rest },
12
+ { label, value, onChange, type = "text", className, disabled, error, ...rest },
12
13
  ref,
13
14
  ) {
14
15
  const id = useId();
16
+ const errorId = `${id}-error`;
15
17
  return (
16
18
  <div className={className}>
17
19
  <FormLabel htmlFor={id}>{label}</FormLabel>
@@ -22,13 +24,22 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
22
24
  value={value}
23
25
  onChange={(e) => onChange(e.target.value)}
24
26
  disabled={disabled}
27
+ aria-invalid={error ? true : undefined}
28
+ aria-describedby={error ? errorId : undefined}
25
29
  className={cn(
26
- "w-full rounded border border-base-200 bg-base px-3 py-2 text-sm text-base-contrast",
27
- "focus:border-base-contrast focus:outline-none focus:ring-1 focus:ring-base-contrast",
30
+ "w-full rounded border bg-base px-3 py-2 text-sm text-base-contrast focus:outline-none focus:ring-1",
31
+ error
32
+ ? "border-red-600 focus:border-red-600 focus:ring-red-600"
33
+ : "border-base-200 focus:border-base-contrast focus:ring-base-contrast",
28
34
  disabled && "cursor-not-allowed opacity-50",
29
35
  )}
30
36
  {...rest}
31
37
  />
38
+ {error && (
39
+ <p id={errorId} className="mt-1 text-xs text-red-600">
40
+ {error}
41
+ </p>
42
+ )}
32
43
  </div>
33
44
  );
34
45
  });
@@ -0,0 +1,87 @@
1
+ import { FormLabel } from "./FormLabel";
2
+ import { Input } from "./Input";
3
+ import { Select } from "./Select";
4
+ import { usePages } from "./PagesContext";
5
+ import type { LinkValue } from "../../schemas/link";
6
+ import { cn } from "../../lib/cn";
7
+
8
+ interface Props {
9
+ label: string;
10
+ value: LinkValue;
11
+ onChange: (value: LinkValue) => void;
12
+ }
13
+
14
+ const TARGET_OPTIONS = [
15
+ { label: "Same tab (_self)", value: "_self" },
16
+ { label: "New tab (_blank)", value: "_blank" },
17
+ ];
18
+
19
+ export function LinkField({ label, value, onChange }: Props) {
20
+ const { pages, getPageHeadings } = usePages();
21
+ const isInternal = value.kind === "internal";
22
+
23
+ function setMode(kind: "external" | "internal") {
24
+ if (kind === value.kind) return;
25
+ onChange(kind === "external"
26
+ ? { kind: "external", href: "", target: value.target }
27
+ : { kind: "internal", pageId: "", anchorSectionId: null, target: value.target });
28
+ }
29
+
30
+ const pageId = isInternal ? value.pageId : "";
31
+ const headings = pageId ? getPageHeadings(pageId) : [];
32
+
33
+ return (
34
+ <div className="flex flex-col gap-3">
35
+ <FormLabel>{label}</FormLabel>
36
+ <div className="flex gap-2" role="tablist">
37
+ {(["external", "internal"] as const).map((kind) => (
38
+ <button
39
+ key={kind}
40
+ type="button"
41
+ role="tab"
42
+ aria-selected={value.kind === kind}
43
+ onClick={() => setMode(kind)}
44
+ className={cn(
45
+ "cursor-pointer rounded border px-3 py-1 text-sm capitalize",
46
+ value.kind === kind ? "border-primary bg-primary text-primary-contrast" : "border-base-200 text-base-contrast",
47
+ )}
48
+ >
49
+ {kind === "external" ? "External URL" : "Internal page"}
50
+ </button>
51
+ ))}
52
+ </div>
53
+
54
+ {value.kind === "external" ? (
55
+ <Input
56
+ label="URL"
57
+ value={value.href}
58
+ placeholder="https://..."
59
+ onChange={(href) => onChange({ kind: "external", href, target: value.target })}
60
+ />
61
+ ) : (
62
+ <>
63
+ <Select
64
+ label="Page"
65
+ value={value.pageId}
66
+ options={[{ label: "Select a page…", value: "" }, ...pages.map((p) => ({ label: p.title, value: p.id }))]}
67
+ onChange={(pid) => onChange({ kind: "internal", pageId: pid, anchorSectionId: null, target: value.target })}
68
+ />
69
+ <Select
70
+ label="Section"
71
+ value={value.anchorSectionId ?? ""}
72
+ disabled={!pageId}
73
+ options={[{ label: "None (top of page)", value: "" }, ...headings.map((h) => ({ label: h.label, value: h.id }))]}
74
+ onChange={(anchor) => onChange({ kind: "internal", pageId, anchorSectionId: anchor || null, target: value.target })}
75
+ />
76
+ </>
77
+ )}
78
+
79
+ <Select
80
+ label="Opens in"
81
+ value={value.target}
82
+ options={TARGET_OPTIONS}
83
+ onChange={(t) => onChange({ ...value, target: t as "_self" | "_blank" })}
84
+ />
85
+ </div>
86
+ );
87
+ }