@drawnagency/primitives 0.1.58 → 0.1.60

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 (88) hide show
  1. package/dist/auth/capabilities.d.ts +16 -0
  2. package/dist/auth/capabilities.d.ts.map +1 -0
  3. package/dist/auth/index.d.ts +2 -0
  4. package/dist/auth/index.d.ts.map +1 -1
  5. package/dist/auth/index.js +3 -1
  6. package/dist/auth/types.d.ts +26 -22
  7. package/dist/auth/types.d.ts.map +1 -1
  8. package/dist/{chunk-ICLXLWQ5.js → chunk-BT7STGDW.js} +31 -23
  9. package/dist/{chunk-L2JJFOXD.js → chunk-CBWK3KPV.js} +5 -1
  10. package/dist/{chunk-V7JN2DDU.js → chunk-ODCJQOVO.js} +1 -1
  11. package/dist/{chunk-AN62WPW7.js → chunk-Q5EEYBMJ.js} +8 -5
  12. package/dist/chunk-TZQPOR5A.js +24 -0
  13. package/dist/{chunk-XTK4BR27.js → chunk-UNWNT52N.js} +13 -0
  14. package/dist/chunk-ZGFAYZWB.js +8 -0
  15. package/dist/components/editor/DropEdgeIndicator.d.ts +8 -0
  16. package/dist/components/editor/DropEdgeIndicator.d.ts.map +1 -0
  17. package/dist/components/editor/PagesModal.d.ts.map +1 -1
  18. package/dist/components/editor/SectionOrderingModal.d.ts.map +1 -1
  19. package/dist/components/primitives/defineHeadingSection.d.ts +28 -0
  20. package/dist/components/primitives/defineHeadingSection.d.ts.map +1 -0
  21. package/dist/components/sections/Colors/index.d.ts.map +1 -1
  22. package/dist/components/sections/Container/index.d.ts.map +1 -1
  23. package/dist/components/sections/IconList/IconList.d.ts +1 -6
  24. package/dist/components/sections/IconList/IconList.d.ts.map +1 -1
  25. package/dist/components/sections/IconList/index.d.ts +11 -0
  26. package/dist/components/sections/IconList/index.d.ts.map +1 -1
  27. package/dist/components/sections/LinkHeading/index.d.ts.map +1 -1
  28. package/dist/components/sections/SubHeading/index.d.ts +0 -1
  29. package/dist/components/sections/SubHeading/index.d.ts.map +1 -1
  30. package/dist/components/sections/SubSubHeading/index.d.ts +0 -1
  31. package/dist/components/sections/SubSubHeading/index.d.ts.map +1 -1
  32. package/dist/components/sections/all-sections.d.ts +0 -2
  33. package/dist/components/sections/all-sections.d.ts.map +1 -1
  34. package/dist/components/sections/register-schemas.js +409 -420
  35. package/dist/components/shared/Navigation.d.ts.map +1 -1
  36. package/dist/components/shell/BugReportFAB.d.ts.map +1 -1
  37. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  38. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  39. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  40. package/dist/hooks/useSortableRow.d.ts +25 -0
  41. package/dist/hooks/useSortableRow.d.ts.map +1 -0
  42. package/dist/index.js +27 -24
  43. package/dist/lib/events.d.ts +1 -0
  44. package/dist/lib/events.d.ts.map +1 -1
  45. package/dist/lib/image-refs.d.ts +21 -0
  46. package/dist/lib/image-refs.d.ts.map +1 -0
  47. package/dist/lib/index.js +8 -7
  48. package/dist/lib/platform-broker.d.ts +14 -0
  49. package/dist/lib/platform-broker.d.ts.map +1 -0
  50. package/dist/lib/platform-broker.js +60 -0
  51. package/dist/lib/reorder.d.ts +12 -0
  52. package/dist/lib/reorder.d.ts.map +1 -0
  53. package/dist/lib/text.d.ts +2 -0
  54. package/dist/lib/text.d.ts.map +1 -1
  55. package/dist/media/index.js +3 -2
  56. package/dist/media/utils.d.ts.map +1 -1
  57. package/dist/schemas/index.js +11 -11
  58. package/dist/types/database.d.ts +46 -0
  59. package/dist/types/database.d.ts.map +1 -1
  60. package/package.json +6 -2
  61. package/src/auth/capabilities.ts +26 -0
  62. package/src/auth/index.ts +3 -0
  63. package/src/auth/types.ts +36 -26
  64. package/src/components/editor/DropEdgeIndicator.tsx +13 -0
  65. package/src/components/editor/PagesModal.tsx +12 -80
  66. package/src/components/editor/SectionOrderingModal.tsx +8 -78
  67. package/src/components/primitives/defineHeadingSection.tsx +56 -0
  68. package/src/components/sections/Colors/index.tsx +2 -4
  69. package/src/components/sections/Container/index.tsx +2 -4
  70. package/src/components/sections/IconList/IconList.tsx +1 -7
  71. package/src/components/sections/IconList/index.tsx +11 -5
  72. package/src/components/sections/LinkHeading/index.tsx +5 -20
  73. package/src/components/sections/SubHeading/index.tsx +6 -23
  74. package/src/components/sections/SubSubHeading/index.tsx +6 -23
  75. package/src/components/shared/Navigation.tsx +6 -4
  76. package/src/components/shell/BugReportFAB.tsx +13 -9
  77. package/src/components/shell/EditorShell.tsx +5 -1
  78. package/src/hooks/useEditorPublish.ts +71 -49
  79. package/src/hooks/useMediaPipeline.ts +11 -34
  80. package/src/hooks/useSortableRow.ts +99 -0
  81. package/src/lib/events.ts +1 -0
  82. package/src/lib/image-refs.ts +53 -0
  83. package/src/lib/platform-broker.ts +72 -0
  84. package/src/lib/reorder.ts +20 -0
  85. package/src/lib/text.ts +5 -0
  86. package/src/media/utils.ts +6 -1
  87. package/src/types/database.ts +41 -0
  88. package/dist/chunk-DKOUFIP6.js +0 -35
