@drawnagency/primitives 0.1.99 → 0.1.101

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/dist/{chunk-FAZFADJK.js → chunk-3TO4RRHY.js} +3 -1
  2. package/dist/{chunk-HKHEAH5F.js → chunk-6SLGKRLW.js} +8 -60
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  5. package/dist/{chunk-KP4DWTPR.js → chunk-P3NNI3QI.js} +328 -4
  6. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  7. package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
  8. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
  9. package/dist/components/docfile/DocFilePane.d.ts +17 -0
  10. package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
  11. package/dist/components/docfile/doc-file-state.d.ts +38 -0
  12. package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
  13. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  14. package/dist/components/editor/index.js +1 -1
  15. package/dist/components/primitives/DocumentDropZone.d.ts +18 -0
  16. package/dist/components/primitives/DocumentDropZone.d.ts.map +1 -0
  17. package/dist/components/sections/Document/index.d.ts +24 -0
  18. package/dist/components/sections/Document/index.d.ts.map +1 -0
  19. package/dist/components/sections/all-sections.d.ts +10 -0
  20. package/dist/components/sections/all-sections.d.ts.map +1 -1
  21. package/dist/components/sections/brand-guide.d.ts +10 -0
  22. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  23. package/dist/components/sections/brand-guide.js +3 -1
  24. package/dist/components/sections/register-schemas.js +5 -3
  25. package/dist/components/sections/register.js +5 -3
  26. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  27. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  28. package/dist/components/shared/Navigation.d.ts.map +1 -1
  29. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  30. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  31. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  32. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  33. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  34. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  35. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  36. package/dist/components/shell/MediaLibraryContext.d.ts +24 -1
  37. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  38. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  39. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  40. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  41. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  42. package/dist/hooks/useEditorPublish.d.ts +18 -1
  43. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  44. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  45. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  46. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  47. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  48. package/dist/index.js +56 -32
  49. package/dist/lib/dexie.d.ts +13 -0
  50. package/dist/lib/dexie.d.ts.map +1 -1
  51. package/dist/lib/dexie.js +39 -2
  52. package/dist/lib/document-refs.d.ts +22 -0
  53. package/dist/lib/document-refs.d.ts.map +1 -0
  54. package/dist/lib/document-uploads.d.ts +38 -0
  55. package/dist/lib/document-uploads.d.ts.map +1 -0
  56. package/dist/lib/documents.d.ts +65 -0
  57. package/dist/lib/documents.d.ts.map +1 -0
  58. package/dist/lib/index.js +4 -4
  59. package/dist/lib/media-folders.d.ts +8 -0
  60. package/dist/lib/media-folders.d.ts.map +1 -1
  61. package/dist/lib/media-folders.js +2 -0
  62. package/dist/lib/nav.d.ts +29 -1
  63. package/dist/lib/nav.d.ts.map +1 -1
  64. package/dist/lib/platform-broker.d.ts +12 -0
  65. package/dist/lib/platform-broker.d.ts.map +1 -1
  66. package/dist/lib/platform-broker.js +12 -3
  67. package/dist/lib/registry.d.ts +10 -0
  68. package/dist/lib/registry.d.ts.map +1 -1
  69. package/dist/lib/save-chunks.d.ts +15 -0
  70. package/dist/lib/save-chunks.d.ts.map +1 -1
  71. package/dist/lib/scroll-lock.d.ts +2 -0
  72. package/dist/lib/scroll-lock.d.ts.map +1 -0
  73. package/dist/schemas/documents.d.ts +80 -0
  74. package/dist/schemas/documents.d.ts.map +1 -0
  75. package/dist/schemas/documents.js +25 -0
  76. package/dist/schemas/index.d.ts +1 -0
  77. package/dist/schemas/index.d.ts.map +1 -1
  78. package/dist/schemas/index.js +23 -1
  79. package/dist/storage/index.d.ts +2 -2
  80. package/dist/storage/index.d.ts.map +1 -1
  81. package/dist/storage/index.js +3 -1
  82. package/dist/storage/types.d.ts +59 -3
  83. package/dist/storage/types.d.ts.map +1 -1
  84. package/package.json +5 -1
  85. package/src/components/chat/ChatPanel.tsx +5 -4
  86. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  87. package/src/components/docfile/DocFilePane.tsx +133 -0
  88. package/src/components/docfile/doc-file-state.ts +208 -0
  89. package/src/components/editor/SettingsForm.tsx +10 -0
  90. package/src/components/primitives/DocumentDropZone.tsx +79 -0
  91. package/src/components/sections/Document/index.tsx +194 -0
  92. package/src/components/sections/brand-guide.ts +2 -1
  93. package/src/components/shared/DocumentSourceField.tsx +51 -0
  94. package/src/components/shared/Navigation.tsx +53 -10
  95. package/src/components/shell/EditorModal.tsx +2 -5
  96. package/src/components/shell/EditorShell.tsx +132 -8
  97. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  98. package/src/components/shell/MediaFolderTile.tsx +4 -2
  99. package/src/components/shell/MediaFolderTree.tsx +20 -3
  100. package/src/components/shell/MediaLibraryContext.tsx +24 -1
  101. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  102. package/src/hooks/useDocumentUploads.ts +336 -0
  103. package/src/hooks/useEditorPublish.ts +180 -8
  104. package/src/hooks/useMediaPipeline.ts +141 -5
  105. package/src/hooks/useResolvedDocument.ts +25 -0
  106. package/src/lib/dexie.ts +66 -1
  107. package/src/lib/document-refs.ts +83 -0
  108. package/src/lib/document-uploads.ts +66 -0
  109. package/src/lib/documents.ts +138 -0
  110. package/src/lib/media-folders.ts +9 -0
  111. package/src/lib/nav.ts +46 -2
  112. package/src/lib/platform-broker.ts +18 -3
  113. package/src/lib/registry.ts +9 -0
  114. package/src/lib/save-chunks.ts +42 -0
  115. package/src/lib/scroll-lock.ts +27 -0
  116. package/src/schemas/documents.ts +82 -0
  117. package/src/schemas/index.ts +1 -0
  118. package/src/storage/index.ts +2 -2
  119. package/src/storage/types.ts +58 -4
  120. /package/dist/{chunk-FOY5TF7D.js → chunk-ZDQ3TT47.js} +0 -0
