@drawnagency/primitives 0.1.48 → 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 (73) 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/hooks/useEditorPublish.d.ts.map +1 -1
  25. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  26. package/dist/index.js +17 -3
  27. package/dist/lib/dexie.d.ts +1 -0
  28. package/dist/lib/dexie.d.ts.map +1 -1
  29. package/dist/lib/dexie.js +25 -3
  30. package/dist/lib/events.d.ts +3 -1
  31. package/dist/lib/events.d.ts.map +1 -1
  32. package/dist/lib/index.js +2 -2
  33. package/dist/lib/links.d.ts +14 -0
  34. package/dist/lib/links.d.ts.map +1 -0
  35. package/dist/lib/loader.d.ts +2 -2
  36. package/dist/lib/loader.d.ts.map +1 -1
  37. package/dist/lib/nav.d.ts +23 -0
  38. package/dist/lib/nav.d.ts.map +1 -1
  39. package/dist/lib/pages.d.ts +31 -0
  40. package/dist/lib/pages.d.ts.map +1 -0
  41. package/dist/lib/registry.d.ts +7 -0
  42. package/dist/lib/registry.d.ts.map +1 -1
  43. package/dist/schemas/index.d.ts +1 -0
  44. package/dist/schemas/index.d.ts.map +1 -1
  45. package/dist/schemas/index.js +17 -3
  46. package/dist/schemas/link.d.ts +24 -0
  47. package/dist/schemas/link.d.ts.map +1 -0
  48. package/dist/schemas/site-config.d.ts +128 -3
  49. package/dist/schemas/site-config.d.ts.map +1 -1
  50. package/package.json +5 -1
  51. package/src/components/editor/MoveSectionModal.tsx +38 -0
  52. package/src/components/editor/PagesModal.tsx +392 -0
  53. package/src/components/editor/SectionWrapper.tsx +13 -0
  54. package/src/components/editor/SettingsForm.tsx +12 -0
  55. package/src/components/sections/Button/CTAButton.tsx +10 -11
  56. package/src/components/sections/Button/index.tsx +4 -9
  57. package/src/components/shared/Input.tsx +14 -3
  58. package/src/components/shared/LinkField.tsx +87 -0
  59. package/src/components/shared/Navigation.tsx +131 -136
  60. package/src/components/shared/PagesContext.tsx +12 -0
  61. package/src/components/shell/EditorShell.tsx +291 -84
  62. package/src/hooks/useEditorPublish.ts +17 -9
  63. package/src/hooks/useMediaPipeline.ts +17 -5
  64. package/src/lib/dexie.ts +34 -5
  65. package/src/lib/events.ts +3 -1
  66. package/src/lib/links.ts +41 -0
  67. package/src/lib/loader.ts +5 -4
  68. package/src/lib/nav.ts +59 -0
  69. package/src/lib/pages.ts +209 -0
  70. package/src/lib/registry.ts +8 -0
  71. package/src/schemas/index.ts +1 -0
  72. package/src/schemas/link.ts +17 -0
  73. package/src/schemas/site-config.ts +113 -11
@@ -2,7 +2,7 @@ import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import type { SiteIndex, SiteConfig } from "../schemas/site-config";
3
3
  import type { LoadedSection } from "../lib/loader";
4
4
  import type { MediaManifest, MediaItem } from "../media/types";
5
- import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, getPendingMediaBlobs, clearPendingMedia } from "../lib/dexie";
5
+ import { getDirtySectionRows, hasLocalChanges, discardSavedChanges, cacheContent, getPendingMediaBlobs, clearPendingMediaByIds } from "../lib/dexie";
6
6
 