package/src/auth/types.ts CHANGED
@@ -21,40 +21,50 @@ export type TokenExchangeResult =
21
21
  | { success: false; error: string };
22
22
 
23
23
  export interface AuthProvider {
24
- capabilities: {
25
- oauth: boolean;
26
- emailPassword: boolean;
27
- passwordOnly: boolean;
28
- userManagement: boolean;
29
- audienceManagement: boolean;
30
- passwordToggle: boolean;
31
- };
32
- oauthProviders?: ("google" | "github")[];
33
-
24
+ // --- Required: every provider authenticates a session. ---
34
25
  resolveSession(ctx: AuthContext): Promise<Session | null>;
35
26
  signIn(method: SignInMethod, ctx: AuthContext): Promise<SignInResult>;
36
27
  signOut(ctx: AuthContext): Promise<void>;
37
28
 
38
- listSiteUsers?(): Promise<SiteUser[]>;
39
- inviteUser?(email: string, role: Role): Promise<void>;
40
- revokeUser?(userId: string): Promise<void>;
41
-
42
- listAudiences(): Promise<Audience[]>;
43
- verifyAudiencePassword(name: string, password: string): Promise<boolean>;
29
+ // --- Declarative sign-in surface (drives the login UI only). ---
30
+ // signIn() is polymorphic and handles all of these; there is no backing method
31
+ // to derive presence from, so these stay declared rather than presence-derived.
32
+ signInMethods: {
33
+ oauth?: ("google" | "github")[]; // non-empty → render OAuth buttons
34
+ emailPassword?: boolean; // render the email + password form
35
+ passwordOnly?: boolean; // single shared-password mode
36
+ };
44
37
 
45
- // Optional single-row lookup (present when the adapter can efficiently query one audience).
46
- getAudience?(name: string): Promise<Audience | null>;
38
+ // --- Viewer access control ("audiences"). Read required; manage optional. ---
39
+ audiences: {
40
+ list(): Promise<Audience[]>;
41
+ verify(name: string, password: string): Promise<boolean>;
42
+ get?(name: string): Promise<Audience | null>; // optional single-row optimization
43
+ // Presence of `manage` === the old capabilities.audienceManagement.
44
+ manage?: {
45
+ create(displayName: string, color: string | null, password: string): Promise<void>;
46
+ setPassword(name: string, password: string): Promise<void>;
47
+ setColor(name: string, color: string | null): Promise<void>;
48
+ setDisplayName(name: string, displayName: string): Promise<void>;
49
+ delete(name: string): Promise<void>;
50
+ };
51
+ };
47
52
 
48
- // Optional present only when capabilities.audienceManagement === true
49
- createAudience?(displayName: string, color: string | null, password: string): Promise<void>;
50
- setAudiencePassword?(name: string, password: string): Promise<void>;
51
- setAudienceColor?(name: string, color: string | null): Promise<void>;
52
- setAudienceDisplayName?(name: string, displayName: string): Promise<void>;
53
- deleteAudience?(name: string): Promise<void>;
53
+ // --- Shared-password toggle. Read required; `set` optional. ---
54
+ // Presence of `set` === the old capabilities.passwordToggle.
55
+ passwordEnabled: {
56
+ get(): Promise<boolean>;
57
+ set?(enabled: boolean): Promise<void>;
58
+ };
54
59
 
55
- getPasswordEnabled(): Promise<boolean>;
56
- setPasswordEnabled?(enabled: boolean): Promise<void>;
60
+ // --- Site user management. Presence === the old capabilities.userManagement. ---
61
+ userManagement?: {
62
+ list(): Promise<SiteUser[]>;
63
+ invite(email: string, role: Role): Promise<void>;
64
+ revoke(userId: string): Promise<void>;
65
+ };
57
66
 
67
+ // --- Provider-specific auth flows (optional; shape unchanged). ---
58
68
  handleCallback?(request: Request, ctx: AuthContext): Promise<Response>;
59
69
  handleTokenExchange?(tokens: { accessToken: string; refreshToken: string }, type: TokenExchangeType, ctx: AuthContext): Promise<TokenExchangeResult>;
60
70
  updatePassword?(password: string, ctx: AuthContext): Promise<void>;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The horizontal insertion line shown at the top or bottom edge of a row during
3
+ * a closest-edge drag-to-reorder, paired with `useSortableRow`'s `closestEdge`.
4
+ */
5
+ export function DropEdgeIndicator({ edge }: { edge: "top" | "bottom" | null }) {
6
+ if (edge === "top") {
7
+ return <div className="absolute top-0 right-0 left-0 z-10 h-0.5 -translate-y-1/2 bg-primary" />;
8
+ }
9
+ if (edge === "bottom") {
10
+ return <div className="absolute right-0 bottom-0 left-0 z-10 h-0.5 translate-y-1/2 bg-primary" />;
11
+ }
12
+ return null;
13
+ }
@@ -1,4 +1,4 @@
1
- import { useState, useEffect, useRef } from "react";
1
+ import { useState, useEffect } from "react";
2
2
  import { Check } from "lucide-react";
3
3
  import type { SiteIndex, Page } from "../../schemas/site-config";
4
4
  import type { Audience } from "../../auth/types";
@@ -7,7 +7,10 @@ import { Input } from "../shared/Input";
7
7
  import { Checkbox } from "../shared/Checkbox";
8
8
  import { AudienceIndicator } from "./AudienceIndicator";
9
9
  import { DragHandle } from "./DragHandle";
10
+ import { useSortableRow } from "../../hooks/useSortableRow";
11
+ import { DropEdgeIndicator } from "./DropEdgeIndicator";
10
12
  import { slugifyPageSlug, uniquePageSlug, pageDisplayTitle } from "../../lib/pages";
13
+ import { pluralize } from "../../lib/text";
11
14
  import { RESERVED_SLUGS } from "../../schemas/site-config";
12
15
  import { cn } from "../../lib/cn";
13
16
 
@@ -111,11 +114,13 @@ function PageRow({
111
114
  onSetFields: (patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>) => void;
112
115
  onSetAudience: (access: string[]) => void; onRequestDelete: () => void;
113
116
  }) {
114
- const rowRef = useRef<HTMLDivElement>(null);
115
- const handleRef = useRef<HTMLButtonElement>(null);
116
- const [isDragging, setIsDragging] = useState(false);
117
- const [closestEdge, setClosestEdge] = useState<"top" | "bottom" | null>(null);
118
117
  const draggable = dragIndex !== undefined;
118
+ const { rowRef, handleRef, isDragging, closestEdge } = useSortableRow<HTMLDivElement, HTMLButtonElement>({
119
+ index: dragIndex ?? 0,
120
+ dragType: "page-ordering-row",
121
+ onReorder,
122
+ enabled: draggable,
123
+ });
119
124
 
120
125
  // The slug input edits a local draft; only valid values commit to the index,
121
126
  // which must always hold a non-empty, unique, non-reserved slug.
@@ -189,85 +194,12 @@ function PageRow({
189
194
  onToggleExpand();
190
195
  };
191
196
 
192
- useEffect(() => {
193
- if (dragIndex === undefined) return;
194
- const row = rowRef.current;
195
- const handle = handleRef.current;
196
- if (!row || !handle) return;
197
-
198
- let cleanup: (() => void) | undefined;
199
- let cancelled = false;
200
-
201
- Promise.all([
202
- import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
203
- import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
204
- ]).then(([{ draggable, dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
205
- if (cancelled) return;
206
-
207
- const cleanupDraggable = draggable({
208
- element: row,
209
- dragHandle: handle,
210
- getInitialData: () => ({ dragType: "page-ordering-row", index: dragIndex }),
211
- onGenerateDragPreview: () => {
212
- row.style.opacity = "0.4";
213
- requestAnimationFrame(() => {
214
- row.style.opacity = "";
215
- });
216
- },
217
- onDragStart: () => setIsDragging(true),
218
- onDrop: () => setIsDragging(false),
219
- });
220
-
221
- const cleanupDropTarget = dropTargetForElements({
222
- element: row,
223
- canDrop: ({ source }) => source.data.dragType === "page-ordering-row",
224
- getData: ({ input, element }) =>
225
- attachClosestEdge({ index: dragIndex }, { input, element, allowedEdges: ["top", "bottom"] }),
226
- onDragEnter: ({ self }) => {
227
- const edge = extractClosestEdge(self.data);
228
- setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
229
- },
230
- onDrag: ({ self }) => {
231
- const edge = extractClosestEdge(self.data);
232
- setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
233
- },
234
- onDragLeave: () => setClosestEdge(null),
235
- onDrop: ({ source, self }) => {
236
- setClosestEdge(null);
237
- const fromIndex = source.data.index as number;
238
- const edge = extractClosestEdge(self.data);
239
- let toIndex = dragIndex;
240
- if (edge === "bottom") toIndex = dragIndex + 1;
241
- if (fromIndex < toIndex) toIndex--;
242
- if (fromIndex !== toIndex) {
243
- onReorder(fromIndex, toIndex);
244
- }
245
- },
246
- });
247
-
248
- cleanup = () => {
249
- cleanupDraggable();
250
- cleanupDropTarget();
251
- };
252
- });
253
-
254
- return () => {
255
- cancelled = true;
256
- cleanup?.();
257
- };
258
- }, [dragIndex, onReorder]);
259
-
260
197
  return (
261
198
  <div
262
199
  ref={rowRef}
263
200
  className={cn("relative rounded-md border border-base-200", isDragging && "opacity-50")}
264
201
  >
265
- {closestEdge === "top" && (
266
- <div className="absolute top-0 right-0 left-0 z-10 h-0.5 -translate-y-1/2 bg-primary" />
267
- )}
268
- {closestEdge === "bottom" && (
269
- <div className="absolute right-0 bottom-0 left-0 z-10 h-0.5 translate-y-1/2 bg-primary" />
270
- )}
202
+ <DropEdgeIndicator edge={closestEdge} />
271
203
  <div className="flex items-center gap-2 px-3 py-2">
272
204
  {draggable && (
273
205
  <div className="shrink-0 [&_[role=tooltip]]:hidden">
@@ -375,7 +307,7 @@ function DeleteConfirm({ page, onCancel, onConfirm }: { page: Page; onCancel: ()
375
307
  <div className="flex flex-col gap-3">
376
308
  <p className="text-sm text-base-contrast">
377
309
  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.
310
+ <strong>{pluralize(n, "section")}</strong>. This is recoverable only via GitHub version history.
379
311
  </p>
380
312
  <Input
381
313
  label={`Type the page name "${confirmName}" to confirm`}
@@ -1,10 +1,11 @@
1
- import { useState, useEffect, useRef } from "react";
2
1
  import type { LoadedSection } from "../../lib/loader";
3
2
  import type { Thumbnail } from "../../lib/registry";
4
3
  import { getSection } from "../../lib/registry";
5
4
  import { getMediaProvider } from "../../media";
6
5
  import type { MediaManifest } from "../../media/types";
7
6
  import { DragHandle } from "./DragHandle";
7
+ import { useSortableRow } from "../../hooks/useSortableRow";
8
+ import { DropEdgeIndicator } from "./DropEdgeIndicator";
8
9
  import { cn } from "../../lib/cn";
9
10
 
10
11
  interface SectionOrderingModalProps {
@@ -34,10 +35,11 @@ function SectionRow({
34
35
  mediaManifest: MediaManifest;
35
36
  onReorder: (fromIndex: number, toIndex: number) => void;
36
37
  }) {
37
- const rowRef = useRef<HTMLDivElement>(null);
38
- const handleRef = useRef<HTMLButtonElement>(null);
39
- const [isDragging, setIsDragging] = useState(false);
40
- const [closestEdge, setClosestEdge] = useState<"top" | "bottom" | null>(null);
38
+ const { rowRef, handleRef, isDragging, closestEdge } = useSortableRow<HTMLDivElement, HTMLButtonElement>({
39
+ index,
40
+ dragType: "ordering-row",
41
+ onReorder,
42
+ });
41
43
 
42
44
  const definition = getSection(section.section.type);
43
45
  const label = definition?.getLabel?.(section.section) ?? definition?.label ?? section.section.type;
@@ -46,73 +48,6 @@ function SectionRow({
46
48
  const thumbnails = allThumbnails.slice(0, 3);
47
49
  const remainingCount = Math.max(0, allThumbnails.length - 3);
48
50
 
49
- useEffect(() => {
50
- const row = rowRef.current;
51
- const handle = handleRef.current;
52
- if (!row || !handle) return;
53
-
54
- let cleanup: (() => void) | undefined;
55
- let cancelled = false;
56
-
57
- Promise.all([
58
- import("@atlaskit/pragmatic-drag-and-drop/element/adapter"),
59
- import("@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"),
60
- ]).then(([{ draggable, dropTargetForElements }, { attachClosestEdge, extractClosestEdge }]) => {
61
- if (cancelled) return;
62
-
63
- const cleanupDraggable = draggable({
64
- element: row,
65
- dragHandle: handle,
66
- getInitialData: () => ({ dragType: "ordering-row", index }),
67
- onGenerateDragPreview: () => {
68
- row.style.opacity = "0.4";
69
- requestAnimationFrame(() => {
70
- row.style.opacity = "";
71
- });
72
- },
73
- onDragStart: () => setIsDragging(true),
74
- onDrop: () => setIsDragging(false),
75
- });
76
-
77
- const cleanupDropTarget = dropTargetForElements({
78
- element: row,
79
- canDrop: ({ source }) => source.data.dragType === "ordering-row",
80
- getData: ({ input, element }) =>
81
- attachClosestEdge({ index }, { input, element, allowedEdges: ["top", "bottom"] }),
82
- onDragEnter: ({ self }) => {
83
- const edge = extractClosestEdge(self.data);
84
- setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
85
- },
86
- onDrag: ({ self }) => {
87
- const edge = extractClosestEdge(self.data);
88
- setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
89
- },
90
- onDragLeave: () => setClosestEdge(null),
91
- onDrop: ({ source, self }) => {
92
- setClosestEdge(null);
93
- const fromIndex = source.data.index as number;
94
- const edge = extractClosestEdge(self.data);
95
- let toIndex = index;
96
- if (edge === "bottom") toIndex = index + 1;
97
- if (fromIndex < toIndex) toIndex--;
98
- if (fromIndex !== toIndex) {
99
- onReorder(fromIndex, toIndex);
100
- }
101
- },
102
- });
103
-
104
- cleanup = () => {
105
- cleanupDraggable();
106
- cleanupDropTarget();
107
- };
108
- });
109
-
110
- return () => {
111
- cancelled = true;
112
- cleanup?.();
113
- };
114
- }, [index, onReorder]);
115
-
116
51
  return (
117
52
  <div
118
53
  ref={rowRef}
@@ -121,12 +56,7 @@ function SectionRow({
121
56
  isDragging && "opacity-50",
122
57
  )}
123
58
  >
124
- {closestEdge === "top" && (
125
- <div className="absolute top-0 right-0 left-0 z-10 h-0.5 -translate-y-1/2 bg-primary" />
126
- )}
127
- {closestEdge === "bottom" && (
128
- <div className="absolute right-0 bottom-0 left-0 z-10 h-0.5 translate-y-1/2 bg-primary" />
129
- )}
59
+ <DropEdgeIndicator edge={closestEdge} />
130
60
 
131
61
  <div className="shrink-0 [&_[role=tooltip]]:hidden">
132
62
  <DragHandle ref={handleRef} />
@@ -0,0 +1,56 @@
1
+ import type { ReactNode } from "react";
2
+ import { z } from "zod";
3
+ import { defineSection } from "../../lib/registry";
4
+ import { HeadingSection } from "./HeadingSection";
5
+
6
+ interface HeadingSectionConfig<T extends string> {
7
+ type: T;
8
+ label: string;
9
+ icon: ReactNode;
10
+ /** Heading level reported to nav generation (one above the rendered tag by design). */
11
+ navRole: "h1" | "h2" | "h3";
12
+ /** The actual HTML tag rendered. */
13
+ tag: "h2" | "h3" | "h4";
14
+ placeholder: string;
15
+ defaultHeading: string;
16
+ /** When true, adds the `excludeFromNav` content field plus its settings toggle. */
17
+ navExclusion?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Factory for the heading section family (link / sub / sub-sub). They differ only
22
+ * in their type tag, label, icon, nav level, rendered tag, placeholder, default
23
+ * text, and whether they expose the nav-exclusion toggle — all three render the
24
+ * shared HeadingSection primitive through the identical edit/view plumbing.
25
+ */
26
+ export function defineHeadingSection<T extends string>(config: HeadingSectionConfig<T>) {
27
+ const content = config.navExclusion
28
+ ? z.object({ heading: z.string(), excludeFromNav: z.boolean().optional() })
29
+ : z.object({ heading: z.string() });
30
+ const schema = z.object({ type: z.literal(config.type), content });
31
+
32
+ return defineSection({
33
+ type: config.type,
34
+ label: config.label,
35
+ icon: config.icon,
36
+ schema,
37
+ navRole: config.navRole,
38
+ component: ({ content, onChange }) => (
39
+ <HeadingSection
40
+ heading={content.content.heading}
41
+ tag={config.tag}
42
+ placeholder={config.placeholder}
43
+ onChange={onChange ? (heading: string) => onChange({ ...content, content: { ...content.content, heading } }) : undefined}
44
+ />
45
+ ),
46
+ defaults: () => ({ type: config.type, content: { heading: config.defaultHeading } }),
47
+ getLabel: (content) => content.content.heading,
48
+ ...(config.navExclusion
49
+ ? {
50
+ settings: {
51
+ excludeFromNav: { type: "checkbox" as const, label: "Exclude from navigation", default: false, target: "content" as const },
52
+ },
53
+ }
54
+ : {}),
55
+ });
56
+ }
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { Palette } from "lucide-react";
4
4
  import { ColorItemSchema } from "../../../schemas/shared";
5
5
  import Colors from "../../brandguide/Colors";
6
+ import { pluralize } from "../../../lib/text";
6
7
 
7
8
  const schema = z.object({
8
9
  type: z.literal("colors"),
@@ -35,10 +36,7 @@ export default defineSection({
35
36
  type: "colors" as const,
36
37
  content: { colors: [{ spaces: [{ hex: "#000000" }] }] },
37
38
  }),
38
- getLabel: (content) => {
39
- const n = content.content.colors.length;
40
- return `${n} color${n === 1 ? "" : "s"}`;
41
- },
39
+ getLabel: (content) => pluralize(content.content.colors.length, "color"),
42
40
  getThumbnails: (content) =>
43
41
  content.content.colors
44
42
  .filter((c) => c.spaces[0]?.hex)
@@ -5,6 +5,7 @@ import { getSectionSchema } from "../../../schemas/sections";
5
5
  import { MAX_CONTAINER_COLUMNS } from "../../../lib/container-grid";
6
6
  import { Container } from "./Container";
7
7
  import { ContainerSettingsForm } from "./ContainerSettingsForm";
8
+ import { pluralize } from "../../../lib/text";
8
9
 
9
10
  const schema = z.object({
10
11
  type: z.literal("container"),
@@ -43,9 +44,6 @@ export default defineSection({
43
44
  type: "container" as const,
44
45
  content: { columns: 1, flow: "row" as const, children: [] },
45
46
  }),
46
- getLabel: (content) => {
47
- const n = content.content.children.length;
48
- return `Container (${n} item${n === 1 ? "" : "s"})`;
49
- },
47
+ getLabel: (content) => `Container (${pluralize(content.content.children.length, "item")})`,
50
48
  settingsForm: ContainerSettingsForm,
51
49
  });
@@ -8,13 +8,7 @@ import { DragHandle, DeleteIcon, AddIcon } from "../../shared/icons";
8
8
  import { CopyPlus, Pencil } from "lucide-react";
9
9
  import { IconButton } from "../../shared/IconButton";
10
10
  import { IconPicker } from "../../primitives/IconPicker";
11
-
12
- interface IconListItem {
13
- label: string;
14
- text: string;
15
- icon?: string;
16
- dodont?: "do" | "dont";
17
- }
11
+ import type { IconListItem } from "./index";
18
12
 
19
13
  interface Props {
20
14
  items: IconListItem[];
@@ -3,11 +3,20 @@ import { z } from "zod";
3
3
  import { List } from "lucide-react";
4
4
  import IconList from "./IconList";
5
5
  import { IconListSettings } from "./IconListSettings";
6
+ import { pluralize } from "../../../lib/text";
7
+
8
+ export const IconListItemSchema = z.object({
9
+ label: z.string(),
10
+ text: z.string(),
11
+ icon: z.string().optional(),
12
+ dodont: z.enum(["do", "dont"]).optional(),
13
+ });
14
+ export type IconListItem = z.infer<typeof IconListItemSchema>;
6
15
 
7
16
  const schema = z.object({
8
17
  type: z.literal("icon_list"),
9
18
  content: z.object({
10
- items: z.array(z.object({ label: z.string(), text: z.string(), icon: z.string().optional(), dodont: z.enum(["do", "dont"]).optional() })),
19
+ items: z.array(IconListItemSchema),
11
20
  }),
12
21
  options: z.object({
13
22
  icon: z.string().nullable().optional(),
@@ -34,9 +43,6 @@ export default defineSection({
34
43
  type: "icon_list" as const,
35
44
  content: { items: [{ label: "", text: "" }] },
36
45
  }),
37
- getLabel: (content) => {
38
- const n = content.content.items.length;
39
- return `${n} icon${n === 1 ? "" : "s"}`;
40
- },
46
+ getLabel: (content) => pluralize(content.content.items.length, "icon"),
41
47
  settingsForm: IconListSettings,
42
48
  });
@@ -1,27 +1,12 @@
1
- import { defineSection } from "../../../lib/registry";
2
- import { z } from "zod";
3
1
  import { Heading1 } from "lucide-react";
4
- import { HeadingSection } from "../../primitives/HeadingSection";
2
+ import { defineHeadingSection } from "../../primitives/defineHeadingSection";
5
3
 
6
- const schema = z.object({
7
- type: z.literal("link_heading"),
8
- content: z.object({ heading: z.string() }),
9
- });
10
-
11
- export default defineSection({
4
+ export default defineHeadingSection({
12
5
  type: "link_heading",
13
6
  label: "Link Heading",
14
7
  icon: <Heading1 size={18} />,
15
- schema,
16
8
  navRole: "h1",
17
- component: ({ content, onChange }) => (
18
- <HeadingSection
19
- heading={content.content.heading}
20
- tag="h2"
21
- placeholder="Section heading"
22
- onChange={onChange ? (heading: string) => onChange({ type: "link_heading", content: { heading } }) : undefined}
23
- />
24
- ),
25
- defaults: () => ({ type: "link_heading" as const, content: { heading: "New Section" } }),
26
- getLabel: (content) => content.content.heading,
9
+ tag: "h2",
10
+ placeholder: "Section heading",
11
+ defaultHeading: "New Section",
27
12
  });
@@ -1,30 +1,13 @@
1
- import { defineSection } from "../../../lib/registry";
2
- import { z } from "zod";
3
1
  import { Heading2 } from "lucide-react";
4
- import { HeadingSection } from "../../primitives/HeadingSection";
2
+ import { defineHeadingSection } from "../../primitives/defineHeadingSection";
5
3
 
6
- const schema = z.object({
7
- type: z.literal("sub_heading"),
8
- content: z.object({ heading: z.string(), excludeFromNav: z.boolean().optional() }),
9
- });
10
-
11
- export default defineSection({
4
+ export default defineHeadingSection({
12
5
  type: "sub_heading",
13
6
  label: "Sub Heading",
14
7
  icon: <Heading2 size={18} />,
15
- schema,
16
8
  navRole: "h2",
17
- component: ({ content, onChange }) => (
18
- <HeadingSection
19
- heading={content.content.heading}
20
- tag="h3"
21
- placeholder="Sub heading"
22
- onChange={onChange ? (heading: string) => onChange({ ...content, content: { ...content.content, heading } }) : undefined}
23
- />
24
- ),
25
- defaults: () => ({ type: "sub_heading" as const, content: { heading: "New Sub Heading" } }),
26
- getLabel: (content) => content.content.heading,
27
- settings: {
28
- excludeFromNav: { type: "checkbox", label: "Exclude from navigation", default: false, target: "content" },
29
- },
9
+ tag: "h3",
10
+ placeholder: "Sub heading",
11
+ defaultHeading: "New Sub Heading",
12
+ navExclusion: true,
30
13
  });
@@ -1,30 +1,13 @@
1
- import { defineSection } from "../../../lib/registry";
2
- import { z } from "zod";
3
1
  import { Heading3 } from "lucide-react";
4
- import { HeadingSection } from "../../primitives/HeadingSection";
2
+ import { defineHeadingSection } from "../../primitives/defineHeadingSection";
5
3
 
6
- const schema = z.object({
7
- type: z.literal("sub_sub_heading"),
8
- content: z.object({ heading: z.string(), excludeFromNav: z.boolean().optional() }),
9
- });
10
-
11
- export default defineSection({
4
+ export default defineHeadingSection({
12
5
  type: "sub_sub_heading",
13
6
  label: "Sub Sub Heading",
14
7
  icon: <Heading3 size={18} />,
15
- schema,
16
8
  navRole: "h3",
17
- component: ({ content, onChange }) => (
18
- <HeadingSection
19
- heading={content.content.heading}
20
- tag="h4"
21
- placeholder="Sub sub heading"
22
- onChange={onChange ? (heading: string) => onChange({ ...content, content: { ...content.content, heading } }) : undefined}
23
- />
24
- ),
25
- defaults: () => ({ type: "sub_sub_heading" as const, content: { heading: "New Sub Sub Heading" } }),
26
- getLabel: (content) => content.content.heading,
27
- settings: {
28
- excludeFromNav: { type: "checkbox", label: "Exclude from navigation", default: false, target: "content" },
29
- },
9
+ tag: "h4",
10
+ placeholder: "Sub sub heading",
11
+ defaultHeading: "New Sub Sub Heading",
12
+ navExclusion: true,
30
13
  });
@@ -3,7 +3,7 @@ import { ChevronRight } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { Toggle } from "./Toggle";
5
5
  import type { SiteNav, NavItem } from "../../lib/nav";
6
- import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, historySelectEvent } from "../../lib/events";
6
+ import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent } from "../../lib/events";
7
7
  import { useActiveHeadings } from "../../hooks/useActiveHeadings";
8
8
  import { formatDate } from "../../lib/timestamp";
9
9
  import { Popover } from "./Popover";
@@ -23,6 +23,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
23
23
  const [isOpen, setIsOpen] = useState(false);
24
24
  const [isEditMode, setIsEditMode] = useState(false);
25
25
  const [currentDarkMode, setCurrentDarkMode] = useState(darkMode);
26
+ const [currentSiteName, setCurrentSiteName] = useState(siteName);
26
27
  const [isDark, setIsDark] = useState(false);
27
28
  const [siteNav, setSiteNav] = useState<SiteNav>(initialNav);
28
29
  const [showHistory, setShowHistory] = useState(false);
@@ -34,7 +35,8 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
34
35
  const unlistenEdit = editModeEvent.listen(({ isEditMode }) => setIsEditMode(isEditMode));
35
36
  const unlistenNav = siteNavChangeEvent.listen((n) => setSiteNav(n));
36
37
  const unlistenDark = darkModeEvent.listen((mode) => setCurrentDarkMode(mode as typeof darkMode));
37
- return () => { unlistenEdit(); unlistenNav(); unlistenDark(); };
38
+ const unlistenName = siteNameChangeEvent.listen((name) => setCurrentSiteName(name));
39
+ return () => { unlistenEdit(); unlistenNav(); unlistenDark(); unlistenName(); };
38
40
  }, []);
39
41
 
40
42
  useEffect(() => {
@@ -191,7 +193,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
191
193
  <>
192
194
  <header className="fixed top-0 left-0 right-0 z-50 bg-base lg:hidden">
193
195
  <div className="mx-auto max-w-screen-xl flex h-16 items-center justify-between px-4">
194
- <span className="text-lg font-bold text-primary">{siteName}</span>
196
+ <span className="text-lg font-bold text-primary">{currentSiteName}</span>
195
197
  <button onClick={() => setIsOpen(!isOpen)} className="cursor-pointer p-2 text-base-contrast" aria-label="Toggle navigation">
196
198
  <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
197
199
  {isOpen
@@ -207,7 +209,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
207
209
  <nav className={cn("fixed top-0 left-0 lg:left-auto z-40 h-full w-64 flex flex-col overflow-y-auto border-r border-base-200 bg-base pt-16 transition-transform lg:translate-x-0 nav-sidebar",
208
210
  isOpen ? "translate-x-0" : "-translate-x-full")}>
209
211
  <div className="hidden px-4 py-4 lg:block">
210
- <span className="text-lg font-bold text-primary">{siteName}</span>
212
+ <span className="text-lg font-bold text-primary">{currentSiteName}</span>
211
213
  </div>
212
214
 
213
215
  <ul className="space-y-1 px-4 py-2">