@@ -0,0 +1,79 @@
1
+ import { useState, type DragEvent } from "react";
2
+ import { FileUp } from "lucide-react";
3
+ import { cn } from "../../lib/cn";
4
+ import { DOCUMENT_MIME_TO_EXT } from "../../schemas/documents";
5
+ import { useMediaLibrary } from "../shell/MediaLibraryContext";
6
+
7
+ interface DocumentDropZoneProps {
8
+ /** Fired with the staged/committed document id once the upload resolves. */
9
+ onDocumentSelected: (documentId: string) => void;
10
+ className?: string;
11
+ }
12
+
13
+ const isDocumentFile = (file: File) => file.type in DOCUMENT_MIME_TO_EXT;
14
+
15
+ /**
16
+ * The empty state of a `document` section in edit mode: drop a PDF/HTML file to
17
+ * upload it, or click to pick one from the library. Editor-only — the viewer
18
+ * never renders an unconfigured document section at all.
19
+ *
20
+ * Deliberately thin on validation: every rejection (wrong type, too large, no
21
+ * documents store) belongs to `uploadDocument`, which already routes typed copy
22
+ * through the pipeline's `onUploadError` → `UploadRejectionAlert` channel. The
23
+ * one judgement made here is WHICH file of a multi-file drop to hand over.
24
+ */
25
+ export function DocumentDropZone({ onDocumentSelected, className }: DocumentDropZoneProps) {
26
+ const mediaLibrary = useMediaLibrary();
27
+ const [dragging, setDragging] = useState(false);
28
+
29
+ // Only react to drags carrying files, so the editor's block reorder (which
30
+ // carries no `Files`) passes straight through. Mirrors ImageDropZone.
31
+ const hasFiles = (e: DragEvent) => e.dataTransfer.types.includes("Files");
32
+
33
+ const handleDragOver = (e: DragEvent) => {
34
+ if (!hasFiles(e)) return;
35
+ e.preventDefault();
36
+ e.dataTransfer.dropEffect = "copy";
37
+ setDragging(true);
38
+ };
39
+
40
+ const handleDragLeave = (e: DragEvent) => {
41
+ if (!hasFiles(e)) return;
42
+ const related = e.relatedTarget as Node | null;
43
+ if (related && e.currentTarget.contains(related)) return;
44
+ setDragging(false);
45
+ };
46
+
47
+ const handleDrop = (e: DragEvent) => {
48
+ if (!hasFiles(e)) return;
49
+ e.preventDefault();
50
+ e.stopPropagation();
51
+ setDragging(false);
52
+ const files = Array.from(e.dataTransfer.files);
53
+ if (files.length === 0) return;
54
+ // First document-typed file wins. With none, the first file still goes to
55
+ // the pipeline so its rejection names the real constraint ("only PDF and
56
+ // HTML documents can be uploaded") instead of a hand-rolled message here.
57
+ void mediaLibrary?.uploadDocument?.(files.find(isDocumentFile) ?? files[0], onDocumentSelected);
58
+ };
59
+
60
+ return (
61
+ <button
62
+ type="button"
63
+ onClick={() =>
64
+ mediaLibrary?.openSelectModal(onDocumentSelected, { kinds: ["document"] })
65
+ }
66
+ onDragOver={handleDragOver}
67
+ onDragLeave={handleDragLeave}
68
+ onDrop={handleDrop}
69
+ className={cn(
70
+ "flex w-full cursor-pointer items-center justify-center gap-2 rounded-outer border-2 border-dashed border-base-200 bg-base px-4 py-6 text-sm text-base-contrast-light transition-colors hover:border-base-300 hover:bg-base-accent hover:text-base-contrast",
71
+ dragging && "border-primary bg-primary/5 text-base-contrast",
72
+ className,
73
+ )}
74
+ >
75
+ <FileUp size={18} aria-hidden="true" className="shrink-0" />
76
+ <span>Drop a PDF or HTML file, or choose from the library</span>
77
+ </button>
78
+ );
79
+ }
@@ -0,0 +1,194 @@
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 { DocumentDropZone } from "../../primitives/DocumentDropZone";
7
+ import { formatDocSize, type SiteDocument } from "../../../lib/documents";
8
+ import {
9
+ getSiteDocuments,
10
+ subscribeSiteDocuments,
11
+ openDocFile,
12
+ isDocDesktop,
13
+ } from "../../docfile/doc-file-state";
14
+
15
+ const schema = z.object({
16
+ type: z.literal("document"),
17
+ content: z.object({
18
+ title: z
19
+ .string()
20
+ .describe("Row title. Leave it blank to show the file name without its extension; type a title to override that."),
21
+ doc: z.object({
22
+ documentId: z
23
+ .string()
24
+ .default("")
25
+ .describe(
26
+ "Hash key into the document manifest (src/content/document-manifest.json). Deliberately NOT `imageId`: documents are not resolved by the media ref-baker.",
27
+ ),
28
+ }),
29
+ showInDocumentsNav: z
30
+ .boolean()
31
+ .default(true)
32
+ .describe(
33
+ "When true, this document also appears in the auto-derived Documents group at the bottom of the left navigation, openable from any page.",
34
+ ),
35
+ }),
36
+ options: z.object({}).default({}),
37
+ });
38
+
39
+ /** Subscribes to the site-wide document list published by the DocFileOverlay
40
+ * island (viewer: SSR-derived; editor: live from EditorShell). `null` means
41
+ * "not seeded yet" — distinct from "seeded, and this id isn't in it", which is
42
+ * the missing-file state. */
43
+ function useSiteDocument(sectionId: string): { doc: SiteDocument | null; resolved: boolean } {
44
+ const [docs, setDocs] = useState<SiteDocument[] | null>(null);
45
+ useEffect(() => {
46
+ setDocs(getSiteDocuments());
47
+ return subscribeSiteDocuments(setDocs);
48
+ }, []);
49
+ return {
50
+ doc: docs ? (docs.find((d) => d.sectionId === sectionId) ?? null) : null,
51
+ resolved: docs !== null,
52
+ };
53
+ }
54
+
55
+ export function DocumentRow({
56
+ sectionId,
57
+ title,
58
+ documentId = "",
59
+ isEditMode,
60
+ onDocumentSelected,
61
+ }: {
62
+ sectionId: string;
63
+ title: string;
64
+ /** Raw `content.doc.documentId`. Empty means UNCONFIGURED, which is a
65
+ * different state from an id that no longer resolves (see below). */
66
+ documentId?: string;
67
+ isEditMode: boolean;
68
+ /** Present only when the section is editable — wires a picked/uploaded id
69
+ * back into content. Absent for viewers and for a locked/read-only render. */
70
+ onDocumentSelected?: (documentId: string) => void;
71
+ }) {
72
+ const { doc, resolved } = useSiteDocument(sectionId);
73
+
74
+ // Viewers never see a row whose bytes are gone — deriveSiteDocuments already
75
+ // dropped it, so absence here is authoritative. Editors keep a muted row so
76
+ // the section stays selectable and repairable.
77
+ if (resolved && !doc && !isEditMode) return null;
78
+
79
+ // An empty id is an unconfigured section: give editors somewhere to drop a
80
+ // file instead of a dead, permanently-disabled row. A NON-empty id that does
81
+ // not resolve is a different problem — it keeps the "File missing" row so the
82
+ // broken reference stays visible rather than looking like a fresh section.
83
+ if (isEditMode && !documentId && onDocumentSelected) {
84
+ return <DocumentDropZone onDocumentSelected={onDocumentSelected} />;
85
+ }
86
+
87
+ // `doc.title` is already `content.title || filenameStem(entry.filename)` —
88
+ // deriveSiteDocuments applies the fallback once so the row, the Documents nav
89
+ // entry and the overlay top bar can never disagree. Picking a file NEVER
90
+ // writes `title` (reconciliation decision 6). `title` (raw section content)
91
+ // is only the pre-seed placeholder, before the registry has published.
92
+ const label = doc?.title ?? title;
93
+
94
+ function handleClick() {
95
+ if (!doc) return;
96
+ // iOS Safari cannot scroll an iframed PDF and Android Chrome forces a
97
+ // download — hand phones the OS viewer instead. HTML still uses the overlay.
98
+ if (doc.kind === "pdf" && !isDocDesktop()) {
99
+ window.open(doc.url, "_blank", "noopener,noreferrer");
100
+ return;
101
+ }
102
+ openDocFile(sectionId);
103
+ }
104
+
105
+ return (
106
+ <button
107
+ type="button"
108
+ onClick={handleClick}
109
+ disabled={!doc}
110
+ aria-label={doc ? `Open ${label}` : undefined}
111
+ className={cn(
112
+ "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",
113
+ doc ? "cursor-pointer hover:bg-base-accent" : "cursor-default opacity-60",
114
+ )}
115
+ >
116
+ <FileText size={18} aria-hidden="true" className="shrink-0 text-base-contrast/60" />
117
+ <div className="min-w-0 flex-1">
118
+ <div className="flex items-center gap-2">
119
+ <span className="truncate text-sm font-medium text-base-contrast">{label}</span>
120
+ {doc && (
121
+ <span className="shrink-0 rounded bg-base-100 px-1.5 py-0.5 text-xs font-medium text-base-contrast">
122
+ {doc.kind === "pdf" ? "PDF" : "HTML"}
123
+ </span>
124
+ )}
125
+ </div>
126
+ <div className="truncate text-xs text-base-contrast/70">
127
+ {doc ? `${doc.filename} · ${formatDocSize(doc.size)}` : resolved ? "File missing" : "\u00a0"}
128
+ </div>
129
+ </div>
130
+ {doc && <ExternalLink size={16} aria-hidden="true" className="shrink-0 text-base-contrast/50" />}
131
+ </button>
132
+ );
133
+ }
134
+
135
+ export default defineSection({
136
+ type: "document",
137
+ category: "brand-guide",
138
+ label: "Document",
139
+ icon: <FileText size={18} />,
140
+ schema,
141
+ // The row reads the client-side document registry and opens the overlay, so
142
+ // the viewer hydrates it as an island rather than emitting static HTML.
143
+ interactive: true,
144
+ authoringNote:
145
+ "One downloadable/viewable file — a PDF or a single self-contained HTML file — presented as a clickable row. " +
146
+ "Clicking opens it in a full-viewport preview overlay without leaving the page; on phones a PDF opens in the OS viewer instead. " +
147
+ "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. " +
148
+ "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. " +
149
+ "Use one section per file; for a set of downloads, place several document sections in a container.",
150
+ component: ({ content, isEditMode, onChange }) => (
151
+ <DocumentRow
152
+ // The section id is added by the outer Section schema (id + layout
153
+ // intersected with the per-type schema), so it is present at render time
154
+ // but absent from this type's inferred content shape.
155
+ sectionId={(content as unknown as { id?: string }).id ?? ""}
156
+ title={content.content.title}
157
+ documentId={content.content.doc?.documentId ?? ""}
158
+ isEditMode={isEditMode}
159
+ // Writes `doc` only: `title` stays the editor's (deriveSiteDocuments
160
+ // applies the filename-stem fallback for display) and showInDocumentsNav
161
+ // is untouched. Spreading `content` preserves the id and layout envelope,
162
+ // which the container path (Container.tsx updateChild) also re-attaches.
163
+ onDocumentSelected={
164
+ onChange
165
+ ? (documentId) =>
166
+ onChange({
167
+ ...content,
168
+ content: { ...content.content, doc: { ...content.content.doc, documentId } },
169
+ })
170
+ : undefined
171
+ }
172
+ />
173
+ ),
174
+ defaults: () => ({
175
+ type: "document" as const,
176
+ content: { title: "", doc: { documentId: "" }, showInDocumentsNav: true },
177
+ options: {},
178
+ }),
179
+ getLabel: (content) => content.content.title || "Document",
180
+ settings: {
181
+ doc: { type: "document-source", label: "File", target: "content", default: { documentId: "" } },
182
+ title: { type: "text", label: "Title", target: "content", default: "", placeholder: "Document title" },
183
+ showInDocumentsNav: {
184
+ type: "checkbox",
185
+ label: "Show in Documents menu",
186
+ target: "content",
187
+ default: true,
188
+ },
189
+ },
190
+ settingsTabs: [
191
+ { label: "File", fields: ["doc", "title"] },
192
+ { label: "Display", fields: ["showInDocumentsNav"] },
193
+ ],
194
+ });
@@ -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) => (e.kind === "group" ? e.pages : [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 and the derived
210
- // editor buckets so the two can never drift apart visually. Callers supply the
211
- // wrapper element, because authored groups are interleaved <li>s inside the
212
- // main nav list while the buckets are their own blocks below it.
213
- const renderGroupBody = (key: string, label: string, pages: PageNavItem[], defaultOpen: boolean) => {
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 && <ul className="space-y-1">{pages.map(renderPage)}</ul>}
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" ? renderAuthoredGroup(entry) : renderPage(entry),
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
- const original = document.body.style.overflow;
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;