@drawnagency/primitives 0.1.49 → 0.1.51

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 (80) hide show
  1. package/dist/{chunk-BJ6FYGYP.js → chunk-24SUF2BC.js} +98 -9
  2. package/dist/{chunk-P3HO76OS.js → chunk-KDGYHU36.js} +6 -3
  3. package/dist/{chunk-5XYUO4HP.js → chunk-PUNXQK4M.js} +19 -2
  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/primitives/LinkPopover.d.ts.map +1 -1
  12. package/dist/components/primitives/tiptap-presets.d.ts.map +1 -1
  13. package/dist/components/sections/Button/CTAButton.d.ts +3 -3
  14. package/dist/components/sections/Button/CTAButton.d.ts.map +1 -1
  15. package/dist/components/sections/Button/index.d.ts +10 -2
  16. package/dist/components/sections/Button/index.d.ts.map +1 -1
  17. package/dist/components/shared/Input.d.ts +1 -0
  18. package/dist/components/shared/Input.d.ts.map +1 -1
  19. package/dist/components/shared/LinkField.d.ts +9 -0
  20. package/dist/components/shared/LinkField.d.ts.map +1 -0
  21. package/dist/components/shared/Navigation.d.ts +4 -3
  22. package/dist/components/shared/Navigation.d.ts.map +1 -1
  23. package/dist/components/shared/PagesContext.d.ts +13 -0
  24. package/dist/components/shared/PagesContext.d.ts.map +1 -0
  25. package/dist/components/shared/RadioGroup.d.ts +15 -0
  26. package/dist/components/shared/RadioGroup.d.ts.map +1 -0
  27. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  28. package/dist/components/shell/SiteSettingsDisplay.d.ts.map +1 -1
  29. package/dist/hooks/useBuildStatus.d.ts.map +1 -1
  30. package/dist/index.js +17 -3
  31. package/dist/lib/dexie.d.ts.map +1 -1
  32. package/dist/lib/dexie.js +16 -3
  33. package/dist/lib/events.d.ts +3 -1
  34. package/dist/lib/events.d.ts.map +1 -1
  35. package/dist/lib/index.js +2 -2
  36. package/dist/lib/links.d.ts +25 -0
  37. package/dist/lib/links.d.ts.map +1 -0
  38. package/dist/lib/loader.d.ts +2 -2
  39. package/dist/lib/loader.d.ts.map +1 -1
  40. package/dist/lib/nav.d.ts +23 -0
  41. package/dist/lib/nav.d.ts.map +1 -1
  42. package/dist/lib/pages.d.ts +31 -0
  43. package/dist/lib/pages.d.ts.map +1 -0
  44. package/dist/lib/registry.d.ts +7 -0
  45. package/dist/lib/registry.d.ts.map +1 -1
  46. package/dist/schemas/index.d.ts +1 -0
  47. package/dist/schemas/index.d.ts.map +1 -1
  48. package/dist/schemas/index.js +17 -3
  49. package/dist/schemas/link.d.ts +24 -0
  50. package/dist/schemas/link.d.ts.map +1 -0
  51. package/dist/schemas/site-config.d.ts +129 -3
  52. package/dist/schemas/site-config.d.ts.map +1 -1
  53. package/package.json +5 -1
  54. package/src/components/editor/MoveSectionModal.tsx +36 -0
  55. package/src/components/editor/PagesModal.tsx +400 -0
  56. package/src/components/editor/SectionWrapper.tsx +13 -0
  57. package/src/components/editor/SettingsForm.tsx +12 -0
  58. package/src/components/primitives/LinkPopover.tsx +99 -27
  59. package/src/components/primitives/tiptap-presets.ts +3 -1
  60. package/src/components/sections/Button/CTAButton.tsx +10 -11
  61. package/src/components/sections/Button/index.tsx +4 -9
  62. package/src/components/shared/Input.tsx +14 -3
  63. package/src/components/shared/LinkField.tsx +90 -0
  64. package/src/components/shared/Navigation.tsx +147 -137
  65. package/src/components/shared/PagesContext.tsx +12 -0
  66. package/src/components/shared/RadioGroup.tsx +71 -0
  67. package/src/components/shell/EditorShell.tsx +273 -78
  68. package/src/components/shell/SiteSettingsDisplay.tsx +65 -0
  69. package/src/hooks/useBuildStatus.ts +19 -4
  70. package/src/hooks/useEditorPublish.ts +1 -1
  71. package/src/lib/dexie.ts +18 -5
  72. package/src/lib/events.ts +3 -1
  73. package/src/lib/links.ts +108 -0
  74. package/src/lib/loader.ts +5 -4
  75. package/src/lib/nav.ts +59 -0
  76. package/src/lib/pages.ts +209 -0
  77. package/src/lib/registry.ts +8 -0
  78. package/src/schemas/index.ts +1 -0
  79. package/src/schemas/link.ts +17 -0
  80. package/src/schemas/site-config.ts +119 -11
