@drawnagency/primitives 0.1.99 → 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 (117) hide show
  1. package/dist/{chunk-FAZFADJK.js → chunk-3TO4RRHY.js} +3 -1
  2. package/dist/{chunk-KP4DWTPR.js → chunk-72FMQQIA.js} +246 -3
  3. package/dist/chunk-BS5AQHZG.js +45 -0
  4. package/dist/{chunk-HKHEAH5F.js → chunk-GOCFTXR3.js} +8 -60
  5. package/dist/{chunk-GYCBWGVW.js → chunk-LBHQWIVL.js} +79 -24
  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 +33 -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/sections/Document/index.d.ts +18 -0
  16. package/dist/components/sections/Document/index.d.ts.map +1 -0
  17. package/dist/components/sections/all-sections.d.ts +10 -0
  18. package/dist/components/sections/all-sections.d.ts.map +1 -1
  19. package/dist/components/sections/brand-guide.d.ts +10 -0
  20. package/dist/components/sections/brand-guide.d.ts.map +1 -1
  21. package/dist/components/sections/brand-guide.js +3 -1
  22. package/dist/components/sections/register-schemas.js +5 -3
  23. package/dist/components/sections/register.js +5 -3
  24. package/dist/components/shared/DocumentSourceField.d.ts +13 -0
  25. package/dist/components/shared/DocumentSourceField.d.ts.map +1 -0
  26. package/dist/components/shared/Navigation.d.ts.map +1 -1
  27. package/dist/components/shell/EditorModal.d.ts.map +1 -1
  28. package/dist/components/shell/EditorShell.d.ts.map +1 -1
  29. package/dist/components/shell/MediaBreadcrumb.d.ts.map +1 -1
  30. package/dist/components/shell/MediaFolderTile.d.ts +3 -1
  31. package/dist/components/shell/MediaFolderTile.d.ts.map +1 -1
  32. package/dist/components/shell/MediaFolderTree.d.ts +3 -0
  33. package/dist/components/shell/MediaFolderTree.d.ts.map +1 -1
  34. package/dist/components/shell/MediaLibraryContext.d.ts +18 -1
  35. package/dist/components/shell/MediaLibraryContext.d.ts.map +1 -1
  36. package/dist/components/shell/MediaLibraryModal.d.ts +21 -1
  37. package/dist/components/shell/MediaLibraryModal.d.ts.map +1 -1
  38. package/dist/hooks/useDocumentUploads.d.ts +32 -0
  39. package/dist/hooks/useDocumentUploads.d.ts.map +1 -0
  40. package/dist/hooks/useEditorPublish.d.ts +18 -1
  41. package/dist/hooks/useEditorPublish.d.ts.map +1 -1
  42. package/dist/hooks/useMediaPipeline.d.ts +14 -1
  43. package/dist/hooks/useMediaPipeline.d.ts.map +1 -1
  44. package/dist/hooks/useResolvedDocument.d.ts +12 -0
  45. package/dist/hooks/useResolvedDocument.d.ts.map +1 -0
  46. package/dist/index.js +56 -32
  47. package/dist/lib/dexie.d.ts +11 -0
  48. package/dist/lib/dexie.d.ts.map +1 -1
  49. package/dist/lib/dexie.js +32 -0
  50. package/dist/lib/document-refs.d.ts +22 -0
  51. package/dist/lib/document-refs.d.ts.map +1 -0
  52. package/dist/lib/document-uploads.d.ts +38 -0
  53. package/dist/lib/document-uploads.d.ts.map +1 -0
  54. package/dist/lib/documents.d.ts +65 -0
  55. package/dist/lib/documents.d.ts.map +1 -0
  56. package/dist/lib/index.js +4 -4
  57. package/dist/lib/media-folders.d.ts +8 -0
  58. package/dist/lib/media-folders.d.ts.map +1 -1
  59. package/dist/lib/media-folders.js +2 -0
  60. package/dist/lib/nav.d.ts +29 -1
  61. package/dist/lib/nav.d.ts.map +1 -1
  62. package/dist/lib/platform-broker.d.ts +12 -0
  63. package/dist/lib/platform-broker.d.ts.map +1 -1
  64. package/dist/lib/platform-broker.js +12 -3
  65. package/dist/lib/registry.d.ts +10 -0
  66. package/dist/lib/registry.d.ts.map +1 -1
  67. package/dist/lib/save-chunks.d.ts +15 -0
  68. package/dist/lib/save-chunks.d.ts.map +1 -1
  69. package/dist/lib/scroll-lock.d.ts +2 -0
  70. package/dist/lib/scroll-lock.d.ts.map +1 -0
  71. package/dist/schemas/documents.d.ts +80 -0
  72. package/dist/schemas/documents.d.ts.map +1 -0
  73. package/dist/schemas/documents.js +25 -0
  74. package/dist/schemas/index.d.ts +1 -0
  75. package/dist/schemas/index.d.ts.map +1 -1
  76. package/dist/schemas/index.js +23 -1
  77. package/dist/storage/index.d.ts +2 -2
  78. package/dist/storage/index.d.ts.map +1 -1
  79. package/dist/storage/index.js +3 -1
  80. package/dist/storage/types.d.ts +47 -0
  81. package/dist/storage/types.d.ts.map +1 -1
  82. package/package.json +5 -1
  83. package/src/components/chat/ChatPanel.tsx +5 -4
  84. package/src/components/docfile/DocFileOverlay.tsx +54 -0
  85. package/src/components/docfile/DocFilePane.tsx +129 -0
  86. package/src/components/docfile/doc-file-state.ts +180 -0
  87. package/src/components/editor/SettingsForm.tsx +10 -0
  88. package/src/components/sections/Document/index.tsx +163 -0
  89. package/src/components/sections/brand-guide.ts +2 -1
  90. package/src/components/shared/DocumentSourceField.tsx +51 -0
  91. package/src/components/shared/Navigation.tsx +53 -10
  92. package/src/components/shell/EditorModal.tsx +2 -5
  93. package/src/components/shell/EditorShell.tsx +111 -7
  94. package/src/components/shell/MediaBreadcrumb.tsx +14 -1
  95. package/src/components/shell/MediaFolderTile.tsx +4 -2
  96. package/src/components/shell/MediaFolderTree.tsx +20 -3
  97. package/src/components/shell/MediaLibraryContext.tsx +18 -1
  98. package/src/components/shell/MediaLibraryModal.tsx +223 -45
  99. package/src/hooks/useDocumentUploads.ts +330 -0
  100. package/src/hooks/useEditorPublish.ts +180 -8
  101. package/src/hooks/useMediaPipeline.ts +136 -5
  102. package/src/hooks/useResolvedDocument.ts +25 -0
  103. package/src/lib/dexie.ts +54 -0
  104. package/src/lib/document-refs.ts +83 -0
  105. package/src/lib/document-uploads.ts +66 -0
  106. package/src/lib/documents.ts +138 -0
  107. package/src/lib/media-folders.ts +9 -0
  108. package/src/lib/nav.ts +46 -2
  109. package/src/lib/platform-broker.ts +18 -3
  110. package/src/lib/registry.ts +9 -0
  111. package/src/lib/save-chunks.ts +42 -0
  112. package/src/lib/scroll-lock.ts +27 -0
  113. package/src/schemas/documents.ts +82 -0
  114. package/src/schemas/index.ts +1 -0
  115. package/src/storage/index.ts +2 -2
  116. package/src/storage/types.ts +46 -0
  117. /package/dist/{chunk-FOY5TF7D.js → chunk-ZDQ3TT47.js} +0 -0
