@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.
- package/dist/{chunk-P3HO76OS.js → chunk-DLXIYIG2.js} +6 -3
- package/dist/{chunk-5XYUO4HP.js → chunk-ICRCH3GI.js} +19 -2
- package/dist/{chunk-BJ6FYGYP.js → chunk-ONBJG426.js} +95 -9
- package/dist/components/editor/MoveSectionModal.d.ts +12 -0
- package/dist/components/editor/MoveSectionModal.d.ts.map +1 -0
- package/dist/components/editor/PagesModal.d.ts +18 -0
- package/dist/components/editor/PagesModal.d.ts.map +1 -0
- package/dist/components/editor/SectionWrapper.d.ts +1 -1
- package/dist/components/editor/SectionWrapper.d.ts.map +1 -1
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/sections/Button/CTAButton.d.ts +3 -3
- package/dist/components/sections/Button/CTAButton.d.ts.map +1 -1
- package/dist/components/sections/Button/index.d.ts +10 -2
- package/dist/components/sections/Button/index.d.ts.map +1 -1
- package/dist/components/shared/Input.d.ts +1 -0
- package/dist/components/shared/Input.d.ts.map +1 -1
- package/dist/components/shared/LinkField.d.ts +9 -0
- package/dist/components/shared/LinkField.d.ts.map +1 -0
- package/dist/components/shared/Navigation.d.ts +4 -3
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shared/PagesContext.d.ts +13 -0
- package/dist/components/shared/PagesContext.d.ts.map +1 -0
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/index.js +17 -3
- package/dist/lib/dexie.d.ts +1 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +25 -3
- package/dist/lib/events.d.ts +3 -1
- package/dist/lib/events.d.ts.map +1 -1
- package/dist/lib/index.js +2 -2
- package/dist/lib/links.d.ts +14 -0
- package/dist/lib/links.d.ts.map +1 -0
- package/dist/lib/loader.d.ts +2 -2
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/nav.d.ts +23 -0
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts +31 -0
- package/dist/lib/pages.d.ts.map +1 -0
- package/dist/lib/registry.d.ts +7 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +17 -3
- package/dist/schemas/link.d.ts +24 -0
- package/dist/schemas/link.d.ts.map +1 -0
- package/dist/schemas/site-config.d.ts +128 -3
- package/dist/schemas/site-config.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/editor/MoveSectionModal.tsx +38 -0
- package/src/components/editor/PagesModal.tsx +392 -0
- package/src/components/editor/SectionWrapper.tsx +13 -0
- package/src/components/editor/SettingsForm.tsx +12 -0
- package/src/components/sections/Button/CTAButton.tsx +10 -11
- package/src/components/sections/Button/index.tsx +4 -9
- package/src/components/shared/Input.tsx +14 -3
- package/src/components/shared/LinkField.tsx +87 -0
- package/src/components/shared/Navigation.tsx +131 -136
- package/src/components/shared/PagesContext.tsx +12 -0
- package/src/components/shell/EditorShell.tsx +291 -84
- package/src/hooks/useEditorPublish.ts +17 -9
- package/src/hooks/useMediaPipeline.ts +17 -5
- package/src/lib/dexie.ts +34 -5
- package/src/lib/events.ts +3 -1
- package/src/lib/links.ts +41 -0
- package/src/lib/loader.ts +5 -4
- package/src/lib/nav.ts +59 -0
- package/src/lib/pages.ts +209 -0
- package/src/lib/registry.ts +8 -0
- package/src/schemas/index.ts +1 -0
- package/src/schemas/link.ts +17 -0
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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");
|
package/src/lib/links.ts
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
package/src/lib/pages.ts
ADDED
|
@@ -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
|
+
}
|
package/src/lib/registry.ts
CHANGED
|
@@ -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;
|
package/src/schemas/index.ts
CHANGED
|
@@ -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" };
|