@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.
Files changed (138) hide show
  1. package/dist/{chunk-EUSWEVOA.js → chunk-3TO4RRHY.js} +12 -1
  2. package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-NA5EJM6Z.js → chunk-CZLYECHC.js} +19 -8
  5. package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
  6. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  7. package/dist/{chunk-HJSE6SK5.js → chunk-REDDTTGJ.js} +3 -3
  8. package/dist/collab/rebase.d.ts.map +1 -1
  9. package/dist/collab/rebase.js +3 -1
  10. package/dist/components/chat/ChatPanel.d.ts.map +1 -1
  11. package/dist/components/docfile/DocFileOverlay.d.ts +12 -0
  12. package/dist/components/docfile/DocFileOverlay.d.ts.map +1 -0
  13. package/dist/components/docfile/DocFilePane.d.ts +17 -0
  14. package/dist/components/docfile/DocFilePane.d.ts.map +1 -0
  15. package/dist/components/docfile/doc-file-state.d.ts +33 -0
  16. package/dist/components/docfile/doc-file-state.d.ts.map +1 -0
  17. package/dist/components/editor/SettingsForm.d.ts.map +1 -1
  18. package/dist/components/editor/index.js +1 -1
  19. package/dist/components/sections/Document/index.d.ts +18 -0
  20. package/dist/components/sections/Document/index.d.ts.map +1 -0
  21. package/dist/components/sections/all-sections.d.ts +10 -0
  22. package/dist/components/sections/all-sections.d.ts.map +1 -1
  23. package/dist/components/sections/brand-guide.d.ts +10 -0
  24. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  25. package/dist/components/sections/brand-guide.js +3 -1
  26. package/dist/components/sections/register-schemas.js +5 -3
  27. package/dist/components/sections/register.js +5 -3
  28. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  29. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  30. package/dist/components/shared/Navigation.d.ts.map +1 -1
  31. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  32. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  33. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  34. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  35. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  36. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  37. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  38. package/dist/components/shell/MediaLibraryContext.d.ts +18 -1
  39. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  40. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  41. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  42. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  43. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  44. package/dist/hooks/useEditorPublish.d.ts +23 -1
  45. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  46. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  47. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  48. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  49. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  50. package/dist/index.js +60 -34
  51. package/dist/lib/block-move.d.ts.map +1 -1
  52. package/dist/lib/dexie.d.ts +11 -0
  53. package/dist/lib/dexie.d.ts.map +1 -1
  54. package/dist/lib/dexie.js +32 -0
  55. package/dist/lib/document-refs.d.ts +22 -0
  56. package/dist/lib/document-refs.d.ts.map +1 -0
  57. package/dist/lib/document-uploads.d.ts +38 -0
  58. package/dist/lib/document-uploads.d.ts.map +1 -0
  59. package/dist/lib/documents.d.ts +65 -0
  60. package/dist/lib/documents.d.ts.map +1 -0
  61. package/dist/lib/index-ops.d.ts +1 -0
  62. package/dist/lib/index-ops.d.ts.map +1 -1
  63. package/dist/lib/index-ops.js +13 -1
  64. package/dist/lib/index.js +6 -6
  65. package/dist/lib/loader.d.ts +6 -0
  66. package/dist/lib/loader.d.ts.map +1 -1
  67. package/dist/lib/media-folders.d.ts +8 -0
  68. package/dist/lib/media-folders.d.ts.map +1 -1
  69. package/dist/lib/media-folders.js +2 -0
  70. package/dist/lib/nav.d.ts +29 -1
  71. package/dist/lib/nav.d.ts.map +1 -1
  72. package/dist/lib/pages.d.ts +18 -0
  73. package/dist/lib/pages.d.ts.map +1 -1
  74. package/dist/lib/platform-broker.d.ts +12 -0
  75. package/dist/lib/platform-broker.d.ts.map +1 -1
  76. package/dist/lib/platform-broker.js +12 -3
  77. package/dist/lib/registry.d.ts +10 -0
  78. package/dist/lib/registry.d.ts.map +1 -1
  79. package/dist/lib/save-chunks.d.ts +15 -0
  80. package/dist/lib/save-chunks.d.ts.map +1 -1
  81. package/dist/lib/scroll-lock.d.ts +2 -0
  82. package/dist/lib/scroll-lock.d.ts.map +1 -0
  83. package/dist/schemas/documents.d.ts +80 -0
  84. package/dist/schemas/documents.d.ts.map +1 -0
  85. package/dist/schemas/documents.js +25 -0
  86. package/dist/schemas/index.d.ts +1 -0
  87. package/dist/schemas/index.d.ts.map +1 -1
  88. package/dist/schemas/index.js +24 -2
  89. package/dist/storage/index.d.ts +2 -2
  90. package/dist/storage/index.d.ts.map +1 -1
  91. package/dist/storage/index.js +5 -1
  92. package/dist/storage/types.d.ts +69 -5
  93. package/dist/storage/types.d.ts.map +1 -1
  94. package/dist/types/database.d.ts +14 -0
  95. package/dist/types/database.d.ts.map +1 -1
  96. package/package.json +5 -1
  97. package/src/collab/rebase.ts +15 -0
  98. package/src/components/chat/ChatPanel.tsx +5 -4
  99. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  100. package/src/components/docfile/DocFilePane.tsx +129 -0
  101. package/src/components/docfile/doc-file-state.ts +180 -0
  102. package/src/components/editor/SettingsForm.tsx +10 -0
  103. package/src/components/sections/Document/index.tsx +163 -0
  104. package/src/components/sections/brand-guide.ts +2 -1
  105. package/src/components/shared/DocumentSourceField.tsx +51 -0
  106. package/src/components/shared/Navigation.tsx +53 -10
  107. package/src/components/shell/EditorModal.tsx +2 -5
  108. package/src/components/shell/EditorShell.tsx +154 -10
  109. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  110. package/src/components/shell/MediaFolderTile.tsx +4 -2
  111. package/src/components/shell/MediaFolderTree.tsx +20 -3
  112. package/src/components/shell/MediaLibraryContext.tsx +18 -1
  113. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  114. package/src/hooks/useDocumentUploads.ts +330 -0
  115. package/src/hooks/useEditorPublish.ts +197 -11
  116. package/src/hooks/useMediaPipeline.ts +136 -5
  117. package/src/hooks/useResolvedDocument.ts +25 -0
  118. package/src/lib/block-move.ts +15 -4
  119. package/src/lib/dexie.ts +54 -0
  120. package/src/lib/document-refs.ts +83 -0
  121. package/src/lib/document-uploads.ts +66 -0
  122. package/src/lib/documents.ts +138 -0
  123. package/src/lib/index-ops.ts +16 -0
  124. package/src/lib/loader.ts +32 -11
  125. package/src/lib/media-folders.ts +9 -0
  126. package/src/lib/nav.ts +46 -2
  127. package/src/lib/pages.ts +48 -0
  128. package/src/lib/platform-broker.ts +18 -3
  129. package/src/lib/registry.ts +9 -0
  130. package/src/lib/save-chunks.ts +42 -0
  131. package/src/lib/scroll-lock.ts +27 -0
  132. package/src/schemas/documents.ts +82 -0
  133. package/src/schemas/index.ts +1 -0
  134. package/src/schemas/nav.ts +3 -3
  135. package/src/storage/index.ts +2 -2
  136. package/src/storage/types.ts +71 -5
  137. package/src/types/database.ts +14 -0
  138. /package/dist/{chunk-FOY5TF7D.js → chunk-ZDQ3TT47.js} +0 -0
