@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.
- package/dist/{chunk-FAZFADJK.js → chunk-3TO4RRHY.js} +3 -1
- package/dist/{chunk-HKHEAH5F.js → chunk-6SLGKRLW.js} +8 -60
- package/dist/chunk-BS5AQHZG.js +45 -0
- package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
- package/dist/{chunk-KP4DWTPR.js → chunk-P3NNI3QI.js} +328 -4
- 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 +38 -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/primitives/DocumentDropZone.d.ts +18 -0
- package/dist/components/primitives/DocumentDropZone.d.ts.map +1 -0
- package/dist/components/sections/Document/index.d.ts +24 -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 +24 -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 +18 -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 +56 -32
- package/dist/lib/dexie.d.ts +13 -0
- package/dist/lib/dexie.d.ts.map +1 -1
- package/dist/lib/dexie.js +39 -2
- 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.js +4 -4
- 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/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 +23 -1
- package/dist/storage/index.d.ts +2 -2
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +3 -1
- package/dist/storage/types.d.ts +59 -3
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/components/chat/ChatPanel.tsx +5 -4
- package/src/components/docfile/DocFileOverlay.tsx +54 -0
- package/src/components/docfile/DocFilePane.tsx +133 -0
- package/src/components/docfile/doc-file-state.ts +208 -0
- package/src/components/editor/SettingsForm.tsx +10 -0
- package/src/components/primitives/DocumentDropZone.tsx +79 -0
- package/src/components/sections/Document/index.tsx +194 -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 +132 -8
- 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 +24 -1
- package/src/components/shell/MediaLibraryModal.tsx +223 -45
- package/src/hooks/useDocumentUploads.ts +336 -0
- package/src/hooks/useEditorPublish.ts +180 -8
- package/src/hooks/useMediaPipeline.ts +141 -5
- package/src/hooks/useResolvedDocument.ts +25 -0
- package/src/lib/dexie.ts +66 -1
- 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/media-folders.ts +9 -0
- package/src/lib/nav.ts +46 -2
- 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/storage/index.ts +2 -2
- package/src/storage/types.ts +58 -4
- /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) =>
|
|
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;
|