@drawnagency/primitives 0.1.98 → 0.1.100
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-EUSWEVOA.js → chunk-3TO4RRHY.js} +12 -1
- package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
- package/dist/chunk-BS5AQHZG.js +45 -0
- package/dist/{chunk-NA5EJM6Z.js → chunk-CZLYECHC.js} +19 -8
- package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
- package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
- package/dist/{chunk-HJSE6SK5.js → chunk-REDDTTGJ.js} +3 -3
- package/dist/collab/rebase.d.ts.map +1 -1
- package/dist/collab/rebase.js +3 -1
- package/dist/components/chat/ChatPanel.d.ts.map +1 -1
- package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
- package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
- package/dist/components/docfile/DocFilePane.d.ts +17 -0
- package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
- package/dist/components/docfile/doc-file-state.d.ts +33 -0
- package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
- package/dist/components/editor/SettingsForm.d.ts.map +1 -1
- package/dist/components/editor/index.js +1 -1
- package/dist/components/sections/Document/index.d.ts +18 -0
- package/dist/components/sections/Document/index.d.ts.map +1 -0
- package/dist/components/sections/all-sections.d.ts +10 -0
- package/dist/components/sections/all-sections.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.d.ts +10 -0
- package/dist/components/sections/brand-guide.d.ts.map +1 -1
- package/dist/components/sections/brand-guide.js +3 -1
- package/dist/components/sections/register-schemas.js +5 -3
- package/dist/components/sections/register.js +5 -3
- package/dist/components/shared/DocumentSourceField.d.ts +13 -0
- package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
- package/dist/components/shared/Navigation.d.ts.map +1 -1
- package/dist/components/shell/EditorModal.d.ts.map +1 -1
- package/dist/components/shell/EditorShell.d.ts.map +1 -1
- package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
- package/dist/components/shell/MediaFolderTile.d.ts +3 -1
- package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
- package/dist/components/shell/MediaFolderTree.d.ts +3 -0
- package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryContext.d.ts +18 -1
- package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
- package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
- package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
- package/dist/hooks/useDocumentUploads.d.ts +32 -0
- package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
- package/dist/hooks/useEditorPublish.d.ts +23 -1
- package/dist/hooks/useEditorPublish.d.ts.map +1 -1
- package/dist/hooks/useMediaPipeline.d.ts +14 -1
- package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
- package/dist/hooks/useResolvedDocument.d.ts +12 -0
- package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
- package/dist/index.js +60 -34
- package/dist/lib/block-move.d.ts.map +1 -1
- package/dist/lib/dexie.d.ts +11 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +32 -0
- package/dist/lib/document-refs.d.ts +22 -0
- package/dist/lib/document-refs.d.ts.map +1 -0
- package/dist/lib/document-uploads.d.ts +38 -0
- package/dist/lib/document-uploads.d.ts.map +1 -0
- package/dist/lib/documents.d.ts +65 -0
- package/dist/lib/documents.d.ts.map +1 -0
- package/dist/lib/index-ops.d.ts +1 -0
- package/dist/lib/index-ops.d.ts.map +1 -1
- package/dist/lib/index-ops.js +13 -1
- package/dist/lib/index.js +6 -6
- package/dist/lib/loader.d.ts +6 -0
- package/dist/lib/loader.d.ts.map +1 -1
- package/dist/lib/media-folders.d.ts +8 -0
- package/dist/lib/media-folders.d.ts.map +1 -1
- package/dist/lib/media-folders.js +2 -0
- package/dist/lib/nav.d.ts +29 -1
- package/dist/lib/nav.d.ts.map +1 -1
- package/dist/lib/pages.d.ts +18 -0
- package/dist/lib/pages.d.ts.map +1 -1
- package/dist/lib/platform-broker.d.ts +12 -0
- package/dist/lib/platform-broker.d.ts.map +1 -1
- package/dist/lib/platform-broker.js +12 -3
- package/dist/lib/registry.d.ts +10 -0
- package/dist/lib/registry.d.ts.map +1 -1
- package/dist/lib/save-chunks.d.ts +15 -0
- package/dist/lib/save-chunks.d.ts.map +1 -1
- package/dist/lib/scroll-lock.d.ts +2 -0
- package/dist/lib/scroll-lock.d.ts.map +1 -0
- package/dist/schemas/documents.d.ts +80 -0
- package/dist/schemas/documents.d.ts.map +1 -0
- package/dist/schemas/documents.js +25 -0
- package/dist/schemas/index.d.ts +1 -0
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +24 -2
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +5 -1
- package/dist/storage/types.d.ts +69 -5
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/types/database.d.ts +14 -0
- package/dist/types/database.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/collab/rebase.ts +15 -0
- package/src/components/chat/ChatPanel.tsx +5 -4
- package/src/components/docfile/DocFileOverlay.tsx +54 -0
- package/src/components/docfile/DocFilePane.tsx +129 -0
- package/src/components/docfile/doc-file-state.ts +180 -0
- package/src/components/editor/SettingsForm.tsx +10 -0
- package/src/components/sections/Document/index.tsx +163 -0
- package/src/components/sections/brand-guide.ts +2 -1
- package/src/components/shared/DocumentSourceField.tsx +51 -0
- package/src/components/shared/Navigation.tsx +53 -10
- package/src/components/shell/EditorModal.tsx +2 -5
- package/src/components/shell/EditorShell.tsx +154 -10
- package/src/components/shell/MediaBreadcrumb.tsx +14 -1
- package/src/components/shell/MediaFolderTile.tsx +4 -2
- package/src/components/shell/MediaFolderTree.tsx +20 -3
- package/src/components/shell/MediaLibraryContext.tsx +18 -1
- package/src/components/shell/MediaLibraryModal.tsx +223 -45
- package/src/hooks/useDocumentUploads.ts +330 -0
- package/src/hooks/useEditorPublish.ts +197 -11
- package/src/hooks/useMediaPipeline.ts +136 -5
- package/src/hooks/useResolvedDocument.ts +25 -0
- package/src/lib/block-move.ts +15 -4
- package/src/lib/dexie.ts +54 -0
- package/src/lib/document-refs.ts +83 -0
- package/src/lib/document-uploads.ts +66 -0
- package/src/lib/documents.ts +138 -0
- package/src/lib/index-ops.ts +16 -0
- package/src/lib/loader.ts +32 -11
- package/src/lib/media-folders.ts +9 -0
- package/src/lib/nav.ts +46 -2
- package/src/lib/pages.ts +48 -0
- package/src/lib/platform-broker.ts +18 -3
- package/src/lib/registry.ts +9 -0
- package/src/lib/save-chunks.ts +42 -0
- package/src/lib/scroll-lock.ts +27 -0
- package/src/schemas/documents.ts +82 -0
- package/src/schemas/index.ts +1 -0
- package/src/schemas/nav.ts +3 -3
- package/src/storage/index.ts +2 -2
- package/src/storage/types.ts +71 -5
- package/src/types/database.ts +14 -0
- /package/dist/{chunk-FOY5TF7D.js → chunk-ZDQ3TT47.js} +0 -0
package/src/lib/block-move.ts
CHANGED
|
@@ -3,7 +3,16 @@ import type { Section } from "../schemas/sections";
|
|
|
3
3
|
import type { SiteIndex, SectionMeta } from "../schemas/site-config";
|
|
4
4
|
import { ROOT_CONTAINER_ID, canDropBlock } from "./block-dnd";
|
|
5
5
|
import { getContainerChildren, reorderChild, insertChildAt, removeChildAt, placeBlockAtColumn, trimTrailingSpacers } from "./container-ops";
|
|
6
|
-
import {
|
|
6
|
+
import { reorderVisibleSectionInPage, visibleInsertPosition, addSectionToPage, removeSectionFromPages } from "./pages";
|
|
7
|
+
|
|
8
|
+
/** The root list the canvas actually renders: page order filtered to loaded
|
|
9
|
+
* bodies. Drag indices refer to THIS list, not to `page.order` — a section
|
|
10
|
+
* whose body the loader dropped (unparseable file, schema miss) is invisible
|
|
11
|
+
* but still holds its order slot. */
|
|
12
|
+
function visibleRootIds(index: SiteIndex, rootPageId: string, sections: LoadedSection[]): string[] {
|
|
13
|
+
const bodyIds = new Set(sections.map((l) => l.section.id));
|
|
14
|
+
return index.pages.find((p) => p.id === rootPageId)?.order.filter((id) => bodyIds.has(id)) ?? [];
|
|
15
|
+
}
|
|
7
16
|
|
|
8
17
|
export interface BlockMoveState {
|
|
9
18
|
sections: LoadedSection[];
|
|
@@ -84,11 +93,11 @@ export function applyBlockMove(
|
|
|
84
93
|
|
|
85
94
|
if (fromContainerId === toContainerId) {
|
|
86
95
|
// toIndex is the raw insertion index in the original (pre-removal) array.
|
|
87
|
-
// Both
|
|
96
|
+
// Both reorder paths use splice(from,1)+splice(to,0,moved),
|
|
88
97
|
// so we must convert to the post-removal position for same-container moves.
|
|
89
98
|
const adjusted = fromIndex < toIndex ? toIndex - 1 : toIndex;
|
|
90
99
|
if (fromContainerId === ROOT_CONTAINER_ID) {
|
|
91
|
-
const nextIndex =
|
|
100
|
+
const nextIndex = reorderVisibleSectionInPage(index, rootPageId, visibleRootIds(index, rootPageId, sections), fromIndex, adjusted);
|
|
92
101
|
if (nextIndex === index) return noop(state);
|
|
93
102
|
return {
|
|
94
103
|
sections,
|
|
@@ -133,7 +142,9 @@ export function applyBlockMove(
|
|
|
133
142
|
if (toContainerId === ROOT_CONTAINER_ID) {
|
|
134
143
|
const { layout: _dropLayout, ...rootBlock } = moving as Section & { layout?: unknown };
|
|
135
144
|
const meta = DEFAULT_CHILD_META(rootBlock.type);
|
|
136
|
-
|
|
145
|
+
const rootOrder = nextIndex.pages.find((p) => p.id === rootPageId)?.order ?? [];
|
|
146
|
+
nextIndex = addSectionToPage(nextIndex, rootPageId, rootBlock.id, meta,
|
|
147
|
+
visibleInsertPosition(rootOrder, visibleRootIds(nextIndex, rootPageId, nextSections), toIndex));
|
|
137
148
|
nextSections = [...nextSections, { section: rootBlock as Section, meta }];
|
|
138
149
|
indexDirty = true;
|
|
139
150
|
createSectionIds.push(rootBlock.id);
|
package/src/lib/dexie.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { SiteIndex, SectionMeta, SiteConfig, Page } from "../schemas/site-c
|
|
|
4
4
|
import type { NavEntry } from "../schemas/nav";
|
|
5
5
|
import type { LoadedSection } from "./loader";
|
|
6
6
|
import type { MediaManifest, MediaItem } from "../media/types";
|
|
7
|
+
import type { DocumentManifestEntry } from "../schemas/documents";
|
|
7
8
|
|
|
8
9
|
interface SectionRow {
|
|
9
10
|
sectionId: string;
|
|
@@ -70,6 +71,17 @@ interface PendingMediaDeletionRow {
|
|
|
70
71
|
deletedAt: string;
|
|
71
72
|
}
|
|
72
73
|
|
|
74
|
+
/** A document staged locally and not yet committed. Git-tier rows carry the
|
|
75
|
+
* bytes (they ride /api/save); bucket-tier rows are manifest-only — their
|
|
76
|
+
* object is already in the private docs bucket. */
|
|
77
|
+
interface PendingDocumentRow {
|
|
78
|
+
id: string;
|
|
79
|
+
entry: DocumentManifestEntry;
|
|
80
|
+
blob: Blob | null;
|
|
81
|
+
localUrl: string;
|
|
82
|
+
updatedAt: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
73
85
|
export interface PreservedEditRow {
|
|
74
86
|
id: string;
|
|
75
87
|
sectionId: string;
|
|
@@ -88,6 +100,7 @@ class EditorDatabase extends Dexie {
|
|
|
88
100
|
pendingMedia!: Dexie.Table<PendingMediaRow, string>;
|
|
89
101
|
pendingMediaDeletions!: Dexie.Table<PendingMediaDeletionRow, string>;
|
|
90
102
|
preservedEdits!: Dexie.Table<PreservedEditRow, string>;
|
|
103
|
+
pendingDocuments!: Dexie.Table<PendingDocumentRow, string>;
|
|
91
104
|
|
|
92
105
|
constructor(siteId: string) {
|
|
93
106
|
super(`brand-portal-${siteId}`);
|
|
@@ -190,6 +203,21 @@ class EditorDatabase extends Dexie {
|
|
|
190
203
|
pendingMediaDeletions: "id",
|
|
191
204
|
preservedEdits: "id, sectionId",
|
|
192
205
|
});
|
|
206
|
+
// v10: pendingDocuments — locally staged document uploads. Additive store
|
|
207
|
+
// only; every existing table declaration is carried forward unchanged so
|
|
208
|
+
// no data is dropped on upgrade.
|
|
209
|
+
this.version(10).stores({
|
|
210
|
+
sections: "sectionId",
|
|
211
|
+
siteIndex: "key",
|
|
212
|
+
meta: "key",
|
|
213
|
+
siteConfig: "key",
|
|
214
|
+
contentCache: "key",
|
|
215
|
+
mediaManifest: "key",
|
|
216
|
+
pendingMedia: "id",
|
|
217
|
+
pendingMediaDeletions: "id",
|
|
218
|
+
preservedEdits: "id, sectionId",
|
|
219
|
+
pendingDocuments: "id",
|
|
220
|
+
});
|
|
193
221
|
}
|
|
194
222
|
}
|
|
195
223
|
|
|
@@ -593,3 +621,29 @@ export async function listPreservedEdits(): Promise<PreservedEditRow[]> {
|
|
|
593
621
|
export async function removePreservedEdit(id: string): Promise<void> {
|
|
594
622
|
await getDb().preservedEdits.delete(id);
|
|
595
623
|
}
|
|
624
|
+
|
|
625
|
+
export interface PendingDocumentRecord {
|
|
626
|
+
id: string;
|
|
627
|
+
entry: DocumentManifestEntry;
|
|
628
|
+
blob: Blob | null;
|
|
629
|
+
localUrl: string;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export async function addPendingDocument(record: PendingDocumentRecord): Promise<void> {
|
|
633
|
+
await getDb().pendingDocuments.put({ ...record, updatedAt: new Date().toISOString() });
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
export async function getPendingDocuments(): Promise<PendingDocumentRecord[]> {
|
|
637
|
+
const rows = await getDb().pendingDocuments.toArray();
|
|
638
|
+
return rows.map(({ id, entry, blob, localUrl }) => ({ id, entry, blob, localUrl }));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export async function getPendingDocumentBlob(id: string): Promise<Blob | null> {
|
|
642
|
+
const row = await getDb().pendingDocuments.get(id);
|
|
643
|
+
return row?.blob ?? null;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export async function clearPendingDocumentsByIds(ids: string[]): Promise<void> {
|
|
647
|
+
if (ids.length === 0) return;
|
|
648
|
+
await getDb().pendingDocuments.bulkDelete(ids);
|
|
649
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { Section } from "../schemas/sections";
|
|
2
|
+
import { getContainerChildren } from "./container-ops";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structural walkers over section content for `document` references, the
|
|
6
|
+
* counterpart to `image-refs` for the document manifest: `tallyDocumentIds`
|
|
7
|
+
* feeds the media modal's usage pills, `clearDocumentRefs` repairs sections
|
|
8
|
+
* that pointed at a deleted document.
|
|
9
|
+
*
|
|
10
|
+
* Two differences from `image-refs`. The reference is identified by NODE TYPE
|
|
11
|
+
* (`type === "document"`, then `content.doc.documentId`) rather than by key
|
|
12
|
+
* name, so an unrelated field that happens to be called `doc` is never touched.
|
|
13
|
+
* And the walk recurses through container children structurally, deliberately
|
|
14
|
+
* without a depth cap: `MAX_BLOCK_DEPTH` currently makes depth 1 the only
|
|
15
|
+
* reachable nesting, but a counter that miscounts if that rule changes would
|
|
16
|
+
* silently under-report usage and leave dangling references behind.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
interface DocumentSectionContent {
|
|
20
|
+
doc?: { documentId?: string };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The non-empty `documentId` this node references, or "" if it references none. */
|
|
24
|
+
function documentIdOf(section: Section): string {
|
|
25
|
+
if (section.type !== "document") return "";
|
|
26
|
+
return (section.content as DocumentSectionContent).doc?.documentId ?? "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Count every non-empty `documentId` referenced by `value` — a section, or an
|
|
31
|
+
* array of them — including sections nested inside containers. Accepts a
|
|
32
|
+
* caller-supplied accumulator so a list of `LoadedSection`s can be tallied one
|
|
33
|
+
* `.section` at a time into a single map.
|
|
34
|
+
*/
|
|
35
|
+
export function tallyDocumentIds(
|
|
36
|
+
value: Section | readonly Section[],
|
|
37
|
+
counts: Map<string, number> = new Map(),
|
|
38
|
+
): Map<string, number> {
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
for (const item of value) tallyDocumentIds(item, counts);
|
|
41
|
+
return counts;
|
|
42
|
+
}
|
|
43
|
+
const section = value as Section;
|
|
44
|
+
const documentId = documentIdOf(section);
|
|
45
|
+
if (documentId) counts.set(documentId, (counts.get(documentId) ?? 0) + 1);
|
|
46
|
+
for (const child of getContainerChildren(section)) tallyDocumentIds(child, counts);
|
|
47
|
+
return counts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Blank every `doc.documentId` in `ids` to "" (the deleted-media repair pass).
|
|
52
|
+
*
|
|
53
|
+
* Returns `changed: false` and the ORIGINAL section reference when nothing
|
|
54
|
+
* matched, so callers can skip a re-render and a dirty mark. A match on a
|
|
55
|
+
* nested child returns a rewritten CONTAINER — the container is the section
|
|
56
|
+
* that owns the child's bytes, so it is what gets dirtied and saved; untouched
|
|
57
|
+
* siblings are carried over by reference.
|
|
58
|
+
*/
|
|
59
|
+
export function clearDocumentRefs(
|
|
60
|
+
section: Section,
|
|
61
|
+
ids: Set<string>,
|
|
62
|
+
): { changed: boolean; section: Section } {
|
|
63
|
+
const documentId = documentIdOf(section);
|
|
64
|
+
if (documentId && ids.has(documentId)) {
|
|
65
|
+
const content = section.content as DocumentSectionContent;
|
|
66
|
+
return {
|
|
67
|
+
changed: true,
|
|
68
|
+
section: { ...section, content: { ...section.content, doc: { ...content.doc, documentId: "" } } },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const children = getContainerChildren(section);
|
|
73
|
+
if (children.length === 0) return { changed: false, section };
|
|
74
|
+
|
|
75
|
+
let changed = false;
|
|
76
|
+
const next = children.map((child) => {
|
|
77
|
+
const result = clearDocumentRefs(child, ids);
|
|
78
|
+
if (result.changed) changed = true;
|
|
79
|
+
return result.section;
|
|
80
|
+
});
|
|
81
|
+
if (!changed) return { changed: false, section };
|
|
82
|
+
return { changed: true, section: { ...section, content: { ...section.content, children: next } } };
|
|
83
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DOCUMENT_BUCKET_MAX_BYTES,
|
|
3
|
+
DOCUMENT_GIT_MAX_BYTES,
|
|
4
|
+
DOCUMENT_MIME_TO_EXT,
|
|
5
|
+
documentGitPath as documentGitPathFromSlug,
|
|
6
|
+
documentBucketPath,
|
|
7
|
+
} from "../schemas/documents";
|
|
8
|
+
import { sanitizeMediaName } from "../media/utils";
|
|
9
|
+
|
|
10
|
+
// Bucket keys are already spelled once, in schemas/documents (S1) — re-exported
|
|
11
|
+
// so this module is the single import site for the whole upload flow.
|
|
12
|
+
export { documentBucketPath };
|
|
13
|
+
|
|
14
|
+
/** Every way a document upload can be refused. The first three are decidable
|
|
15
|
+
* client-side; `no_documents_store` only comes back from the upload-intent
|
|
16
|
+
* endpoint (the browser cannot see whether a docs bucket is configured). */
|
|
17
|
+
export type DocumentRejectionReason =
|
|
18
|
+
| "unsupported_type"
|
|
19
|
+
| "too_large"
|
|
20
|
+
| "too_small"
|
|
21
|
+
| "no_documents_store";
|
|
22
|
+
|
|
23
|
+
export type DocumentUploadPlan =
|
|
24
|
+
| { tier: "git"; kind: "pdf" | "html"; ext: "pdf" | "html" }
|
|
25
|
+
| { tier: "bucket"; kind: "pdf" | "html"; ext: "pdf" | "html" }
|
|
26
|
+
| { tier: "rejected"; reason: DocumentRejectionReason };
|
|
27
|
+
|
|
28
|
+
/** Pure tier routing. The bucket tier's lower bound is exactly the git cap, so
|
|
29
|
+
* there is no size that falls through both. */
|
|
30
|
+
export function planDocumentUpload(file: { name: string; size: number; type: string }): DocumentUploadPlan {
|
|
31
|
+
// `file.type` is an arbitrary string, so the const map is widened for the
|
|
32
|
+
// lookup rather than indexed with a cast that TS would reject.
|
|
33
|
+
const ext = (DOCUMENT_MIME_TO_EXT as Record<string, "pdf" | "html" | undefined>)[file.type];
|
|
34
|
+
if (!ext) return { tier: "rejected", reason: "unsupported_type" };
|
|
35
|
+
if (file.size <= 0) return { tier: "rejected", reason: "too_small" };
|
|
36
|
+
if (file.size > DOCUMENT_BUCKET_MAX_BYTES) return { tier: "rejected", reason: "too_large" };
|
|
37
|
+
if (file.size <= DOCUMENT_GIT_MAX_BYTES) return { tier: "git", kind: ext, ext };
|
|
38
|
+
return { tier: "bucket", kind: ext, ext };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Human copy for every rejection code, client- or server-originated. Never
|
|
42
|
+
* falls back to the media pipeline's generic size copy (an oversized PDF
|
|
43
|
+
* today gets "…is larger than the NMB media limit.", which names the wrong
|
|
44
|
+
* limit and no fix). */
|
|
45
|
+
export function documentRejectionMessage(reason: DocumentRejectionReason, filename: string): string {
|
|
46
|
+
switch (reason) {
|
|
47
|
+
case "unsupported_type":
|
|
48
|
+
return `"${filename}" — only PDF and HTML documents can be uploaded.`;
|
|
49
|
+
case "too_large":
|
|
50
|
+
return `"${filename}" is larger than the 200MB document limit.`;
|
|
51
|
+
case "too_small":
|
|
52
|
+
return `"${filename}" is empty.`;
|
|
53
|
+
case "no_documents_store":
|
|
54
|
+
return `"${filename}" is over 2.5MB, and document storage isn't set up on this site yet.`;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Repo path for a git-tier document, from the RAW file name. The layout itself
|
|
60
|
+
* lives once in schemas/documents (S1), which takes an already-sanitized stem;
|
|
61
|
+
* this wrapper only adds `sanitizeMediaName`, so the two can never drift. The
|
|
62
|
+
* extension comes from the explicit pdf/html map — never mimeToExt's "bin".
|
|
63
|
+
*/
|
|
64
|
+
export function documentGitPath(originalName: string, hash: string, ext: "pdf" | "html"): string {
|
|
65
|
+
return documentGitPathFromSlug(sanitizeMediaName(originalName), hash, ext);
|
|
66
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import type { LoadedSection } from "./loader";
|
|
2
|
+
import type { Section } from "../schemas/sections";
|
|
3
|
+
import type { SiteIndex, SectionMeta } from "../schemas/site-config";
|
|
4
|
+
import type { DocumentManifest } from "../schemas/documents";
|
|
5
|
+
import { documentFileUrl } from "../schemas/documents";
|
|
6
|
+
import { getContainerChildren } from "./container-ops";
|
|
7
|
+
import { audiencePasses } from "./pages";
|
|
8
|
+
|
|
9
|
+
// The gated URL has exactly ONE definition, in schemas/documents (S1). This is
|
|
10
|
+
// a re-export so `@/lib/documents` consumers get it without a second spelling.
|
|
11
|
+
export { documentFileUrl };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* One `document` section, joined against the document manifest and filtered to
|
|
15
|
+
* what THIS requester may see. Derived per request — never persisted, never
|
|
16
|
+
* written back into index.json or nav.json.
|
|
17
|
+
*/
|
|
18
|
+
export interface SiteDocument {
|
|
19
|
+
sectionId: string;
|
|
20
|
+
/** Manifest key. Carried so the overlay can build the ?download=1 URL with
|
|
21
|
+
* documentFileUrl(), and so the editor can key a staged blob URL by id. */
|
|
22
|
+
documentId: string;
|
|
23
|
+
title: string;
|
|
24
|
+
filename: string;
|
|
25
|
+
size: number;
|
|
26
|
+
kind: "pdf" | "html";
|
|
27
|
+
/** The auth-gated route. Append `?download=1` for an attachment response. */
|
|
28
|
+
url: string;
|
|
29
|
+
/** Mirrors content.showInDocumentsNav — generateSiteNav filters on it, while
|
|
30
|
+
* the overlay and the rows need the unfiltered list (an unflagged document
|
|
31
|
+
* is still openable from its own row). */
|
|
32
|
+
showInDocumentsNav: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface DocumentSectionContent {
|
|
36
|
+
title?: string;
|
|
37
|
+
doc?: { documentId?: string };
|
|
38
|
+
showInDocumentsNav?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Filename without its extension. The document row and the overlay's top bar
|
|
43
|
+
* render `content.title || filenameStem(entry.filename)` — a DISPLAY fallback,
|
|
44
|
+
* not a write. (SettingsForm.handleFieldChange is per-key and cannot set a
|
|
45
|
+
* sibling field, so "auto-fill title on pick" would need cross-field machinery
|
|
46
|
+
* for the same visible result.)
|
|
47
|
+
*/
|
|
48
|
+
export function filenameStem(filename: string): string {
|
|
49
|
+
const dot = filename.lastIndexOf(".");
|
|
50
|
+
return dot > 0 ? filename.slice(0, dot) : filename;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Compact byte size for the row's `filename · size` line. */
|
|
54
|
+
export function formatDocSize(bytes: number): string {
|
|
55
|
+
if (!Number.isFinite(bytes) || bytes < 0) return "";
|
|
56
|
+
if (bytes < 1024) return `${Math.round(bytes)} B`;
|
|
57
|
+
const units = ["KB", "MB", "GB"];
|
|
58
|
+
let value = bytes / 1024;
|
|
59
|
+
let unit = 0;
|
|
60
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
61
|
+
value /= 1024;
|
|
62
|
+
unit++;
|
|
63
|
+
}
|
|
64
|
+
return `${value >= 100 ? Math.round(value) : Number(value.toFixed(1))} ${units[unit]}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Every document the requester may see, in page order.
|
|
69
|
+
*
|
|
70
|
+
* Viewer mode mirrors viewer-page.ts exactly: the owning page must be live and
|
|
71
|
+
* audience-passing, AND the section's own meta must be live and audience-
|
|
72
|
+
* passing. Editors see everything on the draft. A document with no manifest
|
|
73
|
+
* entry is excluded in BOTH modes — there are no bytes to serve and no
|
|
74
|
+
* filename/size to render; the editor's row derives its "file missing" state
|
|
75
|
+
* from that absence.
|
|
76
|
+
*
|
|
77
|
+
* Documents nested one level inside a container count too, and are emitted right
|
|
78
|
+
* after their container's own position. A child has no meta of its own and never
|
|
79
|
+
* appears in `page.order`, so both gates read the PARENT's meta and the PARENT's
|
|
80
|
+
* page; the emitted `sectionId` is the CHILD's — that is what `DocumentRow`
|
|
81
|
+
* matches and what `#doc=` deep links carry. Depth 1 only: `MAX_BLOCK_DEPTH`
|
|
82
|
+
* makes anything deeper unrepresentable on write and on load.
|
|
83
|
+
*
|
|
84
|
+
* This is one half of a documented mirror. `referencedByVisibleSection` (the
|
|
85
|
+
* gated file route) applies the same gates with the same recursion — change one,
|
|
86
|
+
* change the other, or a document becomes listable but unservable (or worse).
|
|
87
|
+
*/
|
|
88
|
+
export function deriveSiteDocuments(opts: {
|
|
89
|
+
index: SiteIndex;
|
|
90
|
+
sections: LoadedSection[];
|
|
91
|
+
manifest: DocumentManifest;
|
|
92
|
+
mode: "viewer" | "editor";
|
|
93
|
+
audience: string | null;
|
|
94
|
+
}): SiteDocument[] {
|
|
95
|
+
const { index, sections, manifest, mode, audience } = opts;
|
|
96
|
+
|
|
97
|
+
const pageBySectionId = new Map<string, SiteIndex["pages"][number]>();
|
|
98
|
+
for (const page of index.pages) for (const id of page.order) pageBySectionId.set(id, page);
|
|
99
|
+
|
|
100
|
+
const out: SiteDocument[] = [];
|
|
101
|
+
const seen = new Set<string>();
|
|
102
|
+
|
|
103
|
+
/** `candidate` supplies the id and the body; `owner` (itself, or the container
|
|
104
|
+
* it sits in) supplies the meta and the page. */
|
|
105
|
+
const consider = (candidate: Section, ownerId: string, meta: SectionMeta) => {
|
|
106
|
+
if (candidate.type !== "document" || !candidate.id) return;
|
|
107
|
+
const content = candidate.content as DocumentSectionContent;
|
|
108
|
+
const documentId = content.doc?.documentId ?? "";
|
|
109
|
+
const entry = documentId ? manifest[documentId] : undefined;
|
|
110
|
+
if (!entry) return;
|
|
111
|
+
|
|
112
|
+
if (mode === "viewer") {
|
|
113
|
+
const page = pageBySectionId.get(ownerId);
|
|
114
|
+
if (!page || page.status !== "live" || !audiencePasses(page.access, audience)) return;
|
|
115
|
+
if (meta.status !== "live" || !audiencePasses(meta.access, audience)) return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (seen.has(candidate.id)) return;
|
|
119
|
+
seen.add(candidate.id);
|
|
120
|
+
|
|
121
|
+
out.push({
|
|
122
|
+
sectionId: candidate.id,
|
|
123
|
+
documentId,
|
|
124
|
+
title: content.title?.trim() || filenameStem(entry.filename),
|
|
125
|
+
filename: entry.filename,
|
|
126
|
+
size: entry.size,
|
|
127
|
+
kind: entry.kind,
|
|
128
|
+
url: documentFileUrl(documentId, entry.filename),
|
|
129
|
+
showInDocumentsNav: content.showInDocumentsNav !== false,
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (const { section, meta } of sections) {
|
|
134
|
+
consider(section, section.id, meta);
|
|
135
|
+
for (const child of getContainerChildren(section)) consider(child, section.id, meta);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
package/src/lib/index-ops.ts
CHANGED
|
@@ -188,6 +188,22 @@ export function opTouchedSectionIds(ops: IndexOp[]): string[] {
|
|
|
188
188
|
return [...ids];
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* The ops that materialize and write the nav sidecar (the ones that set
|
|
193
|
+
* `navTouched` in applyIndexOps — keep the two in sync; the "nav sidecar
|
|
194
|
+
* preservation" test asserts they agree). Callers must refuse to apply these
|
|
195
|
+
* over a DEGRADED nav read: the tree they'd commit was built from only the
|
|
196
|
+
* entries that version could parse, so writing it destroys the rest.
|
|
197
|
+
* Section-level ops are absent by design — they emit no `nav` key at all.
|
|
198
|
+
*/
|
|
199
|
+
const NAV_TOUCHING_OPS = new Set<IndexOp["op"]>([
|
|
200
|
+
"addPage", "removePage", "addNavGroup", "removeNavGroup", "setNavGroupMeta", "movePageInNav",
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
export function opsTouchNav(ops: IndexOp[]): boolean {
|
|
204
|
+
return ops.some((op) => NAV_TOUCHING_OPS.has(op.op));
|
|
205
|
+
}
|
|
206
|
+
|
|
191
207
|
const fmt = (ids: string[]) => ids.map((id) => `"${id}"`).join(", ");
|
|
192
208
|
|
|
193
209
|
/**
|
package/src/lib/loader.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { IndexSchema, type SiteIndex, type SectionMeta } from "../schemas/site-c
|
|
|
3
3
|
import { getAllSchemas } from "./registry";
|
|
4
4
|
import { blockDepth, MAX_BLOCK_DEPTH } from "../schemas/block";
|
|
5
5
|
import { upgradeLegacySection } from "./migrate-sections-transform";
|
|
6
|
-
import {
|
|
6
|
+
import { NavEntrySchema, type NavEntry } from "../schemas/nav";
|
|
7
7
|
|
|
8
8
|
export interface LoadedSection {
|
|
9
9
|
section: Section;
|
|
@@ -13,21 +13,41 @@ export interface LoadedSection {
|
|
|
13
13
|
export interface SiteContent {
|
|
14
14
|
sections: LoadedSection[];
|
|
15
15
|
index: SiteIndex;
|
|
16
|
+
/** True when nav.json was present but held entries this package version could
|
|
17
|
+
* not represent (unknown kinds, invalid shapes) — the returned nav is missing
|
|
18
|
+
* them. Writers must treat a degraded nav as read-only: wholesale-replacing
|
|
19
|
+
* nav.json from a degraded read permanently destroys the entries this
|
|
20
|
+
* version dropped. */
|
|
21
|
+
navDegraded: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/**
|
|
19
|
-
* Parse the nav.json sidecar defensively. A malformed sidecar
|
|
20
|
-
*
|
|
21
|
-
* point of the sidecar is that it tolerates writers that don't understand
|
|
25
|
+
* Parse the nav.json sidecar defensively, entry by entry. A malformed sidecar
|
|
26
|
+
* must degrade, never brick the site — nav.json is optional content, and the
|
|
27
|
+
* whole point of the sidecar is that it tolerates writers that don't understand
|
|
28
|
+
* it. Tolerance is PER ENTRY: one unknown entry kind (a newer package version's
|
|
29
|
+
* addition, or an authoring typo) drops that entry alone, not every group on
|
|
30
|
+
* the site. Any drop is reported as `degraded` so write paths can refuse to
|
|
31
|
+
* persist the lossy tree back over the original.
|
|
22
32
|
*/
|
|
23
|
-
function parseNavFile(navFile: unknown): NavEntry[] {
|
|
24
|
-
if (!navFile) return [];
|
|
25
|
-
const
|
|
26
|
-
if (!
|
|
33
|
+
function parseNavFile(navFile: unknown): { entries: NavEntry[]; degraded: boolean } {
|
|
34
|
+
if (!navFile) return { entries: [], degraded: false };
|
|
35
|
+
const rawEntries = (navFile as { entries?: unknown }).entries;
|
|
36
|
+
if (!Array.isArray(rawEntries)) {
|
|
27
37
|
console.warn("Invalid nav.json, falling back to a flat navigation");
|
|
28
|
-
return [];
|
|
38
|
+
return { entries: [], degraded: true };
|
|
29
39
|
}
|
|
30
|
-
|
|
40
|
+
const entries: NavEntry[] = [];
|
|
41
|
+
let dropped = 0;
|
|
42
|
+
for (const raw of rawEntries) {
|
|
43
|
+
const result = NavEntrySchema.safeParse(raw);
|
|
44
|
+
if (result.success) entries.push(result.data);
|
|
45
|
+
else dropped++;
|
|
46
|
+
}
|
|
47
|
+
if (dropped > 0) {
|
|
48
|
+
console.warn(`nav.json: dropped ${dropped} entr${dropped === 1 ? "y" : "ies"} this package version cannot represent; navigation is degraded`);
|
|
49
|
+
}
|
|
50
|
+
return { entries, degraded: dropped > 0 };
|
|
31
51
|
}
|
|
32
52
|
|
|
33
53
|
/**
|
|
@@ -83,7 +103,8 @@ export function mergeSiteContent(
|
|
|
83
103
|
}
|
|
84
104
|
}
|
|
85
105
|
|
|
86
|
-
|
|
106
|
+
const nav = parseNavFile(navFile);
|
|
107
|
+
return { sections, index: { ...index, nav: nav.entries }, navDegraded: nav.degraded };
|
|
87
108
|
}
|
|
88
109
|
|
|
89
110
|
/**
|
package/src/lib/media-folders.ts
CHANGED
|
@@ -13,6 +13,15 @@ export interface FolderUploadPlan {
|
|
|
13
13
|
fileTargets: { file: File; folderId: string | null }[];
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The library's Documents pseudo-folder. UI-only: it is NEVER a `folders` entry
|
|
18
|
+
* (they round-trip to disk verbatim, and no MediaItem can point at it), so
|
|
19
|
+
* every `currentFolderId` consumer that reads `folders` must special-case it.
|
|
20
|
+
* Cannot collide with a real `f_`-prefixed id; distinct from the nav's
|
|
21
|
+
* `__documents__` group id.
|
|
22
|
+
*/
|
|
23
|
+
export const DOCUMENTS_FOLDER_ID = "__documents-folder__";
|
|
24
|
+
|
|
16
25
|
/** Display-folder id. NOT related to MediaItem.folder (the git storage dir). */
|
|
17
26
|
export function createFolderId(): string {
|
|
18
27
|
const bytes = new Uint8Array(6);
|
package/src/lib/nav.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { getSection, type SectionRegistry } from "./registry";
|
|
|
3
3
|
import { audiencePasses, homePage, pageDisplayTitle } from "./pages";
|
|
4
4
|
import { navEntries } from "./nav-tree";
|
|
5
5
|
import type { SiteIndex } from "../schemas/site-config";
|
|
6
|
+
import type { SiteDocument } from "./documents";
|
|
6
7
|
|
|
7
8
|
export interface NavItem {
|
|
8
9
|
href: string;
|
|
@@ -102,7 +103,30 @@ export interface GroupNavItem {
|
|
|
102
103
|
pages: PageNavItem[];
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
/** A launcher entry for one document section. NOT a PageNavItem: the editor's
|
|
107
|
+
* page rendering turns hrefs into pageSelectEvent buttons, which would
|
|
108
|
+
* dead-end here. */
|
|
109
|
+
export interface DocNavItem {
|
|
110
|
+
kind: "doc";
|
|
111
|
+
sectionId: string;
|
|
112
|
+
title: string;
|
|
113
|
+
url: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The synthetic, never-persisted "Documents" group. GroupNavItem.pages is
|
|
117
|
+
* PageNavItem[], so the doc group is its own entry kind rather than a reuse. */
|
|
118
|
+
export interface DocGroupNavItem {
|
|
119
|
+
kind: "doc-group";
|
|
120
|
+
id: string;
|
|
121
|
+
label: string;
|
|
122
|
+
docs: DocNavItem[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Id of the synthetic group. Generated page-group ids never collide with it,
|
|
126
|
+
* and it is never written to nav.json. */
|
|
127
|
+
export const DOCUMENTS_GROUP_ID = "__documents__";
|
|
128
|
+
|
|
129
|
+
export type SiteNavEntry = ({ kind: "page" } & PageNavItem) | GroupNavItem | DocGroupNavItem;
|
|
106
130
|
|
|
107
131
|
export interface SiteNav {
|
|
108
132
|
/** The authored tree: loose pages and groups, in nav order. */
|
|
@@ -123,8 +147,15 @@ export function generateSiteNav(opts: {
|
|
|
123
147
|
mode: "viewer" | "editor";
|
|
124
148
|
audience: string | null;
|
|
125
149
|
registry?: SectionRegistry;
|
|
150
|
+
/** Pre-derived and already requester-filtered (deriveSiteDocuments). This
|
|
151
|
+
* function cannot see SITE-WIDE section content — activeSections carries at
|
|
152
|
+
* most the current page's sections, and EditorShell's two history call
|
|
153
|
+
* sites pass activeSections: [] outright — so the list is always supplied
|
|
154
|
+
* by the caller. Callers without one (history views) pass nothing and
|
|
155
|
+
* render no group. */
|
|
156
|
+
documents?: SiteDocument[];
|
|
126
157
|
}): SiteNav {
|
|
127
|
-
const { index, activeSections, activePageId, mode, audience, registry } = opts;
|
|
158
|
+
const { index, activeSections, activePageId, mode, audience, registry, documents } = opts;
|
|
128
159
|
const active = activePageId ?? homePage(index).id;
|
|
129
160
|
const byId = new Map(index.pages.map((p) => [p.id, p]));
|
|
130
161
|
|
|
@@ -163,6 +194,19 @@ export function generateSiteNav(opts: {
|
|
|
163
194
|
});
|
|
164
195
|
}
|
|
165
196
|
|
|
197
|
+
// Derived, never persisted: the Documents group lives only in this view model,
|
|
198
|
+
// so nav.json is never written, reconcileNav never sees it, and the Pages
|
|
199
|
+
// modal's positional drag indexing is untouched.
|
|
200
|
+
const navDocs = (documents ?? []).filter((d) => d.showInDocumentsNav);
|
|
201
|
+
if (navDocs.length > 0) {
|
|
202
|
+
entries.push({
|
|
203
|
+
kind: "doc-group",
|
|
204
|
+
id: DOCUMENTS_GROUP_ID,
|
|
205
|
+
label: "Documents",
|
|
206
|
+
docs: navDocs.map((d) => ({ kind: "doc" as const, sectionId: d.sectionId, title: d.title, url: d.url })),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
166
210
|
if (mode === "editor") {
|
|
167
211
|
return {
|
|
168
212
|
entries,
|
package/src/lib/pages.ts
CHANGED
|
@@ -203,6 +203,54 @@ export function reorderSectionInPage(
|
|
|
203
203
|
return { ...index, pages };
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Translate an insertion index expressed over the VISIBLE section list (what the
|
|
208
|
+
* editor renders: `page.order` filtered to loadable bodies) into a `page.order`
|
|
209
|
+
* position. The two disagree whenever a section body was dropped by the loader
|
|
210
|
+
* (unparseable file, schema miss, site-local type) — splicing a display index
|
|
211
|
+
* straight into `order` then moves the WRONG section. `displayIndex` may be
|
|
212
|
+
* `visibleIds.length` ("after the last visible entry"), which lands right after
|
|
213
|
+
* the last visible id so hidden trailing sections keep their place.
|
|
214
|
+
*/
|
|
215
|
+
export function visibleInsertPosition(order: string[], visibleIds: string[], displayIndex: number): number {
|
|
216
|
+
if (displayIndex < visibleIds.length) {
|
|
217
|
+
const pos = order.indexOf(visibleIds[displayIndex]);
|
|
218
|
+
if (pos >= 0) return pos;
|
|
219
|
+
}
|
|
220
|
+
const lastVisible = visibleIds[visibleIds.length - 1];
|
|
221
|
+
const lastPos = lastVisible !== undefined ? order.indexOf(lastVisible) : -1;
|
|
222
|
+
return lastPos >= 0 ? lastPos + 1 : order.length;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Reorder a section using DISPLAY indices over the visible list (same semantics
|
|
227
|
+
* as `reorderSectionInPage`: `toIndex` is the post-removal position). Resolves
|
|
228
|
+
* both endpoints to ids before touching `page.order`, so filtered-out sections
|
|
229
|
+
* can neither be moved by mistake nor shift the splice onto a different entry.
|
|
230
|
+
* Falls back to a no-op when the endpoints don't resolve.
|
|
231
|
+
*/
|
|
232
|
+
export function reorderVisibleSectionInPage(
|
|
233
|
+
index: SiteIndex,
|
|
234
|
+
pageId: string,
|
|
235
|
+
visibleIds: string[],
|
|
236
|
+
fromIndex: number,
|
|
237
|
+
toIndex: number,
|
|
238
|
+
): SiteIndex {
|
|
239
|
+
const fromId = visibleIds[fromIndex];
|
|
240
|
+
if (fromId === undefined) return index;
|
|
241
|
+
const withoutFrom = visibleIds.filter((id) => id !== fromId);
|
|
242
|
+
const pages = index.pages.map((p) => {
|
|
243
|
+
if (p.id !== pageId) return p;
|
|
244
|
+
const fromPos = p.order.indexOf(fromId);
|
|
245
|
+
if (fromPos < 0) return p;
|
|
246
|
+
const order = [...p.order];
|
|
247
|
+
order.splice(fromPos, 1);
|
|
248
|
+
order.splice(visibleInsertPosition(order, withoutFrom, toIndex), 0, fromId);
|
|
249
|
+
return { ...p, order };
|
|
250
|
+
});
|
|
251
|
+
return { ...index, pages };
|
|
252
|
+
}
|
|
253
|
+
|
|
206
254
|
export function moveSection(
|
|
207
255
|
index: SiteIndex,
|
|
208
256
|
sectionId: string,
|