@@ -0,0 +1,330 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { hashFileBuffer } from "../media/utils";
3
+ import type { DocumentManifest, DocumentManifestEntry } from "../schemas/documents";
4
+ import {
5
+ planDocumentUpload,
6
+ documentRejectionMessage,
7
+ documentGitPath,
8
+ documentBucketPath,
9
+ type DocumentRejectionReason,
10
+ } from "../lib/document-uploads";
11
+ import {
12
+ addPendingDocument,
13
+ clearPendingDocumentsByIds,
14
+ getPendingDocuments,
15
+ } from "../lib/dexie";
16
+ import type { PendingDocument } from "../components/shell/MediaLibraryContext";
17
+
18
+ const REJECTION_CODES: ReadonlySet<string> = new Set([
19
+ "unsupported_type",
20
+ "too_large",
21
+ "too_small",
22
+ "no_documents_store",
23
+ ]);
24
+
25
+ interface UseDocumentUploadsInput {
26
+ documentManifest: DocumentManifest;
27
+ setDocumentManifest: React.Dispatch<React.SetStateAction<DocumentManifest>>;
28
+ setLocalChangesExist: React.Dispatch<React.SetStateAction<boolean>>;
29
+ /** Drops a committed-but-locally-deleted id from the pipeline's
30
+ * `pendingDocumentDeletions`. That list lives in useMediaPipeline and is
31
+ * invisible here, so without this the dedup below cannot tell a live
32
+ * document from a doomed one. No-op for an id that isn't doomed. */
33
+ undeleteDocument?: (id: string) => void;
34
+ /** Same channel the media pipeline uses — surfaces in UploadRejectionAlert. */
35
+ onUploadError?: (message: string) => void;
36
+ }
37
+
38
+ /**
39
+ * Document uploads. Deliberately a separate hook from useMediaPipeline: bytes
40
+ * pass through untouched (no rasterizer worker, no variants, no poster), the
41
+ * manifest is a sidecar, and every rejection is typed. Both tiers must (1) never
42
+ * throw to the caller, (2) call onUploadError exactly once on failure, and (3)
43
+ * leave no leaked progress entry.
44
+ */
45
+ export function useDocumentUploads({
46
+ documentManifest,
47
+ setDocumentManifest,
48
+ setLocalChangesExist,
49
+ undeleteDocument,
50
+ onUploadError,
51
+ }: UseDocumentUploadsInput) {
52
+ const [pendingDocuments, setPendingDocuments] = useState<PendingDocument[]>([]);
53
+ const [pendingDocumentUrls, setPendingDocumentUrls] = useState<Record<string, string>>({});
54
+ const [documentUploadProgress, setDocumentUploadProgress] = useState<Record<string, number>>({});
55
+
56
+ // Read inside uploadDocument's async body, where a state snapshot would be
57
+ // stale across an await.
58
+ const pendingRef = useRef<PendingDocument[]>(pendingDocuments);
59
+ useEffect(() => { pendingRef.current = pendingDocuments; }, [pendingDocuments]);
60
+ const manifestRef = useRef<DocumentManifest>(documentManifest);
61
+ useEffect(() => { manifestRef.current = documentManifest; }, [documentManifest]);
62
+
63
+ /** Restore documents staged in a previous session (Dexie survives reload). */
64
+ const hydratePendingDocuments = useCallback(async () => {
65
+ // Never throws: it runs from a mount effect, before/independently of the
66
+ // editor store being initialized, and a failed restore must not take the
67
+ // shell down.
68
+ let rows: Awaited<ReturnType<typeof getPendingDocuments>>;
69
+ try {
70
+ rows = await getPendingDocuments();
71
+ } catch (err) {
72
+ console.error("[useDocumentUploads] Failed to restore staged documents:", err);
73
+ return;
74
+ }
75
+ if (rows.length === 0) return;
76
+ setPendingDocuments(rows.map(({ id, entry }) => ({ id, entry })));
77
+ // The persisted `blob:` URL died with the session that minted it, so a
78
+ // git-tier row (which carries its bytes) gets a fresh object URL here.
79
+ // Bucket-tier rows have no blob and keep their stored URL — theirs is a real
80
+ // remote one. The fallback keeps this function's never-throws contract if a
81
+ // round-tripped row comes back as something createObjectURL rejects.
82
+ setPendingDocumentUrls(
83
+ Object.fromEntries(
84
+ rows.map(({ id, localUrl, blob }) => {
85
+ if (!blob) return [id, localUrl];
86
+ try {
87
+ return [id, URL.createObjectURL(blob)];
88
+ } catch (err) {
89
+ console.error(`[useDocumentUploads] Failed to re-mint a URL for "${id}":`, err);
90
+ return [id, localUrl];
91
+ }
92
+ }),
93
+ ),
94
+ );
95
+ }, []);
96
+
97
+ const stage = useCallback(
98
+ async (id: string, entry: DocumentManifestEntry, blob: Blob | null, localUrl: string, filename: string): Promise<boolean> => {
99
+ try {
100
+ await addPendingDocument({ id, entry, blob, localUrl });
101
+ } catch (err) {
102
+ console.error(`[useDocumentUploads] Failed to stage "${filename}":`, err);
103
+ URL.revokeObjectURL(localUrl);
104
+ onUploadError?.(`"${filename}" couldn't be stored locally — try again.`);
105
+ return false;
106
+ }
107
+ setPendingDocuments((prev) => [...prev, { id, entry }]);
108
+ setPendingDocumentUrls((prev) => ({ ...prev, [id]: localUrl }));
109
+ setLocalChangesExist(true);
110
+ return true;
111
+ },
112
+ [onUploadError, setLocalChangesExist],
113
+ );
114
+
115
+ const uploadDocument = useCallback(
116
+ async (file: File, onComplete?: (documentId: string) => void): Promise<void> => {
117
+ const plan = planDocumentUpload({ name: file.name, size: file.size, type: file.type });
118
+ if (plan.tier === "rejected") {
119
+ onUploadError?.(documentRejectionMessage(plan.reason, file.name));
120
+ return;
121
+ }
122
+
123
+ let hash: string;
124
+ try {
125
+ hash = await hashFileBuffer(await file.arrayBuffer());
126
+ } catch (err) {
127
+ console.error(`[useDocumentUploads] Failed to read "${file.name}":`, err);
128
+ onUploadError?.(`"${file.name}" couldn't be read — try again.`);
129
+ return;
130
+ }
131
+
132
+ // Same bytes already known: reuse the id rather than staging a duplicate.
133
+ // Only ever hits on the GIT tier, where the content hash IS the id —
134
+ // bucket-tier ids are server-minted, so re-picking a large file stages a
135
+ // second entry. Accepted: the manifest is correct either way, and the
136
+ // publish-time docs GC collects whichever object stops being referenced.
137
+ if (manifestRef.current[hash] || pendingRef.current.some((d) => d.id === hash)) {
138
+ // A committed document deleted but not yet saved is only DOOMED, never
139
+ // gone: its bytes and its manifest entry are still on the branch, and
140
+ // deletion deliberately leaves documentManifest alone. So re-adding one
141
+ // un-dooms it — otherwise the drop zone swallows the file silently, and
142
+ // the picker points a section at an id the save then deletes. Nothing to
143
+ // re-upload either way. Tier-agnostic on purpose: a bucket-tier id can
144
+ // only reach this branch already staged (its ids are server-minted, so
145
+ // re-picking the file mints a new one), and un-dooming is right there too.
146
+ undeleteDocument?.(hash);
147
+ onComplete?.(hash);
148
+ return;
149
+ }
150
+
151
+ if (plan.tier === "git") {
152
+ const entry: DocumentManifestEntry = {
153
+ filename: file.name,
154
+ size: file.size,
155
+ kind: plan.kind,
156
+ storage: "git",
157
+ path: documentGitPath(file.name, hash, plan.ext),
158
+ };
159
+ const localUrl = URL.createObjectURL(file);
160
+ if (await stage(hash, entry, file, localUrl, file.name)) onComplete?.(hash);
161
+ return;
162
+ }
163
+
164
+ // The id for a BUCKET-tier document is minted server-side and comes back
165
+ // from upload-intent — never the client hash (decision 4). It keys both
166
+ // the object and the manifest entry, so staging under anything else would
167
+ // point the manifest at bytes that are not there. Declared out here so
168
+ // the `finally` can clear the progress entry it keys.
169
+ let documentId = "";
170
+ try {
171
+ let uploadUrl: string;
172
+ let filename: string;
173
+ try {
174
+ const res = await fetch("/api/documents/upload-intent", {
175
+ method: "POST",
176
+ headers: { "Content-Type": "application/json" },
177
+ body: JSON.stringify({ name: file.name, size: file.size, contentType: file.type }),
178
+ });
179
+ if (!res.ok) {
180
+ const body = (await res.json().catch(() => ({}))) as { error?: string };
181
+ const reason: DocumentRejectionReason =
182
+ body.error && REJECTION_CODES.has(body.error)
183
+ ? (body.error as DocumentRejectionReason)
184
+ : "no_documents_store";
185
+ onUploadError?.(documentRejectionMessage(reason, file.name));
186
+ return;
187
+ }
188
+ const intent = (await res.json()) as {
189
+ uploadUrl?: unknown; path?: unknown; documentId?: unknown; filename?: unknown; storage?: unknown;
190
+ };
191
+ if (
192
+ typeof intent.uploadUrl !== "string" ||
193
+ typeof intent.path !== "string" ||
194
+ typeof intent.documentId !== "string" ||
195
+ !/^[a-f0-9]{32}$/.test(intent.documentId)
196
+ ) {
197
+ throw new Error("malformed upload-intent response");
198
+ }
199
+ uploadUrl = intent.uploadUrl;
200
+ documentId = intent.documentId;
201
+ filename = typeof intent.filename === "string" && intent.filename ? intent.filename : file.name;
202
+ } catch (err) {
203
+ onUploadError?.(`"${file.name}": couldn't start the upload — ${(err as Error).message}. Try again.`);
204
+ return;
205
+ }
206
+
207
+ try {
208
+ await new Promise<void>((resolvePut, rejectPut) => {
209
+ const xhr = new XMLHttpRequest();
210
+ xhr.open("PUT", uploadUrl);
211
+ xhr.setRequestHeader("Content-Type", file.type);
212
+ xhr.upload.onprogress = (e) => {
213
+ if (e.lengthComputable) {
214
+ setDocumentUploadProgress((prev) => ({ ...prev, [documentId]: e.loaded / e.total }));
215
+ }
216
+ };
217
+ xhr.onload = () =>
218
+ xhr.status >= 200 && xhr.status < 300 ? resolvePut() : rejectPut(new Error(`upload ${xhr.status}`));
219
+ xhr.onerror = () => rejectPut(new Error("upload failed"));
220
+ xhr.send(file);
221
+ });
222
+ } catch (err) {
223
+ onUploadError?.(`"${file.name}": upload failed — ${(err as Error).message}. Try again.`);
224
+ return;
225
+ }
226
+
227
+ const entry: DocumentManifestEntry = {
228
+ filename,
229
+ size: file.size,
230
+ kind: plan.kind,
231
+ storage: "bucket",
232
+ // SITE-RELATIVE, derived from the server's id — deliberately NOT the
233
+ // intent's `path`, which is the tenant-prefixed object key
234
+ // (sites/{siteId}/…). DocumentStore prepends that prefix itself, and
235
+ // the GC and the delete broker both match on the site-relative form.
236
+ path: documentBucketPath(documentId, plan.ext),
237
+ };
238
+ // Manifest-only: the bytes are in the bucket, never in Dexie or /api/save.
239
+ const localUrl = URL.createObjectURL(file);
240
+ if (await stage(documentId, entry, null, localUrl, file.name)) onComplete?.(documentId);
241
+ } finally {
242
+ setDocumentUploadProgress((prev) => {
243
+ if (!documentId || !(documentId in prev)) return prev;
244
+ const next = { ...prev };
245
+ delete next[documentId];
246
+ return next;
247
+ });
248
+ }
249
+ },
250
+ [onUploadError, stage, undeleteDocument],
251
+ );
252
+
253
+ /** Called after a save commits these ids: promote them into the manifest,
254
+ * drop the staged rows, release the blob URLs. */
255
+ const publishDocuments = useCallback(
256
+ async (ids: string[]) => {
257
+ if (ids.length === 0) return;
258
+ const idSet = new Set(ids);
259
+ const promoted = pendingRef.current.filter((d) => idSet.has(d.id));
260
+ if (promoted.length > 0) {
261
+ setDocumentManifest((prev) => {
262
+ const next = { ...prev };
263
+ for (const { id, entry } of promoted) next[id] = entry;
264
+ return next;
265
+ });
266
+ }
267
+ setPendingDocuments((prev) => prev.filter((d) => !idSet.has(d.id)));
268
+ setPendingDocumentUrls((prev) => {
269
+ const next = { ...prev };
270
+ for (const id of ids) {
271
+ if (next[id]) {
272
+ URL.revokeObjectURL(next[id]);
273
+ delete next[id];
274
+ }
275
+ }
276
+ return next;
277
+ });
278
+ try {
279
+ await clearPendingDocumentsByIds(ids);
280
+ } catch (err) {
281
+ console.error("[useDocumentUploads] Failed to clear staged documents:", err);
282
+ }
283
+ },
284
+ [setDocumentManifest],
285
+ );
286
+
287
+ /** Drop staged-but-never-committed documents (the local-delete path): release
288
+ * the blob URLs and the Dexie rows. Deliberately manifest-free — nothing was
289
+ * ever committed, so a save has nothing to remove. A bucket-tier row's
290
+ * already-PUT object is left where it is; the publish-time docs GC collects
291
+ * whichever object stops being referenced (same acceptance as the re-pick
292
+ * dedup case above). */
293
+ const unstageDocuments = useCallback(async (ids: string[]) => {
294
+ if (ids.length === 0) return;
295
+ const idSet = new Set(ids);
296
+ setPendingDocuments((prev) => prev.filter((d) => !idSet.has(d.id)));
297
+ setPendingDocumentUrls((prev) => {
298
+ const next = { ...prev };
299
+ for (const id of ids) {
300
+ if (next[id]) {
301
+ URL.revokeObjectURL(next[id]);
302
+ delete next[id];
303
+ }
304
+ }
305
+ return next;
306
+ });
307
+ try {
308
+ await clearPendingDocumentsByIds(ids);
309
+ } catch (err) {
310
+ console.error("[useDocumentUploads] Failed to clear staged documents:", err);
311
+ }
312
+ }, []);
313
+
314
+ // Memoized: useMediaPipeline lists this object in handleMediaUpload's and
315
+ // enqueueFile's dependency arrays, and those two callbacks sit in
316
+ // contextValue's deps — a fresh literal every render would defeat all three
317
+ // memos and re-render every MediaLibraryContext consumer on every keystroke.
318
+ return useMemo(
319
+ () => ({
320
+ pendingDocuments,
321
+ pendingDocumentUrls,
322
+ documentUploadProgress,
323
+ uploadDocument,
324
+ publishDocuments,
325
+ unstageDocuments,
326
+ hydratePendingDocuments,
327
+ }),
328
+ [pendingDocuments, pendingDocumentUrls, documentUploadProgress, uploadDocument, publishDocuments, unstageDocuments, hydratePendingDocuments],
329
+ );
330
+ }