7
7
  function blobToBase64(blob: Blob): Promise<string> {
8
8
  return new Promise((resolve, reject) => {
@@ -140,7 +140,7 @@ export function useEditorPublish({
140
140
  pendingMediaDeletions: string[];
141
141
  mediaManifest: MediaManifest;
142
142
  manifestDirty: boolean;
143
- }): Promise<{ sha: string; savedSections: { sectionId: string; updatedAt: string }[] }> {
143
+ }): Promise<{ sha: string; savedSections: { sectionId: string; updatedAt: string }[]; uploadedItems: MediaItem[] }> {
144
144
  const dirty = await getDirtySectionRows();
145
145
  const gathered = await gatherMediaPayload(
146
146
  args.pendingMediaItems,
@@ -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
  }
@@ -199,6 +199,10 @@ export function useEditorPublish({
199
199
  return {
200
200
  sha: responseData.sha as string,
201
201
  savedSections: dirty.map(({ sectionId, updatedAt }) => ({ sectionId, updatedAt })),
202
+ // Only the items whose blobs were actually written to GitHub. Items skipped
203
+ // by gatherMediaPayload (blobs not yet persisted) are excluded so the caller
204
+ // never promotes them into the manifest as orphans.
205
+ uploadedItems: gathered.mediaUploads.map(({ item }) => item),
202
206
  };
203
207
  }
204
208
 
@@ -221,7 +225,7 @@ export function useEditorPublish({
221
225
  return;
222
226
  }
223
227
 
224
- const { sha, savedSections } = await performSave({
228
+ const { sha, savedSections, uploadedItems } = await performSave({
225
229
  siteConfig, siteIndexRef, deletedSectionIds,
226
230
  isConfigDirty, pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
227
231
  });
@@ -230,11 +234,14 @@ export function useEditorPublish({
230
234
  // during the request) and invalidates contentCache so a reload reflects
231
235
  // the just-saved content instead of showing the stale pre-save snapshot.
232
236
  await discardSavedChanges(savedSections);
233
- await clearPendingMedia();
237
+ // Promote/clear only the media that was actually committed. Items skipped
238
+ // because their blobs weren't persisted stay pending and retry next save —
239
+ // promoting them here would create manifest orphans (no backing file).
240
+ await clearPendingMediaByIds(uploadedItems.map((i) => i.id), pendingMediaDeletions);
234
241
  clearConfigDirty();
235
242
  clearManifestDirty();
236
243
  onSuccess();
237
- onMediaPublished(pendingMediaItems, pendingMediaDeletions);
244
+ onMediaPublished(uploadedItems, pendingMediaDeletions);
238
245
  onShasUpdated(sha, null);
239
246
 
240
247
  showFeedback("Saved", 3000);
@@ -295,8 +302,9 @@ export function useEditorPublish({
295
302
  const hasLocalEdits = hasChanges || isConfigDirty() || hasMediaChanges || hasDeletedSections;
296
303
 
297
304
  let savedSections: { sectionId: string; updatedAt: string }[] = [];
305
+ let uploadedItems: MediaItem[] = [];
298
306
  if (hasLocalEdits) {
299
- ({ savedSections } = await performSave({
307
+ ({ savedSections, uploadedItems } = await performSave({
300
308
  siteConfig, siteIndexRef, deletedSectionIds,
301
309
  isConfigDirty, pendingMediaItems, pendingMediaDeletions, mediaManifest, manifestDirty,
302
310
  }));
@@ -322,12 +330,12 @@ export function useEditorPublish({
322
330
 
323
331
  if (hasLocalEdits) {
324
332
  await discardSavedChanges(savedSections);
325
- await clearPendingMedia();
333
+ await clearPendingMediaByIds(uploadedItems.map((i) => i.id), pendingMediaDeletions);
326
334
  await cacheContent(sha, sections, siteIndexRef.current, siteConfig);
327
335
  clearConfigDirty();
328
336
  clearManifestDirty();
329
337
  onSuccess();
330
- onMediaPublished(pendingMediaItems, pendingMediaDeletions);
338
+ onMediaPublished(uploadedItems, pendingMediaDeletions);
331
339
  }
332
340
 
333
341
  onShasUpdated(null, sha);
@@ -96,7 +96,7 @@ export function useMediaPipeline({
96
96
  const kind = event.item.kind;
97
97
  const sanitizedName = sanitizeMediaName(event.item.originalName);
98
98
 
99
- const finalize = (
99
+ const finalize = async (
100
100
  localUrls: Record<string, string>,
101
101
  blobsMap: Record<string, Blob>,
102
102
  width: number,
@@ -121,7 +121,19 @@ export function useMediaPipeline({
121
121
  alt: "",
122
122
  };
123
123
 
124
- addPendingMediaItem(item, localUrls, blobsMap);
124
+ try {
125
+ // Persist blobs to IndexedDB BEFORE marking the item pending.
126
+ // Previously this was fire-and-forget: a Save firing before the
127
+ // write landed — or a failed write (e.g. storage quota) — left the
128
+ // item promoted into the manifest with no backing file, an orphan
129
+ // that dropped out on the next render and blocked re-adding it.
130
+ await addPendingMediaItem(item, localUrls, blobsMap);
131
+ } catch (err) {
132
+ console.error(`[useMediaPipeline] Failed to persist media "${item.originalName}":`, err);
133
+ for (const url of Object.values(localUrls)) URL.revokeObjectURL(url);
134
+ return;
135
+ }
136
+ if (destroyedRef.current) return;
125
137
  removePendingMediaDeletion(item.id);
126
138
  setPendingDeletions((prev) => prev.filter((d) => d !== item.id));
127
139
  setPendingMediaItems((prev) => [...prev, item]);
@@ -156,10 +168,10 @@ export function useMediaPipeline({
156
168
  ({ posterBlob, width, height }) => {
157
169
  blobsMap["poster"] = posterBlob;
158
170
  localUrls["poster"] = URL.createObjectURL(posterBlob);
159
- finalize(localUrls, blobsMap, width, height);
171
+ void finalize(localUrls, blobsMap, width, height);
160
172
  },
161
173
  () => {
162
- finalize(localUrls, blobsMap, result.width, result.height);
174
+ void finalize(localUrls, blobsMap, result.width, result.height);
163
175
  },
164
176
  );
165
177
  } else {
@@ -167,7 +179,7 @@ export function useMediaPipeline({
167
179
  blobsMap["poster"] = result.posterBlob;
168
180
  localUrls["poster"] = URL.createObjectURL(result.posterBlob);
169
181
  }
170
- finalize(localUrls, blobsMap, result.width, result.height);
182
+ void finalize(localUrls, blobsMap, result.width, result.height);
171
183
  }
172
184
  }
173
185
  },
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,
@@ -463,3 +476,19 @@ export async function clearPendingMedia(): Promise<void> {
463
476
  await database.pendingMediaDeletions.clear();
464
477
  });
465
478
  }
479
+
480
+ // Clears only the pending uploads and deletions that were actually committed in
481
+ // a save. Items skipped during the save (e.g. their blobs hadn't finished
482
+ // writing to IndexedDB) are left in place so the next save retries them, instead
483
+ // of being wiped and promoted into the manifest as orphans with no backing file.
484
+ export async function clearPendingMediaByIds(
485
+ itemIds: string[],
486
+ deletionIds: string[],
487
+ ): Promise<void> {
488
+ if (itemIds.length === 0 && deletionIds.length === 0) return;
489
+ const database = getDb();
490
+ await database.transaction("rw", [database.pendingMedia, database.pendingMediaDeletions], async () => {
491
+ if (itemIds.length > 0) await database.pendingMedia.bulkDelete(itemIds);
492
+ if (deletionIds.length > 0) await database.pendingMediaDeletions.bulkDelete(deletionIds);
493
+ });
494
+ }
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,41 @@
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
+ * Rewrite any internal LinkValue embedded in a section's content into a
26
+ * resolved external link so the zero-JS viewer renders a plain <a>. Currently
27
+ * the only link-bearing field is the button section's `content.link`.
28
+ */
29
+ export function resolveInternalLinks(
30
+ section: Section,
31
+ index: SiteIndex,
32
+ headingById: Record<string, string | undefined>,
33
+ ): Section {
34
+ const content = section.content as { link?: LinkValue } | undefined;
35
+ if (!content?.link || content.link.kind !== "internal") return section;
36
+ const resolved = resolveLinkHref(content.link, index, headingById);
37
+ return {
38
+ ...section,
39
+ content: { ...content, link: { kind: "external", href: resolved.href, target: resolved.target } },
40
+ };
41
+ }
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";
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+
3
+ export const LinkTargetSchema = z.enum(["_self", "_blank"]);
4
+
5
+ export const LinkValueSchema = z.discriminatedUnion("kind", [
6
+ z.object({ kind: z.literal("external"), href: z.string(), target: LinkTargetSchema }),
7
+ z.object({
8
+ kind: z.literal("internal"),
9
+ pageId: z.string(),
10
+ anchorSectionId: z.string().nullable().optional(),
11
+ target: LinkTargetSchema,
12
+ }),
13
+ ]);
14
+
15
+ export type LinkValue = z.infer<typeof LinkValueSchema>;
16
+
17
+ export const DEFAULT_LINK: LinkValue = { kind: "external", href: "", target: "_self" };