@@ -1,3 +1,5 @@
1
+ import { useState } from "react";
2
+ import { Button } from "../shared/Button";
1
3
  import { Checkbox } from "../shared/Checkbox";
2
4
  import { ColorPicker } from "../shared/ColorPicker";
3
5
  import { FontPicker } from "../shared/FontPicker";
@@ -19,11 +21,32 @@ const darkModeOptions = [
19
21
  { label: "Optional (viewer toggle)", value: "optional" },
20
22
  ];
21
23
 
24
+ const FAVICON_TYPES = ["image/svg+xml", "image/png"];
25
+ const FAVICON_MAX_BYTES = 100 * 1024;
26
+
22
27
  export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
28
+ const [faviconError, setFaviconError] = useState<string | null>(null);
29
+
23
30
  function update(patch: Partial<SiteConfig>) {
24
31
  onChange({ ...siteConfig, ...patch });
25
32
  }
26
33
 
34
+ function handleFaviconFile(file: File | undefined) {
35
+ if (!file) return;
36
+ if (!FAVICON_TYPES.includes(file.type)) {
37
+ setFaviconError("Favicon must be an SVG or PNG.");
38
+ return;
39
+ }
40
+ if (file.size > FAVICON_MAX_BYTES) {
41
+ setFaviconError("Favicon must be 100KB or smaller.");
42
+ return;
43
+ }
44
+ setFaviconError(null);
45
+ const reader = new FileReader();
46
+ reader.onload = () => update({ favicon: reader.result as string });
47
+ reader.readAsDataURL(file);
48
+ }
49
+
27
50
  function handleColorChange(color: string) {
28
51
  const contrast = deriveContrast(color);
29
52
  onChange({ ...siteConfig, primaryColor: color, primaryContrast: contrast });
@@ -56,6 +79,48 @@ export function SiteSettingsDisplay({ siteConfig, onChange }: Props) {
56
79
  </div>
57
80
  </div>
58
81
 
82
+ <div>
83
+ <FormLabel>Favicon</FormLabel>
84
+ <div className="flex items-center gap-3">
85
+ {siteConfig.favicon && (
86
+ <img
87
+ src={siteConfig.favicon}
88
+ alt="Current favicon"
89
+ className="h-8 w-8 rounded border border-base-200 bg-base-accent object-contain p-1"
90
+ />
91
+ )}
92
+ <label className="inline-flex cursor-pointer items-center rounded border border-base-200 px-3 py-1.5 text-xs font-medium text-base-contrast transition-colors hover:bg-base-accent">
93
+ {siteConfig.favicon ? "Replace" : "Upload"}
94
+ <input
95
+ type="file"
96
+ accept="image/svg+xml,image/png,.svg,.png"
97
+ aria-label="Upload favicon"
98
+ className="sr-only"
99
+ onChange={(e) => {
100
+ handleFaviconFile(e.target.files?.[0]);
101
+ e.target.value = "";
102
+ }}
103
+ />
104
+ </label>
105
+ {siteConfig.favicon && (
106
+ <Button
107
+ variant="ghost"
108
+ tone="destructive"
109
+ size="sm"
110
+ aria-label="Remove favicon"
111
+ onClick={() => {
112
+ setFaviconError(null);
113
+ update({ favicon: null });
114
+ }}
115
+ >
116
+ Remove
117
+ </Button>
118
+ )}
119
+ </div>
120
+ <p className="mt-1 text-xs text-base-contrast/70">SVG or PNG, up to 100KB. Shown in browser tabs.</p>
121
+ {faviconError && <p className="mt-1 text-xs text-red-600">{faviconError}</p>}
122
+ </div>
123
+
59
124
  <Select
60
125
  label="Dark mode"
61
126
  value={siteConfig.darkMode}
@@ -19,6 +19,10 @@ interface BuildStatusResult {
19
19
  const POLL_INTERVAL = 5000;
20
20
  const AUTO_CLEAR_DELAY = 10000;
21
21
  const FADE_DURATION = 1000;
22
+ // A "building" row older than this on initial load is a leftover from a publish
23
+ // whose deploy was never confirmed (e.g. superseded by a later build) — show
24
+ // idle rather than a phantom in-progress publish.
25
+ const STALE_BUILD_MS = 15 * 60 * 1000;
22
26
 
23
27
  export function useBuildStatus(): BuildStatusResult {
24
28
  const [state, setState] = useState<BuildState>("idle");
@@ -34,9 +38,9 @@ export function useBuildStatus(): BuildStatusResult {
34
38
  if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
35
39
  }, []);
36
40
 
37
- const startTimer = useCallback(() => {
41
+ const startTimer = useCallback((initialSeconds = 0) => {
38
42
  stopTimer();
39
- setElapsedSeconds(0);
43
+ setElapsedSeconds(initialSeconds);
40
44
  timerRef.current = setInterval(() => setElapsedSeconds((s) => s + 1), 1000);
41
45
  }, [stopTimer]);
42
46
 
@@ -114,11 +118,22 @@ export function useBuildStatus(): BuildStatusResult {
114
118
  async function check() {
115
119
  const data = await fetchStatus();
116
120
  if (cancelled) return;
117
- handleStatusUpdate(data, true);
118
121
 
119
122
  if (data && data.state === "building") {
123
+ const ageMs = Date.now() - Date.parse(data.updatedAt);
124
+ if (Number.isFinite(ageMs) && ageMs > STALE_BUILD_MS) {
125
+ setState("idle");
126
+ return;
127
+ }
128
+ setState("building");
129
+ // Seed the timer from when the build actually started so the display
130
+ // counts real elapsed time instead of sitting frozen at 0:00.
131
+ startTimer(Number.isFinite(ageMs) ? Math.max(0, Math.floor(ageMs / 1000)) : 0);
120
132
  startPolling();
133
+ return;
121
134
  }
135
+
136
+ handleStatusUpdate(data, true);
122
137
  }
123
138
 
124
139
  check();
@@ -131,7 +146,7 @@ export function useBuildStatus(): BuildStatusResult {
131
146
  if (clearRef.current) clearTimeout(clearRef.current);
132
147
  if (fadeRef.current) clearTimeout(fadeRef.current);
133
148
  };
134
- }, [fetchStatus, handleStatusUpdate, startPolling, stopPolling, stopTimer]);
149
+ }, [fetchStatus, handleStatusUpdate, startPolling, startTimer, stopPolling, stopTimer]);
135
150
 
136
151
  const startTracking = useCallback(() => {
137
152
  if (clearRef.current) { clearTimeout(clearRef.current); clearRef.current = null; }
@@ -158,7 +158,7 @@ export function useEditorPublish({
158
158
  }
159
159
  siteIndex = {
160
160
  ...siteIndex,
161
- order: siteIndex.order.filter((id) => !deleteSet.has(id)),
161
+ pages: siteIndex.pages.map((p) => ({ ...p, order: p.order.filter((id) => !deleteSet.has(id)) })),
162
162
  sections: filteredSections,
163
163
  };
164
164
  }
package/src/lib/dexie.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import Dexie from "dexie";
2
2
  import type { SectionContent } from "../schemas/sections";
3
- import type { SiteIndex, SectionMeta, SiteConfig } from "../schemas/site-config";
3
+ import type { SiteIndex, SectionMeta, SiteConfig, Page } from "../schemas/site-config";
4
4
  import type { LoadedSection } from "./loader";
5
5
  import type { MediaManifest, MediaItem } from "../media/types";
6
6
 
@@ -13,7 +13,7 @@ interface SectionRow {
13
13
 
14
14
  interface SiteIndexRow {
15
15
  key: string;
16
- order: string[];
16
+ pages: Page[];
17
17
  sections: Record<string, SectionMeta>;
18
18
  deletedSections: string[];
19
19
  updatedAt: string;
@@ -123,6 +123,19 @@ class EditorDatabase extends Dexie {
123
123
  }).upgrade((tx) => {
124
124
  return tx.table("pendingMedia").clear();
125
125
  });
126
+ this.version(7).stores({
127
+ sections: "sectionId",
128
+ siteIndex: "key",
129
+ meta: "key",
130
+ siteConfig: "key",
131
+ contentCache: "key",
132
+ mediaManifest: "key",
133
+ pendingMedia: "id",
134
+ pendingMediaDeletions: "id",
135
+ }).upgrade(async (tx) => {
136
+ await tx.table("siteIndex").clear();
137
+ await tx.table("contentCache").clear();
138
+ });
126
139
  }
127
140
  }
128
141
 
@@ -173,7 +186,7 @@ export async function restoreLocalChanges(): Promise<{
173
186
  const siteId = metaRow?.siteId ?? "";
174
187
  return {
175
188
  sections,
176
- siteIndex: { siteId, order: indexRow.order, sections: indexRow.sections },
189
+ siteIndex: { siteId, pages: indexRow.pages, sections: indexRow.sections },
177
190
  siteConfig: configRow?.config,
178
191
  deletedSections: indexRow.deletedSections ?? [],
179
192
  };
@@ -200,7 +213,7 @@ export async function persistSiteIndex(index: SiteIndex, deletedSections: string
200
213
  await database.transaction("rw", [database.siteIndex, database.meta], async () => {
201
214
  await database.siteIndex.put({
202
215
  key: "current",
203
- order: index.order,
216
+ pages: index.pages,
204
217
  sections: index.sections,
205
218
  deletedSections,
206
219
  updatedAt: now,
@@ -323,7 +336,7 @@ export async function persistAll(
323
336
  if (siteIndex) {
324
337
  await database.siteIndex.put({
325
338
  key: "current",
326
- order: siteIndex.order,
339
+ pages: siteIndex.pages,
327
340
  sections: siteIndex.sections,
328
341
  deletedSections: [],
329
342
  updatedAt: now,
package/src/lib/events.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { NavItem } from "./nav";
1
+ import type { NavItem, SiteNav } from "./nav";
2
2
 
3
3
  export interface TypedEvent<T> {
4
4
  dispatch(detail: T): void;
@@ -22,3 +22,5 @@ export const editModeEvent = createEvent<{ isEditMode: boolean }>("editmodechang
22
22
  export const navChangeEvent = createEvent<NavItem[]>("sitenavchange");
23
23
  export const darkModeEvent = createEvent<string>("sitedarkmode");
24
24
  export const historySelectEvent = createEvent<{ sha: string; date: string }>("history-select");
25
+ export const siteNavChangeEvent = createEvent<SiteNav>("sitenavchange-v2");
26
+ export const pageSelectEvent = createEvent<string>("pageselect");
@@ -0,0 +1,108 @@
1
+ import type { SiteIndex } from "../schemas/site-config";
2
+ import type { Section } from "../schemas/sections";
3
+ import type { LinkValue } from "../schemas/link";
4
+ import { pageById } from "./pages";
5
+ import { toSectionId } from "./nav";
6
+
7
+ export function resolveLinkHref(
8
+ link: LinkValue,
9
+ index: SiteIndex,
10
+ headingById: Record<string, string | undefined>,
11
+ ): { href: string; target: string } {
12
+ if (link.kind === "external") return { href: link.href, target: link.target };
13
+
14
+ const page = pageById(index, link.pageId);
15
+ if (!page) return { href: "", target: link.target };
16
+ let href = page.slug === "" ? "/" : `/${page.slug}`;
17
+ if (link.anchorSectionId) {
18
+ const heading = headingById[link.anchorSectionId];
19
+ if (heading) href += `#${toSectionId(heading)}`;
20
+ }
21
+ return { href, target: link.target };
22
+ }
23
+
24
+ /**
25
+ * Stable internal-link href stored inside rich text (TipTap link marks):
26
+ * `page://{pageId}` or `page://{pageId}#{anchorSectionId}`. Page ids survive
27
+ * slug renames; the SSR pass rewrites these to real routes for the viewer.
28
+ */
29
+ export function internalHref(pageId: string, anchorSectionId?: string | null): string {
30
+ return `page://${pageId}${anchorSectionId ? `#${anchorSectionId}` : ""}`;
31
+ }
32
+
33
+ export function parseInternalHref(
34
+ href: string,
35
+ ): { pageId: string; anchorSectionId: string | null } | null {
36
+ const m = /^page:\/\/([^#]+)(?:#(.+))?$/.exec(href);
37
+ return m ? { pageId: m[1], anchorSectionId: m[2] ?? null } : null;
38
+ }
39
+
40
+ function resolveHtmlString(
41
+ html: string,
42
+ index: SiteIndex,
43
+ headingById: Record<string, string | undefined>,
44
+ ): string {
45
+ return html.replace(/href="page:\/\/([^"]*)"/g, (_match, ref: string) => {
46
+ const hash = ref.indexOf("#");
47
+ const pageId = hash === -1 ? ref : ref.slice(0, hash);
48
+ const anchorSectionId = hash === -1 ? null : ref.slice(hash + 1) || null;
49
+ const { href } = resolveLinkHref(
50
+ { kind: "internal", pageId, anchorSectionId, target: "_self" },
51
+ index,
52
+ headingById,
53
+ );
54
+ return `href="${href}"`;
55
+ });
56
+ }
57
+
58
+ function isInternalLinkValue(value: Record<string, unknown>): value is LinkValue & { kind: "internal" } {
59
+ return value.kind === "internal" && typeof value.pageId === "string";
60
+ }
61
+
62
+ /**
63
+ * Rewrite any internal link embedded in a section's content into a resolved
64
+ * plain href so the zero-JS viewer renders a normal <a>. Covers structured
65
+ * LinkValue fields (button) and `page://` hrefs inside rich text HTML (prose,
66
+ * list items — any string field).
67
+ */
68
+ export function resolveInternalLinks(
69
+ section: Section,
70
+ index: SiteIndex,
71
+ headingById: Record<string, string | undefined>,
72
+ ): Section {
73
+ // Copy-on-write: untouched subtrees keep their original references so the
74
+ // per-request SSR pass allocates nothing for the common no-links case.
75
+ const walk = (value: unknown): unknown => {
76
+ if (typeof value === "string") {
77
+ return value.includes("page://") ? resolveHtmlString(value, index, headingById) : value;
78
+ }
79
+ if (Array.isArray(value)) {
80
+ let changed = false;
81
+ const next = value.map((v) => {
82
+ const w = walk(v);
83
+ if (w !== v) changed = true;
84
+ return w;
85
+ });
86
+ return changed ? next : value;
87
+ }
88
+ if (value !== null && typeof value === "object") {
89
+ const obj = value as Record<string, unknown>;
90
+ if (isInternalLinkValue(obj)) {
91
+ const resolved = resolveLinkHref(obj, index, headingById);
92
+ return { kind: "external", href: resolved.href, target: resolved.target };
93
+ }
94
+ let changed = false;
95
+ const result: Record<string, unknown> = {};
96
+ for (const [key, v] of Object.entries(obj)) {
97
+ const w = walk(v);
98
+ result[key] = w;
99
+ if (w !== v) changed = true;
100
+ }
101
+ return changed ? result : value;
102
+ }
103
+ return value;
104
+ };
105
+
106
+ const content = walk(section.content);
107
+ return content === section.content ? section : ({ ...section, content } as Section);
108
+ }
package/src/lib/loader.ts CHANGED
@@ -15,7 +15,7 @@ export interface SiteContent {
15
15
  /**
16
16
  * Merge a validated index with raw section file contents.
17
17
  * Validates each section file against its Zod schema.
18
- * Returns sections in the order specified by index.order.
18
+ * Returns sections in the concatenated order of every page's order.
19
19
  */
20
20
  export function mergeSiteContent(
21
21
  index: SiteIndex,
@@ -26,7 +26,8 @@ export function mergeSiteContent(
26
26
  const canValidate = getAllSchemas().length >= 2;
27
27
  const schema = canValidate ? getSectionSchema() : null;
28
28
 
29
- for (const id of index.order) {
29
+ const orderedIds = index.pages.flatMap((p) => p.order);
30
+ for (const id of orderedIds) {
30
31
  const raw = sectionFiles[id];
31
32
  if (!raw) {
32
33
  console.warn(`Section file missing for id: ${id}, skipping`);
@@ -53,7 +54,7 @@ export function mergeSiteContent(
53
54
  /**
54
55
  * Build a SiteContent from a Vite `import.meta.glob` result.
55
56
  * Callers glob the section JSON files at build time, pass the parsed index, and
56
- * receive sections merged in index.order.
57
+ * receive sections merged across every page's order.
57
58
  */
58
59
  export function loadStaticSiteContent(
59
60
  index: SiteIndex,
@@ -81,7 +82,7 @@ export async function loadSiteContent(contentDir: string): Promise<SiteContent>
81
82
  const index = IndexSchema.parse(indexRaw);
82
83
 
83
84
  const sectionFiles: Record<string, unknown> = {};
84
- for (const id of index.order) {
85
+ for (const id of index.pages.flatMap((p: SiteIndex["pages"][number]) => p.order)) {
85
86
  const sectionPath = path.join(contentDir, "sections", `${id}.json`);
86
87
  sectionFiles[id] = JSON.parse(await fs.readFile(sectionPath, "utf-8"));
87
88
  }
package/src/lib/nav.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { LoadedSection } from "./loader";
2
2
  import { getSection, type SectionRegistry } from "./registry";
3
+ import { audiencePasses, homePage, pageDisplayTitle } from "./pages";
4
+ import type { SiteIndex } from "../schemas/site-config";
3
5
 
4
6
  export interface NavItem {
5
7
  href: string;
@@ -81,3 +83,60 @@ export function generateNavLinks(
81
83
 
82
84
  return nav;
83
85
  }
86
+
87
+ export interface PageNavItem {
88
+ id: string;
89
+ title: string;
90
+ href: string;
91
+ slug: string;
92
+ isActive: boolean;
93
+ headings: NavItem[];
94
+ }
95
+
96
+ export interface SiteNav {
97
+ pages: PageNavItem[];
98
+ /** Live pages with showInNav off — editor-only group, like the archive. */
99
+ hidden: PageNavItem[];
100
+ archive: PageNavItem[];
101
+ }
102
+
103
+ function pageHref(slug: string): string {
104
+ return slug === "" ? "/" : `/${slug}`;
105
+ }
106
+
107
+ export function generateSiteNav(opts: {
108
+ index: SiteIndex;
109
+ activeSections: LoadedSection[];
110
+ activePageId: string | null;
111
+ mode: "viewer" | "editor";
112
+ audience: string | null;
113
+ registry?: SectionRegistry;
114
+ }): SiteNav {
115
+ const { index, activeSections, activePageId, mode, audience, registry } = opts;
116
+ const active = activePageId ?? homePage(index).id;
117
+
118
+ const toNavItem = (p: (typeof index.pages)[number]): PageNavItem => ({
119
+ id: p.id,
120
+ title: pageDisplayTitle(p.title),
121
+ href: pageHref(p.slug),
122
+ slug: p.slug,
123
+ isActive: p.id === active,
124
+ headings: p.id === active ? generateNavLinks(activeSections, registry) : [],
125
+ });
126
+
127
+ if (mode === "editor") {
128
+ return {
129
+ pages: index.pages.filter((p) => p.status === "live" && p.showInNav).map(toNavItem),
130
+ hidden: index.pages.filter((p) => p.status === "live" && !p.showInNav).map(toNavItem),
131
+ archive: index.pages.filter((p) => p.status === "archived").map(toNavItem),
132
+ };
133
+ }
134
+
135
+ return {
136
+ pages: index.pages
137
+ .filter((p) => p.status === "live" && p.showInNav && audiencePasses(p.access, audience))
138
+ .map(toNavItem),
139
+ hidden: [],
140
+ archive: [],
141
+ };
142
+ }
@@ -0,0 +1,209 @@
1
+ import type { Page, SiteIndex, SectionMeta } from "../schemas/site-config";
2
+ import { RESERVED_SLUGS } from "../schemas/site-config";
3
+
4
+ /**
5
+ * Display fallback for blank page titles. addPage creates pages with an empty
6
+ * title (the editor prompts for one), and closing the modal can leave it blank
7
+ * — every UI that renders a page title must go through this.
8
+ */
9
+ export function pageDisplayTitle(title: string): string {
10
+ return title || "Untitled";
11
+ }
12
+
13
+ export function slugifyPageSlug(input: string): string {
14
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
15
+ }
16
+
17
+ export function uniquePageSlug(base: string, existing: string[]): string {
18
+ const taken = new Set(existing);
19
+ const reserved = new Set<string>(RESERVED_SLUGS);
20
+ let candidate = base || "page";
21
+ if (!taken.has(candidate) && !reserved.has(candidate)) return candidate;
22
+ let n = 2;
23
+ while (taken.has(`${candidate}-${n}`) || reserved.has(`${candidate}-${n}`)) n++;
24
+ return `${candidate}-${n}`;
25
+ }
26
+
27
+ export function pageById(index: SiteIndex, id: string): Page | undefined {
28
+ return index.pages.find((p) => p.id === id);
29
+ }
30
+
31
+ export function homePage(index: SiteIndex): Page {
32
+ return index.pages.find((p) => p.isHome) ?? index.pages[0];
33
+ }
34
+
35
+ export function pageBySlug(index: SiteIndex, slug: string): Page | undefined {
36
+ const normalized = slug.replace(/^\/+|\/+$/g, "");
37
+ if (normalized === "") return homePage(index);
38
+ return index.pages.find((p) => p.slug === normalized);
39
+ }
40
+
41
+ export function audiencePasses(access: string[], audience: string | null): boolean {
42
+ if (access.length === 0) return true;
43
+ if (!audience) return true;
44
+ return access.includes(audience);
45
+ }
46
+
47
+ function mapPages(index: SiteIndex, fn: (p: Page) => Page): SiteIndex {
48
+ return { ...index, pages: index.pages.map(fn) };
49
+ }
50
+
51
+ export function addPage(index: SiteIndex, id: string): SiteIndex {
52
+ // Title starts blank (the editor prompts for it); the slug must stay valid and
53
+ // unique in the stored index, so it gets a placeholder until the title is typed.
54
+ const slug = uniquePageSlug("new-page", index.pages.map((p) => p.slug));
55
+ const page: Page = {
56
+ id, title: "", slug, isHome: false, showInNav: true, status: "live", access: [], order: [],
57
+ };
58
+ return { ...index, pages: [...index.pages, page] };
59
+ }
60
+
61
+ export function reorderPages(index: SiteIndex, fromIndex: number, toIndex: number): SiteIndex {
62
+ const len = index.pages.length;
63
+ if (fromIndex < 0 || fromIndex >= len || toIndex < 0 || toIndex >= len) return index;
64
+ const pages = [...index.pages];
65
+ const [moved] = pages.splice(fromIndex, 1);
66
+ pages.splice(toIndex, 0, moved);
67
+ return { ...index, pages };
68
+ }
69
+
70
+ export function setHomePage(index: SiteIndex, pageId: string): SiteIndex {
71
+ const current = homePage(index);
72
+ if (current.id === pageId) return index;
73
+ if (!pageById(index, pageId)) return index;
74
+ const existingSlugs = index.pages.map((p) => p.slug);
75
+ const demotedSlug = uniquePageSlug(slugifyPageSlug(current.title) || "home", existingSlugs.filter((s) => s !== ""));
76
+ return mapPages(index, (p) => {
77
+ if (p.id === pageId) return { ...p, isHome: true, slug: "" };
78
+ if (p.id === current.id) return { ...p, isHome: false, slug: demotedSlug };
79
+ return { ...p, isHome: false };
80
+ });
81
+ }
82
+
83
+ export function setPageArchived(index: SiteIndex, pageId: string, archived: boolean): SiteIndex {
84
+ return mapPages(index, (p) => {
85
+ if (p.id !== pageId) return p;
86
+ if (p.isHome && archived) return p;
87
+ return { ...p, status: archived ? "archived" : "live" };
88
+ });
89
+ }
90
+
91
+ export function setPageFields(
92
+ index: SiteIndex,
93
+ pageId: string,
94
+ patch: Partial<Pick<Page, "title" | "slug" | "showInNav">>,
95
+ ): SiteIndex {
96
+ return mapPages(index, (p) => (p.id === pageId ? { ...p, ...patch } : p));
97
+ }
98
+
99
+ export function setPageAudience(
100
+ index: SiteIndex,
101
+ pageId: string,
102
+ access: string[],
103
+ ): { index: SiteIndex; resetSectionIds: string[] } {
104
+ const page = pageById(index, pageId);
105
+ if (!page) return { index, resetSectionIds: [] };
106
+ const removed = page.access.filter((a) => !access.includes(a));
107
+ const nextPages = index.pages.map((p) => (p.id === pageId ? { ...p, access } : p));
108
+
109
+ const resetSectionIds: string[] = [];
110
+ const nextSections: Record<string, SectionMeta> = { ...index.sections };
111
+ if (removed.length > 0) {
112
+ for (const sectionId of page.order) {
113
+ const meta = nextSections[sectionId];
114
+ if (meta && meta.access.some((a) => removed.includes(a))) {
115
+ nextSections[sectionId] = { ...meta, access: [] };
116
+ resetSectionIds.push(sectionId);
117
+ }
118
+ }
119
+ }
120
+ return { index: { ...index, pages: nextPages, sections: nextSections }, resetSectionIds };
121
+ }
122
+
123
+ export function deletePage(
124
+ index: SiteIndex,
125
+ pageId: string,
126
+ ): { index: SiteIndex; removedSectionIds: string[] } {
127
+ const page = pageById(index, pageId);
128
+ if (!page || page.isHome) return { index, removedSectionIds: [] };
129
+ const removedSectionIds = [...page.order];
130
+ const nextSections: Record<string, SectionMeta> = { ...index.sections };
131
+ for (const id of removedSectionIds) delete nextSections[id];
132
+ return {
133
+ index: { ...index, pages: index.pages.filter((p) => p.id !== pageId), sections: nextSections },
134
+ removedSectionIds,
135
+ };
136
+ }
137
+
138
+ export function addSectionToPage(
139
+ index: SiteIndex,
140
+ pageId: string,
141
+ sectionId: string,
142
+ meta: SectionMeta,
143
+ insertIndex: number,
144
+ ): SiteIndex {
145
+ const pages = index.pages.map((p) => {
146
+ if (p.id !== pageId) return p;
147
+ const order = [...p.order];
148
+ order.splice(insertIndex, 0, sectionId);
149
+ return { ...p, order };
150
+ });
151
+ return { ...index, pages, sections: { ...index.sections, [sectionId]: meta } };
152
+ }
153
+
154
+ export function removeSectionFromPages(index: SiteIndex, sectionId: string): SiteIndex {
155
+ const pages = index.pages.map((p) =>
156
+ p.order.includes(sectionId) ? { ...p, order: p.order.filter((id) => id !== sectionId) } : p,
157
+ );
158
+ const sections = { ...index.sections };
159
+ delete sections[sectionId];
160
+ return { ...index, pages, sections };
161
+ }
162
+
163
+ export function reorderSectionInPage(
164
+ index: SiteIndex,
165
+ pageId: string,
166
+ fromIndex: number,
167
+ toIndex: number,
168
+ ): SiteIndex {
169
+ const pages = index.pages.map((p) => {
170
+ if (p.id !== pageId) return p;
171
+ const len = p.order.length;
172
+ if (fromIndex < 0 || fromIndex >= len || toIndex < 0 || toIndex >= len) return p;
173
+ const order = [...p.order];
174
+ const [moved] = order.splice(fromIndex, 1);
175
+ order.splice(toIndex, 0, moved);
176
+ return { ...p, order };
177
+ });
178
+ return { ...index, pages };
179
+ }
180
+
181
+ export function moveSection(
182
+ index: SiteIndex,
183
+ sectionId: string,
184
+ destPageId: string,
185
+ position: "top" | "bottom",
186
+ ): SiteIndex {
187
+ const dest = pageById(index, destPageId);
188
+ if (!dest) return index;
189
+ if (!index.sections[sectionId]) return index;
190
+ const pages = index.pages.map((p) => {
191
+ if (p.order.includes(sectionId)) p = { ...p, order: p.order.filter((id) => id !== sectionId) };
192
+ if (p.id === destPageId) {
193
+ const order = [...p.order];
194
+ if (position === "top") order.unshift(sectionId);
195
+ else order.push(sectionId);
196
+ p = { ...p, order };
197
+ }
198
+ return p;
199
+ });
200
+
201
+ // Cascade: if the destination declares audiences and the section carries any
202
+ // audience not allowed there, reset it to "No Audience".
203
+ let sections = index.sections;
204
+ const meta = index.sections[sectionId];
205
+ if (meta && dest.access.length > 0 && meta.access.some((a) => !dest.access.includes(a))) {
206
+ sections = { ...index.sections, [sectionId]: { ...meta, access: [] } };
207
+ }
208
+ return { ...index, pages, sections };
209
+ }
@@ -1,6 +1,7 @@
1
1
  import type { ZodType, z } from "zod";
2
2
  import type { ComponentType, ReactNode } from "react";
3
3
  import type { Audience } from "../schemas/auth";
4
+ import type { LinkValue } from "../schemas/link";
4
5
 
5
6
  // --- Settings field types ---
6
7
 
@@ -42,6 +43,12 @@ export type SettingsFieldDef =
42
43
  min: number;
43
44
  max: number;
44
45
  step?: number;
46
+ }
47
+ | {
48
+ type: "link";
49
+ label: string;
50
+ default: LinkValue;
51
+ target?: "content" | "options";
45
52
  };
46
53
 
47
54
  export type SettingsSchema = Record<string, SettingsFieldDef>;
@@ -82,6 +89,7 @@ export interface WrapperProps {
82
89
  onReorder?: (fromIndex: number, toIndex: number) => void;
83
90
  onRequestInsert?: (index: number) => void;
84
91
  onDelete?: () => void;
92
+ onMoveSection?: () => void;
85
93
  mainStatus?: string | null;
86
94
  contentDiffersFromMain?: boolean;
87
95
  isLocalOnly?: boolean;
@@ -5,3 +5,4 @@ export * from "./media";
5
5
  export * from "./audience";
6
6
  export * from "./media-grid-options";
7
7
  export * from "./auth";
8
+ export * from "./link";