@@ -25,4 +25,19 @@ export interface PackedUploads {
25
25
  * for the caller to reject with a per-file error. Items are never split
26
26
  * across chunks (the manifest may only reference fully-committed items). */
27
27
  export declare function packUploads(uploads: MediaUploadPayload[], budget?: number): PackedUploads;
28
+ export interface DocumentUploadPayload {
29
+ id: string;
30
+ path: string;
31
+ base64: string;
32
+ }
33
+ /** Approximate serialized size of one document upload — base64 dominates; the
34
+ * allowance covers JSON keys, quoting, and the manifest entry riding along. */
35
+ export declare function documentUploadSizeBytes(upload: DocumentUploadPayload): number;
36
+ /** Greedy in-order packing of document uploads. A 2.5MiB document base64s to
37
+ * ~3.4MB, so two never share a request — hence chunking rather than one blob
38
+ * request. Documents are never packed with sections/index. */
39
+ export declare function packDocumentUploads(uploads: DocumentUploadPayload[], budget?: number): {
40
+ chunks: DocumentUploadPayload[][];
41
+ oversized: DocumentUploadPayload[];
42
+ };
28
43
  //# sourceMappingURL=save-chunks.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"save-chunks.d.ts","sourceRoot":"","sources":["../../src/lib/save-chunks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC3C;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,QAAkB,CAAC;AAEvD;6EAC6E;AAC7E,wBAAgB,eAAe,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAElE;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,kBAAkB,EAAE,EAAE,CAAC;IAC/B,SAAS,EAAE,kBAAkB,EAAE,CAAC;CACjC;AAED;;;6EAG6E;AAC7E,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,EAAE,EAAE,MAAM,SAA0B,GAAG,aAAa,CAsB1G"}
1
+ {"version":3,"file":"save-chunks.d.ts","sourceRoot":"","sources":["../../src/lib/save-chunks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAEhD,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC3C;AAED;;;;;GAKG;AACH,eAAO,MAAM,uBAAuB,QAAkB,CAAC;AAEvD;6EAC6E;AAC7E,wBAAgB,eAAe,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAElE;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,kBAAkB,EAAE,EAAE,CAAC;IAC/B,SAAS,EAAE,kBAAkB,EAAE,CAAC;CACjC;AAED;;;6EAG6E;AAC7E,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,EAAE,EAAE,MAAM,SAA0B,GAAG,aAAa,CAsB1G;AAED,MAAM,WAAW,qBAAqB;IACpC,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;gFACgF;AAChF,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CAE7E;AAED;;+DAE+D;AAC/D,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,qBAAqB,EAAE,EAChC,MAAM,SAA0B,GAC/B;IAAE,MAAM,EAAE,qBAAqB,EAAE,EAAE,CAAC;IAAC,SAAS,EAAE,qBAAqB,EAAE,CAAA;CAAE,CAsB3E"}
@@ -0,0 +1,2 @@
1
+ export declare function lockBodyScroll(): () => void;
2
+ //# sourceMappingURL=scroll-lock.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scroll-lock.d.ts","sourceRoot":"","sources":["../../src/lib/scroll-lock.ts"],"names":[],"mappings":"AAYA,wBAAgB,cAAc,IAAI,MAAM,IAAI,CAc3C"}
@@ -0,0 +1,80 @@
1
+ import { z } from "zod";
2
+ /**
3
+ * Document metadata lives in its OWN file, not in image-manifest.json.
4
+ *
5
+ * The image manifest is hard-`.parse`d on every page load across five routes,
6
+ * its remote-merge guard fails open on parse failure, and the authoring CLI
7
+ * parse-catch-WRITES it — so one entry a stale package version cannot
8
+ * represent means whole-site 500s or a wiped manifest. A sidecar is simply
9
+ * absent from an older writer's file list and survives untouched. Same
10
+ * structural answer as nav.json; do not "simplify" this back into either
11
+ * index.json or image-manifest.json.
12
+ */
13
+ export declare const DOCUMENT_MANIFEST_PATH = "src/content/document-manifest.json";
14
+ /**
15
+ * Git-tier cap. 2.5 MiB base64-encodes to ~3.4 MB, inside the 4 MB post-base64
16
+ * chunk budget with envelope headroom.
17
+ */
18
+ export declare const DOCUMENT_GIT_MAX_BYTES = 2621440;
19
+ /** Bucket-tier cap. Its lower bound is DOCUMENT_GIT_MAX_BYTES — no dead zone. */
20
+ export declare const DOCUMENT_BUCKET_MAX_BYTES = 209715200;
21
+ /**
22
+ * The only two accepted upload types, and the ONLY source of a document's file
23
+ * extension — never the media pipeline's `?? "bin"` fallback.
24
+ */
25
+ export declare const DOCUMENT_MIME_TO_EXT: {
26
+ readonly "application/pdf": "pdf";
27
+ readonly "text/html": "html";
28
+ };
29
+ /** Reverse of DOCUMENT_MIME_TO_EXT, for setting Content-Type when serving. */
30
+ export declare const DOCUMENT_KIND_TO_MIME: {
31
+ readonly pdf: "application/pdf";
32
+ readonly html: "text/html";
33
+ };
34
+ export declare const DocumentManifestEntrySchema: z.ZodObject<{
35
+ filename: z.ZodString;
36
+ size: z.ZodNumber;
37
+ kind: z.ZodEnum<{
38
+ pdf: "pdf";
39
+ html: "html";
40
+ }>;
41
+ storage: z.ZodEnum<{
42
+ git: "git";
43
+ bucket: "bucket";
44
+ }>;
45
+ path: z.ZodString;
46
+ }, z.core.$strip>;
47
+ export declare const DocumentManifestSchema: z.ZodRecord<z.ZodString, z.ZodObject<{
48
+ filename: z.ZodString;
49
+ size: z.ZodNumber;
50
+ kind: z.ZodEnum<{
51
+ pdf: "pdf";
52
+ html: "html";
53
+ }>;
54
+ storage: z.ZodEnum<{
55
+ git: "git";
56
+ bucket: "bucket";
57
+ }>;
58
+ path: z.ZodString;
59
+ }, z.core.$strip>>;
60
+ export type DocumentManifest = z.infer<typeof DocumentManifestSchema>;
61
+ export type DocumentManifestEntry = z.infer<typeof DocumentManifestEntrySchema>;
62
+ /**
63
+ * The gated URL for a document, at BOTH storage tiers. Single-sourced here so
64
+ * the section row, the overlay, the nav group and the tests cannot drift — a
65
+ * bucket-tier document is still fetched through this route, which authorizes
66
+ * the requester and only then 302s to a short-lived presigned GET.
67
+ */
68
+ export declare function documentFileUrl(documentId: string, filename: string, opts?: {
69
+ download?: boolean;
70
+ }): string;
71
+ /** Git-tier repo path. `slugName` comes from sanitizeMediaName(file.name). */
72
+ export declare function documentGitPath(slugName: string, hash: string, kind: "pdf" | "html"): string;
73
+ /**
74
+ * Bucket-tier object key, SITE-RELATIVE — the store prepends the tenant prefix,
75
+ * so this is also the form stored in the manifest, matched by the delete
76
+ * broker's regex, and compared by the publish-time GC. The id is the 32-hex
77
+ * `documentId` minted by upload-intent, never the client's content hash.
78
+ */
79
+ export declare function documentBucketPath(documentId: string, kind: "pdf" | "html"): string;
80
+ //# sourceMappingURL=documents.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"documents.d.ts","sourceRoot":"","sources":["../../src/schemas/documents.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,uCAAuC,CAAC;AAE3E;;;GAGG;AACH,eAAO,MAAM,sBAAsB,UAAY,CAAC;AAEhD,iFAAiF;AACjF,eAAO,MAAM,yBAAyB,YAAc,CAAC;AAErD;;;GAGG;AACH,eAAO,MAAM,oBAAoB;;;CAGvB,CAAC;AAEX,8EAA8E;AAC9E,eAAO,MAAM,qBAAqB;;;CAGxB,CAAC;AAEX,eAAO,MAAM,2BAA2B;;;;;;;;;;;;iBAOtC,CAAC;AAEH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;kBAAoD,CAAC;AAExF,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACtE,MAAM,MAAM,qBAAqB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,2BAA2B,CAAC,CAAC;AAEhF;;;;;GAKG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAO,GAChC,MAAM,CAGR;AAED,8EAA8E;AAC9E,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,CAE5F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,UAAU,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,GAAG,MAAM,GAAG,MAAM,CAEnF"}
@@ -0,0 +1,25 @@
1
+ import {
2
+ DOCUMENT_BUCKET_MAX_BYTES,
3
+ DOCUMENT_GIT_MAX_BYTES,
4
+ DOCUMENT_KIND_TO_MIME,
5
+ DOCUMENT_MANIFEST_PATH,
6
+ DOCUMENT_MIME_TO_EXT,
7
+ DocumentManifestEntrySchema,
8
+ DocumentManifestSchema,
9
+ documentBucketPath,
10
+ documentFileUrl,
11
+ documentGitPath
12
+ } from "../chunk-BS5AQHZG.js";
13
+ import "../chunk-JSBRDJBE.js";
14
+ export {
15
+ DOCUMENT_BUCKET_MAX_BYTES,
16
+ DOCUMENT_GIT_MAX_BYTES,
17
+ DOCUMENT_KIND_TO_MIME,
18
+ DOCUMENT_MANIFEST_PATH,
19
+ DOCUMENT_MIME_TO_EXT,
20
+ DocumentManifestEntrySchema,
21
+ DocumentManifestSchema,
22
+ documentBucketPath,
23
+ documentFileUrl,
24
+ documentGitPath
25
+ };
@@ -7,4 +7,5 @@ export * from "./audience";
7
7
  export * from "./media-grid-options";
8
8
  export * from "./auth";
9
9
  export * from "./link";
10
+ export * from "./documents";
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/schemas/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,OAAO,CAAC;AACtB,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,cAAc,sBAAsB,CAAC;AACrC,cAAc,QAAQ,CAAC;AACvB,cAAc,QAAQ,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/schemas/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,eAAe,CAAC;AAC9B,cAAc,OAAO,CAAC;AACtB,cAAc,SAAS,CAAC;AACxB,cAAc,YAAY,CAAC;AAC3B,cAAc,sBAAsB,CAAC;AACrC,cAAc,QAAQ,CAAC;AACvB,cAAc,QAAQ,CAAC;AACvB,cAAc,aAAa,CAAC"}
@@ -3,7 +3,7 @@ import {
3
3
  AudienceNameSchema,
4
4
  MediaGridOptionsSchema,
5
5
  slugifyAudienceName
6
- } from "../chunk-FOY5TF7D.js";
6
+ } from "../chunk-ZDQ3TT47.js";
7
7
  import {
8
8
  AudienceSchema,
9
9
  RoleSchema,
@@ -16,6 +16,18 @@ import {
16
16
  LinkValueSchema,
17
17
  isSafeHref
18
18
  } from "../chunk-PZJL2S3Z.js";
19
+ import {
20
+ DOCUMENT_BUCKET_MAX_BYTES,
21
+ DOCUMENT_GIT_MAX_BYTES,
22
+ DOCUMENT_KIND_TO_MIME,
23
+ DOCUMENT_MANIFEST_PATH,
24
+ DOCUMENT_MIME_TO_EXT,
25
+ DocumentManifestEntrySchema,
26
+ DocumentManifestSchema,
27
+ documentBucketPath,
28
+ documentFileUrl,
29
+ documentGitPath
30
+ } from "../chunk-BS5AQHZG.js";
19
31
  import {
20
32
  NavEntrySchema,
21
33
  NavFileSchema
@@ -58,6 +70,13 @@ export {
58
70
  ChatbotConfigSchema,
59
71
  ChatbotTaskSchema,
60
72
  DEFAULT_LINK,
73
+ DOCUMENT_BUCKET_MAX_BYTES,
74
+ DOCUMENT_GIT_MAX_BYTES,
75
+ DOCUMENT_KIND_TO_MIME,
76
+ DOCUMENT_MANIFEST_PATH,
77
+ DOCUMENT_MIME_TO_EXT,
78
+ DocumentManifestEntrySchema,
79
+ DocumentManifestSchema,
61
80
  FontNameSchema,
62
81
  HexColorSchema,
63
82
  ImageManifestSchema,
@@ -84,6 +103,9 @@ export {
84
103
  StatusSchema,
85
104
  TextLineSchema,
86
105
  VariantSchema,
106
+ documentBucketPath,
107
+ documentFileUrl,
108
+ documentGitPath,
87
109
  getSectionContentSchema,
88
110
  getSectionSchema,
89
111
  isSafeHref,
@@ -1,3 +1,3 @@
1
- export type { StorageProvider, FileWrite, AssetStore } from "./types";
2
- export { StorageConflictError, IdempotencyLookupUnavailableError, ASSET_CACHE_CONTROL, IDEMPOTENCY_TRAILER, IDEMPOTENCY_KEY_RE, withIdempotencyTrailer } from "./types";
1
+ export type { StorageProvider, FileWrite, AssetStore, DocumentStore } from "./types";
2
+ export { StorageConflictError, IdempotencyLookupUnavailableError, ASSET_CACHE_CONTROL, DOCUMENT_CACHE_CONTROL, IDEMPOTENCY_TRAILER, IDEMPOTENCY_KEY_RE, withIdempotencyTrailer } from "./types";
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACtE,OAAO,EAAE,oBAAoB,EAAE,iCAAiC,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/storage/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,SAAS,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AACrF,OAAO,EAAE,oBAAoB,EAAE,iCAAiC,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,sBAAsB,EAAE,MAAM,SAAS,CAAC"}
@@ -1,14 +1,16 @@
1
1
  import {
2
2
  ASSET_CACHE_CONTROL,
3
+ DOCUMENT_CACHE_CONTROL,
3
4
  IDEMPOTENCY_KEY_RE,
4
5
  IDEMPOTENCY_TRAILER,
5
6
  IdempotencyLookupUnavailableError,
6
7
  StorageConflictError,
7
8
  withIdempotencyTrailer
8
- } from "../chunk-FAZFADJK.js";
9
+ } from "../chunk-3TO4RRHY.js";
9
10
  import "../chunk-JSBRDJBE.js";
10
11
  export {
11
12
  ASSET_CACHE_CONTROL,
13
+ DOCUMENT_CACHE_CONTROL,
12
14
  IDEMPOTENCY_KEY_RE,
13
15
  IDEMPOTENCY_TRAILER,
14
16
  IdempotencyLookupUnavailableError,
@@ -131,4 +131,51 @@ export interface AssetStore {
131
131
  updatedAt: string;
132
132
  }[]>;
133
133
  }
134
+ /**
135
+ * `Cache-Control` every DOCUMENT upload must set. Deliberately NOT
136
+ * ASSET_CACHE_CONTROL: documents live in a PRIVATE bucket reachable only
137
+ * through short-lived presigned GETs, and a shared cache keyed on the full
138
+ * signed URL would otherwise retain the bytes for a year after the signature
139
+ * expired. `private` keeps the copy in the requesting browser only.
140
+ */
141
+ export declare const DOCUMENT_CACHE_CONTROL = "private, max-age=31536000, immutable";
142
+ /**
143
+ * Optional PRIVATE document store — a second bucket with no public domain.
144
+ *
145
+ * Deliberately not an AssetStore: the assets bucket is public by configuration
146
+ * at R2_PUBLIC_BASE (videos depend on it), so a signed GET there would protect
147
+ * nothing and hosted HTML would render unsandboxed on a sibling origin. Hence
148
+ * no `getPublicUrl` here at all — every read is a short-lived signed GET,
149
+ * minted only after the gated route has authorized the requester.
150
+ *
151
+ * Paths are SITE-RELATIVE ("documents/{hash}/original.pdf"); implementations
152
+ * prepend the tenant prefix themselves so a caller can never address another
153
+ * tenant's objects.
154
+ */
155
+ export interface DocumentStore {
156
+ /** Mint a signed direct-upload URL for the browser (PUT). */
157
+ createUploadUrl(path: string, opts: {
158
+ contentType: string;
159
+ }): Promise<{
160
+ uploadUrl: string;
161
+ path: string;
162
+ }>;
163
+ /**
164
+ * Mint a short-lived signed GET. `downloadFilename`, when given, signs
165
+ * `response-content-disposition: attachment` into the URL — R2 sends no
166
+ * Content-Disposition of its own, so this is the only way ?download=1 can
167
+ * force a download on the bucket tier.
168
+ */
169
+ createDownloadUrl(path: string, opts: {
170
+ expiresInSeconds: number;
171
+ downloadFilename?: string;
172
+ }): Promise<string>;
173
+ delete(paths: string[]): Promise<void>;
174
+ /** Recursive listing under a site-relative prefix. */
175
+ list(prefix: string): Promise<Array<{
176
+ path: string;
177
+ size: number;
178
+ updatedAt: string;
179
+ }>>;
180
+ }
134
181
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;aACjB,cAAc,EAAE,MAAM,GAAG,IAAI;gBAA7B,cAAc,EAAE,MAAM,GAAG,IAAI;CAI1D;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CACR,KAAK,EAAE,SAAS,EAAE,EAClB,OAAO,EAAE,MAAM,EAIf,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,GACxC,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAEhC,QAAQ,CACN,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAC5B,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IAE9B,YAAY,IAAI,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAE7C,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;IAE9B,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAE9E;;;;;;;;;;;;;;;;;;;;;;;;;;OA0BG;IACH,0BAA0B,CAAC,CACzB,GAAG,EAAE,MAAM,EACX,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAC3B,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;CAC3B;AAED;;;;;GAKG;AACH,qBAAa,iCAAkC,SAAQ,KAAK;aAC5B,KAAK,CAAC,EAAE,OAAO;gBAAf,KAAK,CAAC,EAAE,OAAO,YAAA;CAI9C;AAED;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,oBAAoB,CAAC;AAErD;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,QAA6B,CAAC;AAE7D;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,MAAM,GAAG,MAAM,CAM5E;AAED,MAAM,MAAM,SAAS,GAAG;IACtB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAAC;IACpC,QAAQ,CAAC,EAAE,OAAO,GAAG,QAAQ,CAAC;CAC/B,CAAC;AAEF;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,mBAAmB,gCAAgC,CAAC;AAEjE;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IACzB,6DAA6D;IAC7D,eAAe,CACb,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,GAC5B,OAAO,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAEnE,oEAAoE;IACpE,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IAEnC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAEvC,sDAAsD;IACtD,IAAI,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAC;CACpF"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/storage/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawnagency/primitives",
3
- "version": "0.1.99",
3
+ "version": "0.1.100",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -24,6 +24,10 @@
24
24
  "types": "./dist/schemas/link.d.ts",
25
25
  "default": "./dist/schemas/link.js"
26
26
  },
27
+ "./schemas/documents": {
28
+ "types": "./dist/schemas/documents.d.ts",
29
+ "default": "./dist/schemas/documents.js"
30
+ },
27
31
  "./lib": {
28
32
  "types": "./dist/lib/index.d.ts",
29
33
  "default": "./dist/lib/index.js"
@@ -4,6 +4,7 @@ import { ArrowDown, ChevronDown, ChevronRight, History, Pin, PinOff, SquarePen,
4
4
  import { cn } from "../../lib/cn";
5
5
  import { ensureSanitizer, sanitizeHtml } from "../../lib/sanitize";
6
6
  import { resolveThinkingPhrases } from "../../lib/chatbot";
7
+ import { lockBodyScroll } from "../../lib/scroll-lock";
7
8
  import { loadHistory, upsertConversation, setConversationPinned, removeConversation, type StoredConversation } from "./chat-history";
8
9
  import { Select } from "../shared/Select";
9
10
  import { IconButton } from "../shared/IconButton";
@@ -299,9 +300,7 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
299
300
  // The open sheet is modal: the page behind it must not scroll.
300
301
  useEffect(() => {
301
302
  if (mode !== "sheet" || !open) return;
302
- const prev = document.body.style.overflow;
303
- document.body.style.overflow = "hidden";
304
- return () => { document.body.style.overflow = prev; };
303
+ return lockBodyScroll();
305
304
  }, [mode, open]);
306
305
 
307
306
  // Explicit-open focus: composer on desktop, the panel itself on the sheet
@@ -487,7 +486,9 @@ export default function ChatPanel({ open, closing, animateEnter, onClose, siteNa
487
486
  // width, so the panel always tucks below it — the toolbar never
488
487
  // moves during the open/close slide.
489
488
  mode === "docked" && cn(
490
- "fixed right-0 bottom-0 z-40 w-[var(--chat-w)] border-l border-base-200",
489
+ // bp-chat-panel is a STABLE hook, not styling: base.css pins the
490
+ // docked panel to the 120rem band edge under html.bp-doc-open.
491
+ "bp-chat-panel fixed right-0 bottom-0 z-40 w-[var(--chat-w)] border-l border-base-200",
491
492
  variant === "editor" ? "top-16" : "top-16 xl:top-0",
492
493
  "motion-safe:transition-transform motion-safe:duration-300",
493
494
  open ? "translate-x-0" : "translate-x-full",
@@ -0,0 +1,54 @@
1
+ import { lazy, Suspense, useEffect, useState } from "react";
2
+ import type { SiteDocument } from "../../lib/documents";
3
+ import {
4
+ subscribeDocFile,
5
+ subscribeSiteDocuments,
6
+ getSiteDocuments,
7
+ seedSiteDocuments,
8
+ closeDocFile,
9
+ isDocDesktop,
10
+ } from "./doc-file-state";
11
+
12
+ const DocFilePane = lazy(() => import("./DocFilePane"));
13
+
14
+ /**
15
+ * One mount per page, rendered into the Layout `chrome` slot (OUTSIDE <main>:
16
+ * the chat dock's band-shift transform would re-anchor a position:fixed
17
+ * descendant). `documents` is the requester-filtered SSR list — on /edit it is
18
+ * initial state only; EditorShell republishes the live draft list.
19
+ */
20
+ export default function DocFileOverlay({
21
+ documents,
22
+ editor = false,
23
+ }: { documents: SiteDocument[]; editor?: boolean }) {
24
+ const [docs, setDocs] = useState<SiteDocument[] | null>(null);
25
+ const [openId, setOpenId] = useState<string | null>(null);
26
+
27
+ useEffect(() => {
28
+ seedSiteDocuments(documents);
29
+ setDocs(getSiteDocuments());
30
+ return subscribeSiteDocuments(setDocs);
31
+ }, [documents]);
32
+
33
+ useEffect(() => subscribeDocFile(setOpenId), []);
34
+
35
+ const doc = openId && docs ? (docs.find((d) => d.sectionId === openId) ?? null) : null;
36
+
37
+ // Below the desktop breakpoint a PDF goes to the OS viewer. The row already
38
+ // short-circuits, but a Documents-nav entry lands here, so the guard belongs
39
+ // at the pane too.
40
+ useEffect(() => {
41
+ if (!doc || doc.kind !== "pdf" || isDocDesktop()) return;
42
+ window.open(doc.url, "_blank", "noopener,noreferrer");
43
+ closeDocFile();
44
+ }, [doc]);
45
+
46
+ if (!doc) return null;
47
+ if (doc.kind === "pdf" && !isDocDesktop()) return null;
48
+
49
+ return (
50
+ <Suspense fallback={null}>
51
+ <DocFilePane doc={doc} onClose={closeDocFile} editor={editor} />
52
+ </Suspense>
53
+ );
54
+ }
@@ -0,0 +1,129 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { Download, X } from "lucide-react";
3
+ import type { SiteDocument } from "../../lib/documents";
4
+ import { documentFileUrl } from "../../schemas/documents";
5
+ import { lockBodyScroll } from "../../lib/scroll-lock";
6
+
7
+ /** How long a frame may sit on "loading" before we offer a retry. */
8
+ const LOAD_TIMEOUT_MS = 15_000;
9
+
10
+ /**
11
+ * The solid stage: an opaque, flat, bordered pane between the nav edge and the
12
+ * chat edge. Geometry lives in base.css (.doc-file-pane) because it is calc()
13
+ * against doc mode's own 120rem stage band — the band the nav and chat are
14
+ * pinned to under html.bp-doc-open (NOT the page's 80rem --layout-max-width
15
+ * band, which doc mode never touches). z-30 — above unpositioned page content,
16
+ * BELOW the nav and docked chat (z-40) and the mobile nav header / chat sheet
17
+ * (z-50), so all chrome stays reachable. `editor` adds top clearance for the
18
+ * edit route's always-fixed h-16 toolbar.
19
+ */
20
+ export default function DocFilePane({
21
+ doc,
22
+ onClose,
23
+ editor = false,
24
+ }: { doc: SiteDocument; onClose: () => void; editor?: boolean }) {
25
+ const [attempt, setAttempt] = useState(0);
26
+ const [state, setState] = useState<"loading" | "ready" | "error">("loading");
27
+ const frameRef = useRef<HTMLIFrameElement | null>(null);
28
+
29
+ useEffect(() => lockBodyScroll(), []);
30
+
31
+ useEffect(() => {
32
+ const onKey = (e: KeyboardEvent) => {
33
+ if (e.key !== "Escape") return;
34
+ e.preventDefault();
35
+ onClose();
36
+ };
37
+ window.addEventListener("keydown", onKey);
38
+ return () => window.removeEventListener("keydown", onKey);
39
+ }, [onClose]);
40
+
41
+ useEffect(() => {
42
+ setState("loading");
43
+ const timer = setTimeout(() => setState((s) => (s === "loading" ? "error" : s)), LOAD_TIMEOUT_MS);
44
+ return () => clearTimeout(timer);
45
+ }, [attempt, doc.url]);
46
+
47
+ // Natively, not via an onError prop: React registers a non-delegated `error`
48
+ // listener for <img>/<image> but for <iframe> only `load` (react-dom
49
+ // setInitialProperties), and `error` does not bubble — so an onError prop on
50
+ // an iframe is never called. Browsers rarely fire it either, which is what
51
+ // LOAD_TIMEOUT_MS is for; this covers the cases that do.
52
+ useEffect(() => {
53
+ const frame = frameRef.current;
54
+ if (!frame) return;
55
+ const onError = () => setState("error");
56
+ frame.addEventListener("error", onError);
57
+ return () => frame.removeEventListener("error", onError);
58
+ }, [attempt, doc.url]);
59
+
60
+ return (
61
+ <div
62
+ role="dialog"
63
+ aria-label={doc.title}
64
+ data-testid="doc-file-pane"
65
+ className={`doc-file-pane${editor ? " doc-file-pane--editor" : ""} fixed z-30 flex flex-col overflow-hidden rounded-outer border border-base-200 bg-base`}
66
+ >
67
+ <div className="flex shrink-0 items-center justify-between gap-3 border-b border-base-200 px-4 py-2">
68
+ <div className="flex min-w-0 items-baseline gap-2">
69
+ <span className="truncate text-sm font-medium text-base-contrast">{doc.title}</span>
70
+ <span className="truncate font-mono text-xs text-base-contrast/70">{doc.filename}</span>
71
+ </div>
72
+ <div className="flex shrink-0 items-center gap-1">
73
+ <a
74
+ // Built by documentFileUrl's `download` option, never by appending
75
+ // a query string: `doc.url` is a blob: URL for a document staged
76
+ // this session, and "blob:…?download=1" resolves to nothing. A blob
77
+ // URL is already directly downloadable, so it passes through.
78
+ href={
79
+ doc.url.startsWith("blob:")
80
+ ? doc.url
81
+ : documentFileUrl(doc.documentId, doc.filename, { download: true })
82
+ }
83
+ download={doc.filename}
84
+ aria-label={`Download ${doc.filename}`}
85
+ className="cursor-pointer rounded-inner p-1.5 text-base-contrast/60 transition-colors hover:text-primary"
86
+ >
87
+ <Download size={16} aria-hidden="true" />
88
+ </a>
89
+ <button
90
+ type="button"
91
+ onClick={onClose}
92
+ aria-label="Close document"
93
+ className="cursor-pointer rounded-inner p-1.5 text-base-contrast/60 transition-colors hover:text-primary"
94
+ >
95
+ <X size={16} aria-hidden="true" />
96
+ </button>
97
+ </div>
98
+ </div>
99
+
100
+ {state === "error" ? (
101
+ <div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-6 text-center">
102
+ <p className="text-sm text-base-contrast">Couldn't load document.</p>
103
+ <button
104
+ type="button"
105
+ onClick={() => setAttempt((n) => n + 1)}
106
+ className="cursor-pointer rounded-inner border border-base-200 px-3 py-1.5 text-sm text-base-contrast transition-colors hover:text-primary"
107
+ >
108
+ Retry
109
+ </button>
110
+ </div>
111
+ ) : (
112
+ <iframe
113
+ key={attempt}
114
+ ref={frameRef}
115
+ src={doc.url}
116
+ title={doc.title}
117
+ // Opaque origin for authored HTML: no portal cookies, storage or
118
+ // parent DOM. The ATTRIBUTE applies regardless of origin, so
119
+ // containment survives the bucket tier's 302 to a presigned R2 GET,
120
+ // where no CSP header comes back. PDFs get the browser's native
121
+ // viewer, which a sandbox would break.
122
+ sandbox={doc.kind === "html" ? "allow-scripts" : undefined}
123
+ onLoad={() => setState("ready")}
124
+ className="min-h-0 w-full flex-1 border-0 bg-base"
125
+ />
126
+ )}
127
+ </div>
128
+ );
129
+ }