@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
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { SiteDocument } from "../../lib/documents";
|
|
2
|
+
import { createEvent } from "../../lib/events";
|
|
3
|
+
|
|
4
|
+
/** Toggled on <html> while a document is open (the chat's class mechanism). */
|
|
5
|
+
export const DOC_OPEN_CLASS = "bp-doc-open";
|
|
6
|
+
|
|
7
|
+
/** The one breakpoint the doc feature keys off: at or above it the overlay
|
|
8
|
+
* gets the desktop stage; below it a PDF goes to the OS viewer instead (iOS
|
|
9
|
+
* Safari cannot scroll an iframed PDF, Android Chrome forces a download).
|
|
10
|
+
* 1024px — the SAME breakpoint as U5's desktop CSS block and the width where
|
|
11
|
+
* the nav sidebar exists at all. Deliberately NOT the chat's 768px dock query:
|
|
12
|
+
* keying JS at 768 while the pane's desktop rules start at 1024 would give
|
|
13
|
+
* 768–1023px viewers framed PDFs inside the mobile pane geometry, under a
|
|
14
|
+
* docked chat. */
|
|
15
|
+
export const DOC_DESKTOP_QUERY = "(min-width: 1024px)";
|
|
16
|
+
|
|
17
|
+
/** Cross-island open request. Islands that would rather not import this module
|
|
18
|
+
* can dispatch it instead; applying an already-current value is a no-op, so
|
|
19
|
+
* the dispatch-listen pair cannot loop. */
|
|
20
|
+
export const docFileOpenEvent = createEvent<{ sectionId: string | null }>("bp-doc-file-open");
|
|
21
|
+
|
|
22
|
+
interface DocFileStore {
|
|
23
|
+
open: string | null;
|
|
24
|
+
/** null = not seeded yet. Distinct from [] (seeded, no documents) — a row
|
|
25
|
+
* that hydrates before the overlay must not flash "file missing". */
|
|
26
|
+
docs: SiteDocument[] | null;
|
|
27
|
+
openListeners: Set<(id: string | null) => void>;
|
|
28
|
+
docsListeners: Set<(docs: SiteDocument[] | null) => void>;
|
|
29
|
+
teardown: (() => void) | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Symbol-keyed on globalThis for the same reason the section registry is: the
|
|
33
|
+
// bundler may hand two islands two copies of this module, and a split store
|
|
34
|
+
// would let the nav open a document the overlay never hears about.
|
|
35
|
+
const STORE_KEY = Symbol.for("@drawnagency/primitives/doc-file-state");
|
|
36
|
+
const g = globalThis as unknown as Record<symbol, DocFileStore | undefined>;
|
|
37
|
+
|
|
38
|
+
function store(): DocFileStore {
|
|
39
|
+
let s = g[STORE_KEY];
|
|
40
|
+
if (!s) {
|
|
41
|
+
s = { open: null, docs: null, openListeners: new Set(), docsListeners: new Set(), teardown: null };
|
|
42
|
+
g[STORE_KEY] = s;
|
|
43
|
+
}
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isDocDesktop(): boolean {
|
|
48
|
+
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return true;
|
|
49
|
+
return window.matchMedia(DOC_DESKTOP_QUERY).matches;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readHash(): string | null {
|
|
53
|
+
if (typeof window === "undefined") return null;
|
|
54
|
+
const match = /^#doc=(.+)$/.exec(window.location.hash);
|
|
55
|
+
if (!match) return null;
|
|
56
|
+
try {
|
|
57
|
+
return decodeURIComponent(match[1]);
|
|
58
|
+
} catch {
|
|
59
|
+
return match[1];
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function apply(sectionId: string | null): void {
|
|
64
|
+
const s = store();
|
|
65
|
+
if (s.open === sectionId) return;
|
|
66
|
+
s.open = sectionId;
|
|
67
|
+
if (typeof document !== "undefined") {
|
|
68
|
+
document.documentElement.classList.toggle(DOC_OPEN_CLASS, sectionId !== null);
|
|
69
|
+
}
|
|
70
|
+
for (const listener of [...s.openListeners]) listener(sectionId);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function syncFromHash(): void {
|
|
74
|
+
const next = readHash();
|
|
75
|
+
// "Ignores hash writes that still contain its own value" — a same-value
|
|
76
|
+
// write (e.g. a repeated nav click on the already-open document) must not
|
|
77
|
+
// re-notify. NOTE the one hash writer that CAN silently stomp #doc is a nav
|
|
78
|
+
// HEADING click (useActiveHeadings' setActiveSection does a replaceState —
|
|
79
|
+
// click-driven, not scroll-driven, so the scroll lock does not prevent it);
|
|
80
|
+
// U3 closes the overlay in handleHeadingNav before that write happens.
|
|
81
|
+
if (next === store().open) return;
|
|
82
|
+
apply(next);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function openDocFile(sectionId: string): void {
|
|
86
|
+
if (typeof window !== "undefined") {
|
|
87
|
+
const hash = `#doc=${encodeURIComponent(sectionId)}`;
|
|
88
|
+
if (window.location.hash !== hash) {
|
|
89
|
+
// pushState so Back closes the overlay. It fires no hashchange, which is
|
|
90
|
+
// why the atom — not the hash — is the store.
|
|
91
|
+
window.history.pushState(null, "", hash);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
apply(sectionId);
|
|
95
|
+
docFileOpenEvent.dispatch({ sectionId });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function closeDocFile(): void {
|
|
99
|
+
if (typeof window !== "undefined" && readHash() !== null) {
|
|
100
|
+
// replaceState, never history.back(): back() races the editor's own
|
|
101
|
+
// pushState in goToPage and is unobservable in jsdom. The cost is that Back
|
|
102
|
+
// after a manual close returns to an identical URL — invisible to the user.
|
|
103
|
+
const { pathname, search } = window.location;
|
|
104
|
+
window.history.replaceState(null, "", `${pathname}${search}`);
|
|
105
|
+
}
|
|
106
|
+
apply(null);
|
|
107
|
+
docFileOpenEvent.dispatch({ sectionId: null });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function getOpenDocFile(): string | null {
|
|
111
|
+
return store().open;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function subscribeDocFile(cb: (sectionId: string | null) => void): () => void {
|
|
115
|
+
const s = store();
|
|
116
|
+
s.openListeners.add(cb);
|
|
117
|
+
if (!s.teardown && typeof window !== "undefined") {
|
|
118
|
+
const onHash = () => syncFromHash();
|
|
119
|
+
const onPop = () => syncFromHash();
|
|
120
|
+
const onRequest = (detail: { sectionId: string | null }) => {
|
|
121
|
+
// The already-current check must come FIRST and cover null too: this
|
|
122
|
+
// listener also hears the event openDocFile/closeDocFile dispatch
|
|
123
|
+
// themselves, and an unguarded close would re-close → re-dispatch →
|
|
124
|
+
// recurse until the stack blew (jsdom swallows listener throws, so the
|
|
125
|
+
// suite only surfaced it as an unhandled RangeError).
|
|
126
|
+
if (detail.sectionId === s.open) return;
|
|
127
|
+
if (detail.sectionId === null) closeDocFile();
|
|
128
|
+
else openDocFile(detail.sectionId);
|
|
129
|
+
};
|
|
130
|
+
window.addEventListener("hashchange", onHash);
|
|
131
|
+
window.addEventListener("popstate", onPop);
|
|
132
|
+
const unlistenRequest = docFileOpenEvent.listen(onRequest);
|
|
133
|
+
s.teardown = () => {
|
|
134
|
+
window.removeEventListener("hashchange", onHash);
|
|
135
|
+
window.removeEventListener("popstate", onPop);
|
|
136
|
+
unlistenRequest();
|
|
137
|
+
};
|
|
138
|
+
// Deep link: the hash is an INPUT, read once when the first listener wires up.
|
|
139
|
+
syncFromHash();
|
|
140
|
+
}
|
|
141
|
+
return () => {
|
|
142
|
+
s.openListeners.delete(cb);
|
|
143
|
+
if (s.openListeners.size === 0 && s.teardown) {
|
|
144
|
+
s.teardown();
|
|
145
|
+
s.teardown = null;
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Authoritative publish — the editor's live draft list. */
|
|
151
|
+
export function setSiteDocuments(docs: SiteDocument[]): void {
|
|
152
|
+
const s = store();
|
|
153
|
+
s.docs = docs;
|
|
154
|
+
for (const listener of [...s.docsListeners]) listener(docs);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** First-writer-wins publish for the SSR-derived list, so an island that mounts
|
|
158
|
+
* after the editor has already published cannot clobber the live list. */
|
|
159
|
+
export function seedSiteDocuments(docs: SiteDocument[]): void {
|
|
160
|
+
if (store().docs !== null) return;
|
|
161
|
+
setSiteDocuments(docs);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function getSiteDocuments(): SiteDocument[] | null {
|
|
165
|
+
return store().docs;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function subscribeSiteDocuments(cb: (docs: SiteDocument[] | null) => void): () => void {
|
|
169
|
+
const s = store();
|
|
170
|
+
s.docsListeners.add(cb);
|
|
171
|
+
return () => { s.docsListeners.delete(cb); };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Test-only: drop the store and any window wiring (mirrors clearRegistry). */
|
|
175
|
+
export function resetDocFileState(): void {
|
|
176
|
+
const s = g[STORE_KEY];
|
|
177
|
+
if (s?.teardown) s.teardown();
|
|
178
|
+
if (typeof document !== "undefined") document.documentElement.classList.remove(DOC_OPEN_CLASS);
|
|
179
|
+
g[STORE_KEY] = undefined;
|
|
180
|
+
}
|
|
@@ -6,6 +6,7 @@ import { Checkbox } from "../shared/Checkbox";
|
|
|
6
6
|
import { FormLabel } from "../shared/FormLabel";
|
|
7
7
|
import { LinkField } from "../shared/LinkField";
|
|
8
8
|
import { VideoSourceField } from "../shared/VideoSourceField";
|
|
9
|
+
import { DocumentSourceField, type DocumentRef } from "../shared/DocumentSourceField";
|
|
9
10
|
import { Tabs } from "../shared/Tabs";
|
|
10
11
|
import type { LinkValue } from "../../schemas/link";
|
|
11
12
|
import type { VideoSource } from "../sections/Video/VideoBlock";
|
|
@@ -174,6 +175,15 @@ function renderField(
|
|
|
174
175
|
/>
|
|
175
176
|
);
|
|
176
177
|
|
|
178
|
+
case "document-source":
|
|
179
|
+
return (
|
|
180
|
+
<DocumentSourceField
|
|
181
|
+
key={key}
|
|
182
|
+
value={value as DocumentRef | undefined}
|
|
183
|
+
onChange={(v) => handleFieldChange(key, v)}
|
|
184
|
+
/>
|
|
185
|
+
);
|
|
186
|
+
|
|
177
187
|
default:
|
|
178
188
|
return null;
|
|
179
189
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { ExternalLink, FileText } from "lucide-react";
|
|
4
|
+
import { defineSection } from "../../../lib/registry";
|
|
5
|
+
import { cn } from "../../../lib/cn";
|
|
6
|
+
import { formatDocSize, type SiteDocument } from "../../../lib/documents";
|
|
7
|
+
import {
|
|
8
|
+
getSiteDocuments,
|
|
9
|
+
subscribeSiteDocuments,
|
|
10
|
+
openDocFile,
|
|
11
|
+
isDocDesktop,
|
|
12
|
+
} from "../../docfile/doc-file-state";
|
|
13
|
+
|
|
14
|
+
const schema = z.object({
|
|
15
|
+
type: z.literal("document"),
|
|
16
|
+
content: z.object({
|
|
17
|
+
title: z
|
|
18
|
+
.string()
|
|
19
|
+
.describe("Row title. Leave it blank to show the file name without its extension; type a title to override that."),
|
|
20
|
+
doc: z.object({
|
|
21
|
+
documentId: z
|
|
22
|
+
.string()
|
|
23
|
+
.default("")
|
|
24
|
+
.describe(
|
|
25
|
+
"Hash key into the document manifest (src/content/document-manifest.json). Deliberately NOT `imageId`: documents are not resolved by the media ref-baker.",
|
|
26
|
+
),
|
|
27
|
+
}),
|
|
28
|
+
showInDocumentsNav: z
|
|
29
|
+
.boolean()
|
|
30
|
+
.default(true)
|
|
31
|
+
.describe(
|
|
32
|
+
"When true, this document also appears in the auto-derived Documents group at the bottom of the left navigation, openable from any page.",
|
|
33
|
+
),
|
|
34
|
+
}),
|
|
35
|
+
options: z.object({}).default({}),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** Subscribes to the site-wide document list published by the DocFileOverlay
|
|
39
|
+
* island (viewer: SSR-derived; editor: live from EditorShell). `null` means
|
|
40
|
+
* "not seeded yet" — distinct from "seeded, and this id isn't in it", which is
|
|
41
|
+
* the missing-file state. */
|
|
42
|
+
function useSiteDocument(sectionId: string): { doc: SiteDocument | null; resolved: boolean } {
|
|
43
|
+
const [docs, setDocs] = useState<SiteDocument[] | null>(null);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
setDocs(getSiteDocuments());
|
|
46
|
+
return subscribeSiteDocuments(setDocs);
|
|
47
|
+
}, []);
|
|
48
|
+
return {
|
|
49
|
+
doc: docs ? (docs.find((d) => d.sectionId === sectionId) ?? null) : null,
|
|
50
|
+
resolved: docs !== null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function DocumentRow({
|
|
55
|
+
sectionId,
|
|
56
|
+
title,
|
|
57
|
+
isEditMode,
|
|
58
|
+
}: {
|
|
59
|
+
sectionId: string;
|
|
60
|
+
title: string;
|
|
61
|
+
isEditMode: boolean;
|
|
62
|
+
}) {
|
|
63
|
+
const { doc, resolved } = useSiteDocument(sectionId);
|
|
64
|
+
|
|
65
|
+
// Viewers never see a row whose bytes are gone — deriveSiteDocuments already
|
|
66
|
+
// dropped it, so absence here is authoritative. Editors keep a muted row so
|
|
67
|
+
// the section stays selectable and repairable.
|
|
68
|
+
if (resolved && !doc && !isEditMode) return null;
|
|
69
|
+
|
|
70
|
+
// `doc.title` is already `content.title || filenameStem(entry.filename)` —
|
|
71
|
+
// deriveSiteDocuments applies the fallback once so the row, the Documents nav
|
|
72
|
+
// entry and the overlay top bar can never disagree. Picking a file NEVER
|
|
73
|
+
// writes `title` (reconciliation decision 6). `title` (raw section content)
|
|
74
|
+
// is only the pre-seed placeholder, before the registry has published.
|
|
75
|
+
const label = doc?.title ?? title;
|
|
76
|
+
|
|
77
|
+
function handleClick() {
|
|
78
|
+
if (!doc) return;
|
|
79
|
+
// iOS Safari cannot scroll an iframed PDF and Android Chrome forces a
|
|
80
|
+
// download — hand phones the OS viewer instead. HTML still uses the overlay.
|
|
81
|
+
if (doc.kind === "pdf" && !isDocDesktop()) {
|
|
82
|
+
window.open(doc.url, "_blank", "noopener,noreferrer");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
openDocFile(sectionId);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
onClick={handleClick}
|
|
92
|
+
disabled={!doc}
|
|
93
|
+
aria-label={doc ? `Open ${label}` : undefined}
|
|
94
|
+
className={cn(
|
|
95
|
+
"flex w-full min-w-0 items-center gap-3 rounded-outer border border-base-200 bg-base px-4 py-3 text-left transition-colors",
|
|
96
|
+
doc ? "cursor-pointer hover:bg-base-accent" : "cursor-default opacity-60",
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
<FileText size={18} aria-hidden="true" className="shrink-0 text-base-contrast/60" />
|
|
100
|
+
<div className="min-w-0 flex-1">
|
|
101
|
+
<div className="flex items-center gap-2">
|
|
102
|
+
<span className="truncate text-sm font-medium text-base-contrast">{label}</span>
|
|
103
|
+
{doc && (
|
|
104
|
+
<span className="shrink-0 rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">
|
|
105
|
+
{doc.kind === "pdf" ? "PDF" : "HTML"}
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
<div className="truncate text-xs text-base-contrast/70">
|
|
110
|
+
{doc ? `${doc.filename} · ${formatDocSize(doc.size)}` : resolved ? "File missing" : "\u00a0"}
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
{doc && <ExternalLink size={16} aria-hidden="true" className="shrink-0 text-base-contrast/50" />}
|
|
114
|
+
</button>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default defineSection({
|
|
119
|
+
type: "document",
|
|
120
|
+
category: "brand-guide",
|
|
121
|
+
label: "Document",
|
|
122
|
+
icon: <FileText size={18} />,
|
|
123
|
+
schema,
|
|
124
|
+
// The row reads the client-side document registry and opens the overlay, so
|
|
125
|
+
// the viewer hydrates it as an island rather than emitting static HTML.
|
|
126
|
+
interactive: true,
|
|
127
|
+
authoringNote:
|
|
128
|
+
"One downloadable/viewable file — a PDF or a single self-contained HTML file — presented as a clickable row. " +
|
|
129
|
+
"Clicking opens it in a full-viewport preview overlay without leaving the page; on phones a PDF opens in the OS viewer instead. " +
|
|
130
|
+
"Pick the file from the media library (documents tab); leave the title blank to show the file name without its extension, or type one to override it. " +
|
|
131
|
+
"Leave 'Show in Documents menu' on for anything a viewer should be able to reach from any page — those entries collect into a derived Documents group at the bottom of the left nav. " +
|
|
132
|
+
"Use one section per file; for a set of downloads, place several document sections in a container.",
|
|
133
|
+
component: ({ content, isEditMode }) => (
|
|
134
|
+
<DocumentRow
|
|
135
|
+
// The section id is added by the outer Section schema (id + layout
|
|
136
|
+
// intersected with the per-type schema), so it is present at render time
|
|
137
|
+
// but absent from this type's inferred content shape.
|
|
138
|
+
sectionId={(content as unknown as { id?: string }).id ?? ""}
|
|
139
|
+
title={content.content.title}
|
|
140
|
+
isEditMode={isEditMode}
|
|
141
|
+
/>
|
|
142
|
+
),
|
|
143
|
+
defaults: () => ({
|
|
144
|
+
type: "document" as const,
|
|
145
|
+
content: { title: "", doc: { documentId: "" }, showInDocumentsNav: true },
|
|
146
|
+
options: {},
|
|
147
|
+
}),
|
|
148
|
+
getLabel: (content) => content.content.title || "Document",
|
|
149
|
+
settings: {
|
|
150
|
+
doc: { type: "document-source", label: "File", target: "content", default: { documentId: "" } },
|
|
151
|
+
title: { type: "text", label: "Title", target: "content", default: "", placeholder: "Document title" },
|
|
152
|
+
showInDocumentsNav: {
|
|
153
|
+
type: "checkbox",
|
|
154
|
+
label: "Show in Documents menu",
|
|
155
|
+
target: "content",
|
|
156
|
+
default: true,
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
settingsTabs: [
|
|
160
|
+
{ label: "File", fields: ["doc", "title"] },
|
|
161
|
+
{ label: "Display", fields: ["showInDocumentsNav"] },
|
|
162
|
+
],
|
|
163
|
+
});
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import colors from "./Colors";
|
|
2
2
|
import iconList from "./IconList";
|
|
3
3
|
import doDontMedia from "./DoDontMedia";
|
|
4
|
+
import documentSection from "./Document";
|
|
4
5
|
|
|
5
6
|
/** Opinionated brand-guide sections. Self-contained subtree: a build that does
|
|
6
7
|
* not import this module drops these sections (and their cmyk/pantone schemas
|
|
7
8
|
* and do/dont compositions) entirely. Exposed at
|
|
8
9
|
* `@drawnagency/primitives/components/sections/brand-guide`. */
|
|
9
10
|
// Un-annotated for the same generic-variance reason as coreSectionDefs.
|
|
10
|
-
export const brandGuideSectionDefs = [colors, iconList, doDontMedia];
|
|
11
|
+
export const brandGuideSectionDefs = [colors, iconList, doDontMedia, documentSection];
|
|
11
12
|
|
|
12
13
|
export { ColorSpaceSchema, ColorItemSchema } from "./Colors/schema";
|
|
13
14
|
export type { ColorSpace, ColorItem } from "./Colors/schema";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { FormLabel } from "./FormLabel";
|
|
2
|
+
import { Button } from "./Button";
|
|
3
|
+
import { useMediaLibrary } from "../shell/MediaLibraryContext";
|
|
4
|
+
import { useResolvedDocument } from "../../hooks/useResolvedDocument";
|
|
5
|
+
import { formatFileSize } from "../../media/utils";
|
|
6
|
+
|
|
7
|
+
export interface DocumentRef {
|
|
8
|
+
documentId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
value: DocumentRef | undefined;
|
|
13
|
+
onChange: (value: DocumentRef) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Picker for the `document` section type. Writes only { documentId } — the
|
|
17
|
+
* filename/size shown here are read back out of the sidecar manifest, never
|
|
18
|
+
* copied into section content. */
|
|
19
|
+
export function DocumentSourceField({ value, onChange }: Props) {
|
|
20
|
+
const mediaLibrary = useMediaLibrary();
|
|
21
|
+
const documentId = value?.documentId || undefined;
|
|
22
|
+
const { entry, missing } = useResolvedDocument(documentId);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex flex-col gap-3">
|
|
26
|
+
<FormLabel>Document</FormLabel>
|
|
27
|
+
<div className="flex items-center gap-3">
|
|
28
|
+
{entry ? (
|
|
29
|
+
<span className="min-w-0 flex-1 truncate text-sm text-base-contrast">
|
|
30
|
+
{entry.filename}
|
|
31
|
+
<span className="text-base-contrast-light"> · {formatFileSize(entry.size)}</span>
|
|
32
|
+
</span>
|
|
33
|
+
) : (
|
|
34
|
+
<span className="flex-1 text-sm text-base-contrast-light">
|
|
35
|
+
{missing ? "File missing" : "No document selected"}
|
|
36
|
+
</span>
|
|
37
|
+
)}
|
|
38
|
+
<Button
|
|
39
|
+
type="button"
|
|
40
|
+
variant="secondary"
|
|
41
|
+
size="sm"
|
|
42
|
+
onClick={() =>
|
|
43
|
+
mediaLibrary?.openSelectModal((id) => onChange({ documentId: id }), { kinds: ["document"] })
|
|
44
|
+
}
|
|
45
|
+
>
|
|
46
|
+
{documentId ? "Replace document" : "Choose document"}
|
|
47
|
+
</Button>
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { useState, useCallback, useEffect, useRef } from "react";
|
|
1
|
+
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
|
|
2
2
|
import { ChevronRight } from "lucide-react";
|
|
3
3
|
import { cn } from "../../lib/cn";
|
|
4
4
|
import { Toggle } from "./Toggle";
|
|
5
|
-
import type { SiteNav, NavItem, GroupNavItem, PageNavItem } from "../../lib/nav";
|
|
5
|
+
import type { SiteNav, NavItem, GroupNavItem, PageNavItem, DocGroupNavItem, DocNavItem } from "../../lib/nav";
|
|
6
6
|
import { groupDisplayLabel } from "../../lib/nav-tree";
|
|
7
|
+
import { openDocFile, closeDocFile } from "../docfile/doc-file-state";
|
|
7
8
|
import { editModeEvent, siteNavChangeEvent, pageSelectEvent, darkModeEvent, siteNameChangeEvent, historySelectEvent, editorDirtyEvent } from "../../lib/events";
|
|
8
9
|
import { useActiveHeadings } from "../../hooks/useActiveHeadings";
|
|
9
10
|
import { formatDate } from "../../lib/timestamp";
|
|
@@ -81,7 +82,9 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
81
82
|
|
|
82
83
|
// The active page can live in any tier (editors navigate to hidden and
|
|
83
84
|
// archived pages via the Pages modal).
|
|
84
|
-
const treePages = siteNav.entries.flatMap((e) =>
|
|
85
|
+
const treePages = siteNav.entries.flatMap((e) =>
|
|
86
|
+
e.kind === "group" ? e.pages : e.kind === "page" ? [e] : [],
|
|
87
|
+
);
|
|
85
88
|
const activePage =
|
|
86
89
|
[...treePages, ...siteNav.hidden, ...siteNav.archive].find((p) => p.isActive) ?? null;
|
|
87
90
|
const headings: NavItem[] = activePage?.headings ?? [];
|
|
@@ -100,6 +103,7 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
100
103
|
useActiveHeadings(parentIds, childIdsByParent, grandchildIdsByChild);
|
|
101
104
|
|
|
102
105
|
const handleHeadingNav = useCallback((href: string) => {
|
|
106
|
+
closeDocFile(); // a nav heading click always leaves doc mode first
|
|
103
107
|
const id = href.replace("#", "");
|
|
104
108
|
const el = document.getElementById(id);
|
|
105
109
|
if (el) { el.scrollIntoView({ behavior: "smooth" }); setActiveSection(id); }
|
|
@@ -206,11 +210,12 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
206
210
|
</li>
|
|
207
211
|
);
|
|
208
212
|
|
|
209
|
-
// The one collapsible-section body, shared by authored groups
|
|
210
|
-
// editor buckets
|
|
211
|
-
// wrapper element, because authored groups
|
|
212
|
-
// main nav list while the buckets are their
|
|
213
|
-
|
|
213
|
+
// The one collapsible-section body, shared by authored groups, the derived
|
|
214
|
+
// editor buckets and the derived Documents group so they can never drift
|
|
215
|
+
// apart visually. Callers supply the wrapper element, because authored groups
|
|
216
|
+
// are interleaved <li>s inside the main nav list while the buckets are their
|
|
217
|
+
// own blocks below it.
|
|
218
|
+
const renderCollapsible = (key: string, label: string, defaultOpen: boolean, body: ReactNode) => {
|
|
214
219
|
const isOpen = openGroups[key] ?? defaultOpen;
|
|
215
220
|
return (
|
|
216
221
|
<>
|
|
@@ -223,11 +228,45 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
223
228
|
<span>{label}</span>
|
|
224
229
|
<ChevronRight size={14} aria-hidden="true" className={cn("transition-transform", isOpen && "rotate-90")} />
|
|
225
230
|
</button>
|
|
226
|
-
{isOpen &&
|
|
231
|
+
{isOpen && body}
|
|
227
232
|
</>
|
|
228
233
|
);
|
|
229
234
|
};
|
|
230
235
|
|
|
236
|
+
const renderGroupBody = (key: string, label: string, pages: PageNavItem[], defaultOpen: boolean) =>
|
|
237
|
+
renderCollapsible(key, label, defaultOpen, <ul className="space-y-1">{pages.map(renderPage)}</ul>);
|
|
238
|
+
|
|
239
|
+
// Documents are launchers, not destinations: the viewer sets the deep-link
|
|
240
|
+
// hash (the overlay atom reads it), the editor opens the atom directly —
|
|
241
|
+
// never pageSelectEvent, which would dead-end on a page that doesn't exist.
|
|
242
|
+
const renderDoc = (doc: DocNavItem) => (
|
|
243
|
+
<li key={doc.sectionId}>
|
|
244
|
+
{asButton ? (
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={() => { openDocFile(doc.sectionId); setIsOpen(false); }}
|
|
248
|
+
className="cursor-pointer w-full rounded-inner px-3 py-2 text-left text-sm font-bold text-base-contrast transition-colors hover:text-primary"
|
|
249
|
+
>
|
|
250
|
+
{doc.title}
|
|
251
|
+
</button>
|
|
252
|
+
) : (
|
|
253
|
+
<a
|
|
254
|
+
href={`#doc=${doc.sectionId}`}
|
|
255
|
+
onClick={() => setIsOpen(false)}
|
|
256
|
+
className="block w-full rounded-inner px-3 py-2 text-left text-sm font-bold text-base-contrast transition-colors hover:text-primary"
|
|
257
|
+
>
|
|
258
|
+
{doc.title}
|
|
259
|
+
</a>
|
|
260
|
+
)}
|
|
261
|
+
</li>
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const renderDocGroup = (group: DocGroupNavItem) => (
|
|
265
|
+
<li key={group.id} className="mt-2 border-t border-base-200 pt-2">
|
|
266
|
+
{renderCollapsible(group.id, group.label, true, <ul className="space-y-1">{group.docs.map(renderDoc)}</ul>)}
|
|
267
|
+
</li>
|
|
268
|
+
);
|
|
269
|
+
|
|
231
270
|
const renderAuthoredGroup = (group: GroupNavItem) => (
|
|
232
271
|
// No mx-4: the enclosing list is already padded, unlike the bucket blocks.
|
|
233
272
|
<li key={group.id} className="mt-2 border-t border-base-200 pt-2">
|
|
@@ -288,7 +327,11 @@ export default function Navigation({ siteNav: initialNav, siteName, darkMode, la
|
|
|
288
327
|
|
|
289
328
|
<ul className="space-y-1 px-4 py-2">
|
|
290
329
|
{siteNav.entries.map((entry) =>
|
|
291
|
-
entry.kind === "group"
|
|
330
|
+
entry.kind === "group"
|
|
331
|
+
? renderAuthoredGroup(entry)
|
|
332
|
+
: entry.kind === "doc-group"
|
|
333
|
+
? renderDocGroup(entry)
|
|
334
|
+
: renderPage(entry),
|
|
292
335
|
)}
|
|
293
336
|
</ul>
|
|
294
337
|
|
|
@@ -4,6 +4,7 @@ import { RemoveIcon } from "../shared/icons";
|
|
|
4
4
|
import { IconButton } from "../shared/IconButton";
|
|
5
5
|
import { useFocusTrap } from "../../hooks/useFocusTrap";
|
|
6
6
|
import { cn } from "../../lib/cn";
|
|
7
|
+
import { lockBodyScroll } from "../../lib/scroll-lock";
|
|
7
8
|
|
|
8
9
|
interface EditorModalProps {
|
|
9
10
|
isOpen: boolean;
|
|
@@ -37,11 +38,7 @@ export function EditorModal({ isOpen, onClose, title, blocking = false, size = "
|
|
|
37
38
|
|
|
38
39
|
useEffect(() => {
|
|
39
40
|
if (!isOpen) return;
|
|
40
|
-
|
|
41
|
-
document.body.style.overflow = "hidden";
|
|
42
|
-
return () => {
|
|
43
|
-
document.body.style.overflow = original;
|
|
44
|
-
};
|
|
41
|
+
return lockBodyScroll();
|
|
45
42
|
}, [isOpen]);
|
|
46
43
|
|
|
47
44
|
if (!isOpen) return null;
|