@elytracms/studio 0.0.87 → 0.0.90

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 (164) hide show
  1. package/convex/asset-revalidate.test.ts +35 -42
  2. package/convex/delivery.ts +8 -11
  3. package/convex/document-revalidate.test.ts +175 -0
  4. package/convex/redirect-revalidate.test.ts +36 -36
  5. package/convex/revalidate.ts +18 -30
  6. package/convex/triggers.ts +47 -38
  7. package/convex/webhooks.ts +77 -62
  8. package/dist/components/account/self-account-view-source.js +2 -1
  9. package/dist/components/account/self-account-view-source.js.map +1 -1
  10. package/dist/components/ai/ai-chat-panel.js +9 -2
  11. package/dist/components/ai/ai-chat-panel.js.map +1 -1
  12. package/dist/components/app-views/default-modules.js +3 -3
  13. package/dist/components/app-views/default-modules.js.map +1 -1
  14. package/dist/components/assets/asset-grid.js +1 -1
  15. package/dist/components/assets/asset-grid.js.map +1 -1
  16. package/dist/components/assets/assets-collection-view-source.js +3 -2
  17. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  18. package/dist/components/assets/assets-store.d.ts +11 -1
  19. package/dist/components/assets/assets-store.js +10 -0
  20. package/dist/components/assets/assets-store.js.map +1 -1
  21. package/dist/components/assets/focal-point.d.ts +0 -7
  22. package/dist/components/assets/focal-point.js +0 -10
  23. package/dist/components/assets/focal-point.js.map +1 -1
  24. package/dist/components/builder/canvas-render-context.d.ts +12 -1
  25. package/dist/components/builder/canvas-render-context.js +20 -16
  26. package/dist/components/builder/canvas-render-context.js.map +1 -1
  27. package/dist/components/builder/reuse-context.js +2 -2
  28. package/dist/components/builder/reuse-context.js.map +1 -1
  29. package/dist/components/builder/reuse.d.ts +6 -2
  30. package/dist/components/builder/reuse.js +12 -7
  31. package/dist/components/builder/reuse.js.map +1 -1
  32. package/dist/components/cms/asset-field.d.ts +2 -1
  33. package/dist/components/cms/asset-field.js +27 -59
  34. package/dist/components/cms/asset-field.js.map +1 -1
  35. package/dist/components/cms/asset-picker-core.d.ts +77 -40
  36. package/dist/components/cms/asset-picker-core.js +73 -46
  37. package/dist/components/cms/asset-picker-core.js.map +1 -1
  38. package/dist/components/cms/asset-picker.d.ts +48 -19
  39. package/dist/components/cms/asset-picker.js +69 -30
  40. package/dist/components/cms/asset-picker.js.map +1 -1
  41. package/dist/components/cms/cell-edit.d.ts +3 -7
  42. package/dist/components/cms/cell-edit.js +7 -25
  43. package/dist/components/cms/cell-edit.js.map +1 -1
  44. package/dist/components/cms/cms-store.d.ts +3 -2
  45. package/dist/components/cms/cms-store.js +38 -47
  46. package/dist/components/cms/cms-store.js.map +1 -1
  47. package/dist/components/cms/collection-table.js +14 -4
  48. package/dist/components/cms/collection-table.js.map +1 -1
  49. package/dist/components/cms/detail-editor.js +20 -21
  50. package/dist/components/cms/detail-editor.js.map +1 -1
  51. package/dist/components/cms/duplicate-document.d.ts +3 -2
  52. package/dist/components/cms/duplicate-document.js +5 -5
  53. package/dist/components/cms/duplicate-document.js.map +1 -1
  54. package/dist/components/cms/editable-cell.js +3 -6
  55. package/dist/components/cms/editable-cell.js.map +1 -1
  56. package/dist/components/cms/field-control.js +7 -6
  57. package/dist/components/cms/field-control.js.map +1 -1
  58. package/dist/components/cms/gate-validation.d.ts +27 -2
  59. package/dist/components/cms/gate-validation.js +19 -1
  60. package/dist/components/cms/gate-validation.js.map +1 -1
  61. package/dist/components/cms/publish-clean.d.ts +13 -6
  62. package/dist/components/cms/publish-clean.js +14 -6
  63. package/dist/components/cms/publish-clean.js.map +1 -1
  64. package/dist/components/cms/rich-text/editor.js +2 -2
  65. package/dist/components/cms/rich-text/editor.js.map +1 -1
  66. package/dist/components/cms/rich-text/insert.d.ts +7 -0
  67. package/dist/components/cms/rich-text/insert.js +7 -0
  68. package/dist/components/cms/rich-text/insert.js.map +1 -1
  69. package/dist/components/cms/use-publish-controller.js +3 -2
  70. package/dist/components/cms/use-publish-controller.js.map +1 -1
  71. package/dist/components/components/transfer-dialog.js +1 -0
  72. package/dist/components/components/transfer-dialog.js.map +1 -1
  73. package/dist/components/components/transfer.d.ts +10 -4
  74. package/dist/components/components/transfer.js +11 -9
  75. package/dist/components/components/transfer.js.map +1 -1
  76. package/dist/components/delivery-health-banner.d.ts +3 -2
  77. package/dist/components/delivery-health-banner.js +3 -2
  78. package/dist/components/delivery-health-banner.js.map +1 -1
  79. package/dist/components/nav-issues.d.ts +23 -3
  80. package/dist/components/nav-issues.js +38 -2
  81. package/dist/components/nav-issues.js.map +1 -1
  82. package/dist/components/redirects/redirects-collection-view-source.js +3 -2
  83. package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
  84. package/dist/components/section-nav.js +26 -2
  85. package/dist/components/section-nav.js.map +1 -1
  86. package/dist/components/users/users-collection-view-source.js +2 -1
  87. package/dist/components/users/users-collection-view-source.js.map +1 -1
  88. package/dist/lib/ai/apply-content.js +32 -28
  89. package/dist/lib/ai/apply-content.js.map +1 -1
  90. package/dist/lib/ai/tools.d.ts +10 -4
  91. package/dist/lib/ai/tools.js +12 -4
  92. package/dist/lib/ai/tools.js.map +1 -1
  93. package/dist/lib/ai/translate-document.js +11 -7
  94. package/dist/lib/ai/translate-document.js.map +1 -1
  95. package/dist/lib/ai/types.d.ts +9 -5
  96. package/dist/lib/ai/types.js.map +1 -1
  97. package/dist/lib/ai/update-fields.js +10 -29
  98. package/dist/lib/ai/update-fields.js.map +1 -1
  99. package/dist/lib/ai/vite.d.ts +22 -2
  100. package/dist/lib/ai/vite.js +12 -3
  101. package/dist/lib/ai/vite.js.map +1 -1
  102. package/dist/lib/cms/published-documents.d.ts +14 -3
  103. package/dist/lib/cms/published-documents.js +12 -3
  104. package/dist/lib/cms/published-documents.js.map +1 -1
  105. package/dist/lib/cms-views/view-model.d.ts +4 -4
  106. package/dist/lib/cms-views/view-model.js +16 -13
  107. package/dist/lib/cms-views/view-model.js.map +1 -1
  108. package/dist/lib/data/collection-content.d.ts +22 -1
  109. package/dist/lib/data/collection-content.js +51 -14
  110. package/dist/lib/data/collection-content.js.map +1 -1
  111. package/dist/lib/delivery/instant-publish.d.ts +0 -3
  112. package/dist/lib/delivery/instant-publish.js +0 -1
  113. package/dist/lib/delivery/instant-publish.js.map +1 -1
  114. package/dist/lib/engine-version.d.ts +3 -3
  115. package/dist/lib/engine-version.js +3 -3
  116. package/dist/lib/engine-version.js.map +1 -1
  117. package/dist/lib/i18n/messages.de.js +5 -0
  118. package/dist/lib/i18n/messages.de.js.map +1 -1
  119. package/dist/lib/i18n/messages.en.d.ts +4 -0
  120. package/dist/lib/i18n/messages.en.js +6 -0
  121. package/dist/lib/i18n/messages.en.js.map +1 -1
  122. package/dist/lib/locale/fallback.d.ts +5 -19
  123. package/dist/lib/locale/fallback.js +18 -39
  124. package/dist/lib/locale/fallback.js.map +1 -1
  125. package/dist/lib/locale/locale-store.js +2 -1
  126. package/dist/lib/locale/locale-store.js.map +1 -1
  127. package/dist/lib/operations/session-hydration.d.ts +24 -26
  128. package/dist/lib/operations/session-hydration.js +15 -53
  129. package/dist/lib/operations/session-hydration.js.map +1 -1
  130. package/dist/lib/operations/studio-operations.js +4 -6
  131. package/dist/lib/operations/studio-operations.js.map +1 -1
  132. package/dist/lib/persistence/backend-mode.d.ts +5 -25
  133. package/dist/lib/persistence/backend-mode.js +13 -30
  134. package/dist/lib/persistence/backend-mode.js.map +1 -1
  135. package/dist/lib/references/reference-model.d.ts +17 -3
  136. package/dist/lib/references/reference-model.js +17 -23
  137. package/dist/lib/references/reference-model.js.map +1 -1
  138. package/dist/lib/references/references-store.js +7 -0
  139. package/dist/lib/references/references-store.js.map +1 -1
  140. package/dist/lib/workspace/config-issues.d.ts +6 -0
  141. package/dist/lib/workspace/config-issues.js +8 -4
  142. package/dist/lib/workspace/config-issues.js.map +1 -1
  143. package/dist/lib/workspace/index.d.ts +1 -8
  144. package/dist/lib/workspace/index.js +1 -10
  145. package/dist/lib/workspace/index.js.map +1 -1
  146. package/dist/router/navigate-once.d.ts +19 -4
  147. package/dist/router/navigate-once.js +22 -5
  148. package/dist/router/navigate-once.js.map +1 -1
  149. package/package.json +3 -7
  150. package/dist/lib/webhooks/config.d.ts +0 -15
  151. package/dist/lib/webhooks/config.js +0 -65
  152. package/dist/lib/webhooks/config.js.map +0 -1
  153. package/dist/lib/webhooks/dispatcher.d.ts +0 -70
  154. package/dist/lib/webhooks/dispatcher.js +0 -61
  155. package/dist/lib/webhooks/dispatcher.js.map +0 -1
  156. package/dist/lib/webhooks/index.d.ts +0 -11
  157. package/dist/lib/webhooks/index.js +0 -10
  158. package/dist/lib/webhooks/index.js.map +0 -1
  159. package/dist/lib/webhooks/signing.d.ts +0 -15
  160. package/dist/lib/webhooks/signing.js +0 -24
  161. package/dist/lib/webhooks/signing.js.map +0 -1
  162. package/dist/lib/webhooks/tags.d.ts +0 -25
  163. package/dist/lib/webhooks/tags.js +0 -289
  164. package/dist/lib/webhooks/tags.js.map +0 -1
@@ -1,9 +1,17 @@
1
+ import type { AssetFieldDef } from '@elytracms/core/cms-core';
1
2
  import type { AssetRecord } from '@elytracms/core/persistence';
2
3
  /**
3
4
  * Pure selection/filter core for the asset picker (EC-432). DOM-free and
4
- * side-effect-free so the media browser's "what shows" and "what a pick/upload
5
- * commits" are unit-testable without a browser — the picker component is just the
6
- * stage shell around these.
5
+ * side-effect-free so the media browser's "what shows", "what an upload may send" and
6
+ * "what a pick/upload commits" are unit-testable without a browser — the picker
7
+ * component is just the stage shell around these.
8
+ *
9
+ * EC-588: the picker takes the field's STRUCTURED `accept` list — the one shape the
10
+ * schema declares — and derives everything from it here or in core: the grid filter
11
+ * ({@link visiblePickerAssets}), the drop/upload split ({@link splitByAccept}), the
12
+ * file-input attribute (`assetAcceptAttr`, core), and the commit filter
13
+ * ({@link uploadBatchProgress}). An absent `accept` is unrestricted everywhere; there is
14
+ * no hidden `image/*` default.
7
15
  */
8
16
  /**
9
17
  * The stage width preset the media browser mounts with (EC-432 / EC-431 AD-A):
@@ -12,62 +20,70 @@ import type { AssetRecord } from '@elytracms/core/persistence';
12
20
  * (which is `null` in the node test env).
13
21
  */
14
22
  export declare const ASSET_PICKER_STAGE_PRESET: "grid";
15
- /**
16
- * Split a file-input `accept` string ("image/*", "image/png,image/jpeg",
17
- * ".pdf") into the token list the core matcher understands. The picker receives
18
- * `accept` as the file-input attribute string (from `assetAcceptAttr(field.accept)`,
19
- * a literal like `"image/*"`, or `undefined`), not a field's structured list —
20
- * so we re-split it here. Empty/undefined ⇒ no tokens (no restriction).
21
- */
22
- export declare function pickerAcceptTokens(accept: string | undefined): string[];
23
- /**
24
- * Does an asset satisfy the picker's `accept`? Reuses the core
25
- * {@link assetMatchesAccept} matcher over the split tokens — an empty/absent
26
- * `accept` accepts everything, so a picker opened WITHOUT an accept (the editable
27
- * cell) still browses the whole library, unchanged.
28
- */
29
- export declare function matchesPickerAccept(asset: Pick<AssetRecord, 'contentType' | 'filename'>, accept: string | undefined): boolean;
30
23
  /**
31
24
  * The assets shown in the picker grid: the shipped search filter (EC-152) then
32
25
  * the `accept` filter (EC-432) — an image field's picker browses only images,
33
26
  * never the project's PDFs. Order follows the library.
34
27
  */
35
- export declare function visiblePickerAssets(assets: readonly AssetRecord[], query: string, accept: string | undefined): AssetRecord[];
28
+ export declare function visiblePickerAssets(assets: readonly AssetRecord[], query: string, accept: readonly string[] | undefined): AssetRecord[];
36
29
  /**
37
- * The id an upload just added to the library, if any (upload-then-select, EC-301):
38
- * the `upload()` API is fire-and-forget, so the picker diffs the library against
39
- * the pre-upload id set to learn the new asset. `null` when nothing new landed.
30
+ * THE drop rule (EC-301 → EC-588): split files about to be uploaded into the ones the
31
+ * field's `accept` admits and the NAMES of the ones it refuses. A drag-drop bypasses
32
+ * the file dialog's soft filter, so every upload path — the field's drop zone, a drop
33
+ * into the open picker, the picker's Upload dialog — passes through this, and the
34
+ * caller names the refused files in a visible notice instead of uploading them.
40
35
  */
41
- export declare function uploadedSelection(before: Iterable<string>, after: readonly string[]): string | null;
36
+ export declare function splitByAccept<F extends {
37
+ name: string;
38
+ type: string;
39
+ }>(files: readonly F[], accept: readonly string[] | undefined): {
40
+ accepted: F[];
41
+ rejected: string[];
42
+ };
42
43
  /**
43
- * One upload BATCH (EC-460): the pre-upload library snapshot plus how many
44
- * files the interaction submitted. The old flow tracked only `before` and
45
- * committed on the FIRST library growth — with N concurrent uploads landing at
46
- * different instants, files 2…N reached the library but never the field
47
- * (silent partial data loss, the EC-276 multi-upload symptom).
44
+ * One upload BATCH (EC-460): the upload-queue ids THIS interaction started.
45
+ *
46
+ * EC-588 A7: it used to be a pre-upload library snapshot, and whatever the library
47
+ * gained since was committed — so an upload from another tab (re-listed with ours) or
48
+ * from a sibling field's drop could land in this field. A batch now names exactly its
49
+ * own uploads, and the store reports which asset each one landed as.
48
50
  */
49
51
  export interface UploadBatch {
50
- before: ReadonlySet<string>;
51
- /** How many files this batch submitted (post `accept`-filter). */
52
- expected: number;
52
+ uploadIds: readonly string[];
53
+ }
54
+ /** What batch progress reads from the assets store (`AssetsStore` satisfies it). */
55
+ export interface UploadSource {
56
+ assets: readonly AssetRecord[];
57
+ uploads: readonly {
58
+ id: string;
59
+ state: {
60
+ phase: string;
61
+ };
62
+ }[];
63
+ /** The asset id an upload landed as, once its record is confirmed. */
64
+ landedAssetId(uploadId: string): string | undefined;
53
65
  }
54
66
  export interface UploadBatchProgress {
55
- /** Every id the library gained since the batch started, in library order. */
67
+ /** The batch's landed asset ids that the field accepts, in upload order. */
56
68
  added: string[];
57
69
  /**
58
- * The batch is finished: every expected file landed, OR nothing is uploading
59
- * anymore (the remainder failed — visible in the upload queue, never awaited
60
- * forever). Commit/attach decisions happen only once settled for `many`
61
- * targets; a single-select target still commits on the first arrival.
70
+ * The batch is finished: none of its uploads is still running, and every one that
71
+ * landed is in the library. A failed (or dismissed) upload is terminal — it stays in
72
+ * the queue for retry and is never awaited forever. Commit/attach decisions happen
73
+ * only once settled for `many` targets; a single-select target commits on the first
74
+ * arrival.
62
75
  */
63
76
  settled: boolean;
64
77
  }
65
78
  /**
66
- * Where a batch stands, given the current library ids and how many uploads are
67
- * still in flight (non-terminal upload-queue entries). Pure — the picker and
68
- * the asset field both drive their effects with this.
79
+ * Where a batch stands. An upload is in one of four states: running (in the queue,
80
+ * not failed), landed-but-not-listed (the store confirmed it, the library re-list has
81
+ * not arrived), landed (attachable), or terminal (failed / dismissed). Only landed
82
+ * uploads whose RECORD satisfies `accept` are `added` (A7: the commit is filtered,
83
+ * not just the dialog). Pure — the picker and the asset field both drive their
84
+ * effects with this.
69
85
  */
70
- export declare function uploadBatchProgress(batch: UploadBatch, libraryIds: readonly string[], uploadsInFlight: number): UploadBatchProgress;
86
+ export declare function uploadBatchProgress(batch: UploadBatch, source: UploadSource, accept: readonly string[] | undefined): UploadBatchProgress;
71
87
  /** The picker's commit callbacks — a pick fires `onChange` then closes the stage. */
72
88
  export interface PickerCommit {
73
89
  onChange: (assetId: string | null) => void;
@@ -90,3 +106,24 @@ export interface PickerCommitMany {
90
106
  * files in one picker interaction attaches all N, not just the first to land.
91
107
  */
92
108
  export declare function commitPickerSelectionMany(assetIds: string[], { onChangeMany, onOpenChange }: PickerCommitMany): void;
109
+ /**
110
+ * The picker props a field's value position needs, derived from the field alone
111
+ * (EC-570 #1 → EC-588): the selected id, the field's structured `accept`, whether it
112
+ * holds many, and the commits — a pick through the carry rule (EC-558), a settled
113
+ * upload batch appended for a `many` field (EC-460).
114
+ */
115
+ export interface AssetPickerBinding {
116
+ value: string | null;
117
+ accept?: readonly string[];
118
+ multiple: boolean;
119
+ onChange: (assetId: string | null) => void;
120
+ onChangeMany?: (assetIds: string[]) => void;
121
+ }
122
+ /**
123
+ * ONE picker configuration for every surface that edits an asset field — the detail
124
+ * editor and the table cell — so the two cannot drift. Before, the cell opened the
125
+ * picker with no `accept`, no `multiple` and no batch commit: its grid offered PDFs to
126
+ * an image field and an N-file upload attached one (EC-570 #1). `write` receives the
127
+ * next stored value; the caller decides how to land it (form state, cell commit).
128
+ */
129
+ export declare function assetPickerBinding(field: Pick<AssetFieldDef, 'multiple' | 'accept' | 'fields'>, value: unknown, write: (next: unknown) => void): AssetPickerBinding;
@@ -1,11 +1,18 @@
1
1
  import { assetMatchesAccept } from '@elytracms/core/cms-core';
2
2
  import { filterAssets } from '../assets/asset-search.js';
3
- import { newlyAddedAssetIds } from '../assets/focal-point.js';
3
+ import { appendAssetSelections, applyAssetSelection, assetPickForm, singleAssetIdOf, } from '../assets/asset-selection.js';
4
4
  /**
5
5
  * Pure selection/filter core for the asset picker (EC-432). DOM-free and
6
- * side-effect-free so the media browser's "what shows" and "what a pick/upload
7
- * commits" are unit-testable without a browser — the picker component is just the
8
- * stage shell around these.
6
+ * side-effect-free so the media browser's "what shows", "what an upload may send" and
7
+ * "what a pick/upload commits" are unit-testable without a browser — the picker
8
+ * component is just the stage shell around these.
9
+ *
10
+ * EC-588: the picker takes the field's STRUCTURED `accept` list — the one shape the
11
+ * schema declares — and derives everything from it here or in core: the grid filter
12
+ * ({@link visiblePickerAssets}), the drop/upload split ({@link splitByAccept}), the
13
+ * file-input attribute (`assetAcceptAttr`, core), and the commit filter
14
+ * ({@link uploadBatchProgress}). An absent `accept` is unrestricted everywhere; there is
15
+ * no hidden `image/*` default.
9
16
  */
10
17
  /**
11
18
  * The stage width preset the media browser mounts with (EC-432 / EC-431 AD-A):
@@ -14,60 +21,62 @@ import { newlyAddedAssetIds } from '../assets/focal-point.js';
14
21
  * (which is `null` in the node test env).
15
22
  */
16
23
  export const ASSET_PICKER_STAGE_PRESET = 'grid';
17
- /**
18
- * Split a file-input `accept` string ("image/*", "image/png,image/jpeg",
19
- * ".pdf") into the token list the core matcher understands. The picker receives
20
- * `accept` as the file-input attribute string (from `assetAcceptAttr(field.accept)`,
21
- * a literal like `"image/*"`, or `undefined`), not a field's structured list —
22
- * so we re-split it here. Empty/undefined ⇒ no tokens (no restriction).
23
- */
24
- export function pickerAcceptTokens(accept) {
25
- if (!accept)
26
- return [];
27
- return accept
28
- .split(',')
29
- .map((token) => token.trim())
30
- .filter(Boolean);
31
- }
32
- /**
33
- * Does an asset satisfy the picker's `accept`? Reuses the core
34
- * {@link assetMatchesAccept} matcher over the split tokens — an empty/absent
35
- * `accept` accepts everything, so a picker opened WITHOUT an accept (the editable
36
- * cell) still browses the whole library, unchanged.
37
- */
38
- export function matchesPickerAccept(asset, accept) {
39
- const tokens = pickerAcceptTokens(accept);
40
- if (tokens.length === 0)
41
- return true;
42
- return assetMatchesAccept({ contentType: asset.contentType, filename: asset.filename }, tokens);
43
- }
44
24
  /**
45
25
  * The assets shown in the picker grid: the shipped search filter (EC-152) then
46
26
  * the `accept` filter (EC-432) — an image field's picker browses only images,
47
27
  * never the project's PDFs. Order follows the library.
48
28
  */
49
29
  export function visiblePickerAssets(assets, query, accept) {
50
- return filterAssets(assets, query).filter((asset) => matchesPickerAccept(asset, accept));
30
+ return filterAssets(assets, query).filter((asset) => assetMatchesAccept(asset, accept));
51
31
  }
52
32
  /**
53
- * The id an upload just added to the library, if any (upload-then-select, EC-301):
54
- * the `upload()` API is fire-and-forget, so the picker diffs the library against
55
- * the pre-upload id set to learn the new asset. `null` when nothing new landed.
33
+ * THE drop rule (EC-301 → EC-588): split files about to be uploaded into the ones the
34
+ * field's `accept` admits and the NAMES of the ones it refuses. A drag-drop bypasses
35
+ * the file dialog's soft filter, so every upload path — the field's drop zone, a drop
36
+ * into the open picker, the picker's Upload dialog — passes through this, and the
37
+ * caller names the refused files in a visible notice instead of uploading them.
56
38
  */
57
- export function uploadedSelection(before, after) {
58
- return newlyAddedAssetIds(before, after)[0] ?? null;
39
+ export function splitByAccept(files, accept) {
40
+ const accepted = [];
41
+ const rejected = [];
42
+ for (const file of files) {
43
+ if (assetMatchesAccept({ contentType: file.type, filename: file.name }, accept))
44
+ accepted.push(file);
45
+ else
46
+ rejected.push(file.name);
47
+ }
48
+ return { accepted, rejected };
59
49
  }
60
50
  /**
61
- * Where a batch stands, given the current library ids and how many uploads are
62
- * still in flight (non-terminal upload-queue entries). Pure — the picker and
63
- * the asset field both drive their effects with this.
51
+ * Where a batch stands. An upload is in one of four states: running (in the queue,
52
+ * not failed), landed-but-not-listed (the store confirmed it, the library re-list has
53
+ * not arrived), landed (attachable), or terminal (failed / dismissed). Only landed
54
+ * uploads whose RECORD satisfies `accept` are `added` (A7: the commit is filtered,
55
+ * not just the dialog). Pure — the picker and the asset field both drive their
56
+ * effects with this.
64
57
  */
65
- export function uploadBatchProgress(batch, libraryIds, uploadsInFlight) {
66
- const added = newlyAddedAssetIds(batch.before, libraryIds);
67
- return {
68
- added,
69
- settled: added.length >= batch.expected || uploadsInFlight === 0,
70
- };
58
+ export function uploadBatchProgress(batch, source, accept) {
59
+ const running = new Set(source.uploads.filter((u) => u.state.phase !== 'failed').map((u) => u.id));
60
+ const library = new Map(source.assets.map((asset) => [asset.id, asset]));
61
+ const added = [];
62
+ let pending = false;
63
+ for (const uploadId of batch.uploadIds) {
64
+ if (running.has(uploadId)) {
65
+ pending = true;
66
+ continue;
67
+ }
68
+ const assetId = source.landedAssetId(uploadId);
69
+ if (assetId === undefined)
70
+ continue; // failed or dismissed — terminal
71
+ const record = library.get(assetId);
72
+ if (!record) {
73
+ pending = true; // confirmed, but the library re-list has not landed yet
74
+ continue;
75
+ }
76
+ if (assetMatchesAccept(record, accept))
77
+ added.push(assetId);
78
+ }
79
+ return { added, settled: !pending };
71
80
  }
72
81
  /**
73
82
  * Commit a picked asset (or a clear, `assetId === null`) and close the picker —
@@ -87,4 +96,22 @@ export function commitPickerSelectionMany(assetIds, { onChangeMany, onOpenChange
87
96
  onChangeMany(assetIds);
88
97
  onOpenChange(false);
89
98
  }
99
+ /**
100
+ * ONE picker configuration for every surface that edits an asset field — the detail
101
+ * editor and the table cell — so the two cannot drift. Before, the cell opened the
102
+ * picker with no `accept`, no `multiple` and no batch commit: its grid offered PDFs to
103
+ * an image field and an N-file upload attached one (EC-570 #1). `write` receives the
104
+ * next stored value; the caller decides how to land it (form state, cell commit).
105
+ */
106
+ export function assetPickerBinding(field, value, write) {
107
+ const form = assetPickForm(field);
108
+ const multiple = field.multiple;
109
+ return {
110
+ value: multiple ? null : singleAssetIdOf(value),
111
+ ...(field.accept ? { accept: field.accept } : {}),
112
+ multiple,
113
+ onChange: (picked) => write(applyAssetSelection(value, picked, multiple, form)),
114
+ ...(multiple ? { onChangeMany: (picked) => write(appendAssetSelections(value, picked, form)) } : {}),
115
+ };
116
+ }
90
117
  //# sourceMappingURL=asset-picker-core.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"asset-picker-core.js","sourceRoot":"","sources":["../../../src/components/cms/asset-picker-core.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,MAAM,0BAA0B,CAAA;AAC7D,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AACrD,OAAO,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAA;AAE1D;;;;;GAKG;AAEH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,yBAAyB,GAAG,MAAe,CAAA;AAExD;;;;;;GAMG;AACH,MAAM,UAAU,kBAAkB,CAAC,MAA0B;IAC3D,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,CAAA;IACtB,OAAO,MAAM;SACV,KAAK,CAAC,GAAG,CAAC;SACV,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;SAC5B,MAAM,CAAC,OAAO,CAAC,CAAA;AACpB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAoD,EACpD,MAA0B;IAE1B,MAAM,MAAM,GAAG,kBAAkB,CAAC,MAAM,CAAC,CAAA;IACzC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAA;IACpC,OAAO,kBAAkB,CAAC,EAAE,WAAW,EAAE,KAAK,CAAC,WAAW,EAAE,QAAQ,EAAE,KAAK,CAAC,QAAQ,EAAE,EAAE,MAAM,CAAC,CAAA;AACjG,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CACjC,MAA8B,EAC9B,KAAa,EACb,MAA0B;IAE1B,OAAO,YAAY,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,mBAAmB,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAA;AAC1F,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAAC,MAAwB,EAAE,KAAwB;IAClF,OAAO,kBAAkB,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAA;AACrD,CAAC;AA2BD;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CACjC,KAAkB,EAClB,UAA6B,EAC7B,eAAuB;IAEvB,MAAM,KAAK,GAAG,kBAAkB,CAAC,KAAK,CAAC,MAAM,EAAE,UAAU,CAAC,CAAA;IAC1D,OAAO;QACL,KAAK;QACL,OAAO,EAAE,KAAK,CAAC,MAAM,IAAI,KAAK,CAAC,QAAQ,IAAI,eAAe,KAAK,CAAC;KACjE,CAAA;AACH,CAAC;AAQD;;;;GAIG;AACH,MAAM,UAAU,qBAAqB,CAAC,OAAsB,EAAE,EAAE,QAAQ,EAAE,YAAY,EAAgB;IACpG,QAAQ,CAAC,OAAO,CAAC,CAAA;IACjB,YAAY,CAAC,KAAK,CAAC,CAAA;AACrB,CAAC;AAQD;;;;GAIG;AACH,MAAM,UAAU,yBAAyB,CACvC,QAAkB,EAClB,EAAE,YAAY,EAAE,YAAY,EAAoB;IAEhD,YAAY,CAAC,QAAQ,CAAC,CAAA;IACtB,YAAY,CAAC,KAAK,CAAC,CAAA;AACrB,CAAC","sourcesContent":["import type { AssetRecord } from '@elytracms/core/persistence'\nimport { assetMatchesAccept } from '@elytracms/core/cms-core'\nimport { filterAssets } from '../assets/asset-search'\nimport { newlyAddedAssetIds } from '../assets/focal-point'\n\n/**\n * Pure selection/filter core for the asset picker (EC-432). DOM-free and\n * side-effect-free so the media browser's \"what shows\" and \"what a pick/upload\n * commits\" are unit-testable without a browser — the picker component is just the\n * stage shell around these.\n */\n\n/**\n * The stage width preset the media browser mounts with (EC-432 / EC-431 AD-A):\n * the roomy `grid` measure (`min(1240px, 94vw)`) — browsing assets is spatial.\n * Exported so the geometry contract is pinnable without rendering the portal\n * (which is `null` in the node test env).\n */\nexport const ASSET_PICKER_STAGE_PRESET = 'grid' as const\n\n/**\n * Split a file-input `accept` string (\"image/*\", \"image/png,image/jpeg\",\n * \".pdf\") into the token list the core matcher understands. The picker receives\n * `accept` as the file-input attribute string (from `assetAcceptAttr(field.accept)`,\n * a literal like `\"image/*\"`, or `undefined`), not a field's structured list —\n * so we re-split it here. Empty/undefined ⇒ no tokens (no restriction).\n */\nexport function pickerAcceptTokens(accept: string | undefined): string[] {\n if (!accept) return []\n return accept\n .split(',')\n .map((token) => token.trim())\n .filter(Boolean)\n}\n\n/**\n * Does an asset satisfy the picker's `accept`? Reuses the core\n * {@link assetMatchesAccept} matcher over the split tokens — an empty/absent\n * `accept` accepts everything, so a picker opened WITHOUT an accept (the editable\n * cell) still browses the whole library, unchanged.\n */\nexport function matchesPickerAccept(\n asset: Pick<AssetRecord, 'contentType' | 'filename'>,\n accept: string | undefined,\n): boolean {\n const tokens = pickerAcceptTokens(accept)\n if (tokens.length === 0) return true\n return assetMatchesAccept({ contentType: asset.contentType, filename: asset.filename }, tokens)\n}\n\n/**\n * The assets shown in the picker grid: the shipped search filter (EC-152) then\n * the `accept` filter (EC-432) — an image field's picker browses only images,\n * never the project's PDFs. Order follows the library.\n */\nexport function visiblePickerAssets(\n assets: readonly AssetRecord[],\n query: string,\n accept: string | undefined,\n): AssetRecord[] {\n return filterAssets(assets, query).filter((asset) => matchesPickerAccept(asset, accept))\n}\n\n/**\n * The id an upload just added to the library, if any (upload-then-select, EC-301):\n * the `upload()` API is fire-and-forget, so the picker diffs the library against\n * the pre-upload id set to learn the new asset. `null` when nothing new landed.\n */\nexport function uploadedSelection(before: Iterable<string>, after: readonly string[]): string | null {\n return newlyAddedAssetIds(before, after)[0] ?? null\n}\n\n/**\n * One upload BATCH (EC-460): the pre-upload library snapshot plus how many\n * files the interaction submitted. The old flow tracked only `before` and\n * committed on the FIRST library growth — with N concurrent uploads landing at\n * different instants, files 2…N reached the library but never the field\n * (silent partial data loss, the EC-276 multi-upload symptom).\n */\nexport interface UploadBatch {\n before: ReadonlySet<string>\n /** How many files this batch submitted (post `accept`-filter). */\n expected: number\n}\n\nexport interface UploadBatchProgress {\n /** Every id the library gained since the batch started, in library order. */\n added: string[]\n /**\n * The batch is finished: every expected file landed, OR nothing is uploading\n * anymore (the remainder failed — visible in the upload queue, never awaited\n * forever). Commit/attach decisions happen only once settled for `many`\n * targets; a single-select target still commits on the first arrival.\n */\n settled: boolean\n}\n\n/**\n * Where a batch stands, given the current library ids and how many uploads are\n * still in flight (non-terminal upload-queue entries). Pure — the picker and\n * the asset field both drive their effects with this.\n */\nexport function uploadBatchProgress(\n batch: UploadBatch,\n libraryIds: readonly string[],\n uploadsInFlight: number,\n): UploadBatchProgress {\n const added = newlyAddedAssetIds(batch.before, libraryIds)\n return {\n added,\n settled: added.length >= batch.expected || uploadsInFlight === 0,\n }\n}\n\n/** The picker's commit callbacks — a pick fires `onChange` then closes the stage. */\nexport interface PickerCommit {\n onChange: (assetId: string | null) => void\n onOpenChange: (open: boolean) => void\n}\n\n/**\n * Commit a picked asset (or a clear, `assetId === null`) and close the picker —\n * the selection semantic (click = pick, fire `onChange`, close). Both the grid\n * card and the Clear affordance route through this.\n */\nexport function commitPickerSelection(assetId: string | null, { onChange, onOpenChange }: PickerCommit): void {\n onChange(assetId)\n onOpenChange(false)\n}\n\n/** The multi-commit callbacks (EC-460): a settled upload batch on a `many` field. */\nexport interface PickerCommitMany {\n onChangeMany: (assetIds: string[]) => void\n onOpenChange: (open: boolean) => void\n}\n\n/**\n * Commit EVERY asset a settled upload batch added (EC-460) and close the picker —\n * the `many`-field counterpart of {@link commitPickerSelection}, so selecting N\n * files in one picker interaction attaches all N, not just the first to land.\n */\nexport function commitPickerSelectionMany(\n assetIds: string[],\n { onChangeMany, onOpenChange }: PickerCommitMany,\n): void {\n onChangeMany(assetIds)\n onOpenChange(false)\n}\n"]}
1
+ {"version":3,"file":"asset-picker-core.js","sourceRoot":"","sources":["../../../src/components/cms/asset-picker-core.ts"],"names":[],"mappings":"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","sourcesContent":["import type { AssetFieldDef } from '@elytracms/core/cms-core'\nimport type { AssetRecord } from '@elytracms/core/persistence'\nimport { assetMatchesAccept } from '@elytracms/core/cms-core'\nimport { filterAssets } from '../assets/asset-search'\nimport {\n appendAssetSelections,\n applyAssetSelection,\n assetPickForm,\n singleAssetIdOf,\n} from '../assets/asset-selection'\n\n/**\n * Pure selection/filter core for the asset picker (EC-432). DOM-free and\n * side-effect-free so the media browser's \"what shows\", \"what an upload may send\" and\n * \"what a pick/upload commits\" are unit-testable without a browser — the picker\n * component is just the stage shell around these.\n *\n * EC-588: the picker takes the field's STRUCTURED `accept` list — the one shape the\n * schema declares — and derives everything from it here or in core: the grid filter\n * ({@link visiblePickerAssets}), the drop/upload split ({@link splitByAccept}), the\n * file-input attribute (`assetAcceptAttr`, core), and the commit filter\n * ({@link uploadBatchProgress}). An absent `accept` is unrestricted everywhere; there is\n * no hidden `image/*` default.\n */\n\n/**\n * The stage width preset the media browser mounts with (EC-432 / EC-431 AD-A):\n * the roomy `grid` measure (`min(1240px, 94vw)`) — browsing assets is spatial.\n * Exported so the geometry contract is pinnable without rendering the portal\n * (which is `null` in the node test env).\n */\nexport const ASSET_PICKER_STAGE_PRESET = 'grid' as const\n\n/**\n * The assets shown in the picker grid: the shipped search filter (EC-152) then\n * the `accept` filter (EC-432) — an image field's picker browses only images,\n * never the project's PDFs. Order follows the library.\n */\nexport function visiblePickerAssets(\n assets: readonly AssetRecord[],\n query: string,\n accept: readonly string[] | undefined,\n): AssetRecord[] {\n return filterAssets(assets, query).filter((asset) => assetMatchesAccept(asset, accept))\n}\n\n/**\n * THE drop rule (EC-301 → EC-588): split files about to be uploaded into the ones the\n * field's `accept` admits and the NAMES of the ones it refuses. A drag-drop bypasses\n * the file dialog's soft filter, so every upload path — the field's drop zone, a drop\n * into the open picker, the picker's Upload dialog — passes through this, and the\n * caller names the refused files in a visible notice instead of uploading them.\n */\nexport function splitByAccept<F extends { name: string; type: string }>(\n files: readonly F[],\n accept: readonly string[] | undefined,\n): { accepted: F[]; rejected: string[] } {\n const accepted: F[] = []\n const rejected: string[] = []\n for (const file of files) {\n if (assetMatchesAccept({ contentType: file.type, filename: file.name }, accept)) accepted.push(file)\n else rejected.push(file.name)\n }\n return { accepted, rejected }\n}\n\n/**\n * One upload BATCH (EC-460): the upload-queue ids THIS interaction started.\n *\n * EC-588 A7: it used to be a pre-upload library snapshot, and whatever the library\n * gained since was committed — so an upload from another tab (re-listed with ours) or\n * from a sibling field's drop could land in this field. A batch now names exactly its\n * own uploads, and the store reports which asset each one landed as.\n */\nexport interface UploadBatch {\n uploadIds: readonly string[]\n}\n\n/** What batch progress reads from the assets store (`AssetsStore` satisfies it). */\nexport interface UploadSource {\n assets: readonly AssetRecord[]\n uploads: readonly { id: string; state: { phase: string } }[]\n /** The asset id an upload landed as, once its record is confirmed. */\n landedAssetId(uploadId: string): string | undefined\n}\n\nexport interface UploadBatchProgress {\n /** The batch's landed asset ids that the field accepts, in upload order. */\n added: string[]\n /**\n * The batch is finished: none of its uploads is still running, and every one that\n * landed is in the library. A failed (or dismissed) upload is terminal — it stays in\n * the queue for retry and is never awaited forever. Commit/attach decisions happen\n * only once settled for `many` targets; a single-select target commits on the first\n * arrival.\n */\n settled: boolean\n}\n\n/**\n * Where a batch stands. An upload is in one of four states: running (in the queue,\n * not failed), landed-but-not-listed (the store confirmed it, the library re-list has\n * not arrived), landed (attachable), or terminal (failed / dismissed). Only landed\n * uploads whose RECORD satisfies `accept` are `added` (A7: the commit is filtered,\n * not just the dialog). Pure — the picker and the asset field both drive their\n * effects with this.\n */\nexport function uploadBatchProgress(\n batch: UploadBatch,\n source: UploadSource,\n accept: readonly string[] | undefined,\n): UploadBatchProgress {\n const running = new Set(source.uploads.filter((u) => u.state.phase !== 'failed').map((u) => u.id))\n const library = new Map(source.assets.map((asset) => [asset.id, asset]))\n const added: string[] = []\n let pending = false\n for (const uploadId of batch.uploadIds) {\n if (running.has(uploadId)) {\n pending = true\n continue\n }\n const assetId = source.landedAssetId(uploadId)\n if (assetId === undefined) continue // failed or dismissed — terminal\n const record = library.get(assetId)\n if (!record) {\n pending = true // confirmed, but the library re-list has not landed yet\n continue\n }\n if (assetMatchesAccept(record, accept)) added.push(assetId)\n }\n return { added, settled: !pending }\n}\n\n/** The picker's commit callbacks — a pick fires `onChange` then closes the stage. */\nexport interface PickerCommit {\n onChange: (assetId: string | null) => void\n onOpenChange: (open: boolean) => void\n}\n\n/**\n * Commit a picked asset (or a clear, `assetId === null`) and close the picker —\n * the selection semantic (click = pick, fire `onChange`, close). Both the grid\n * card and the Clear affordance route through this.\n */\nexport function commitPickerSelection(assetId: string | null, { onChange, onOpenChange }: PickerCommit): void {\n onChange(assetId)\n onOpenChange(false)\n}\n\n/** The multi-commit callbacks (EC-460): a settled upload batch on a `many` field. */\nexport interface PickerCommitMany {\n onChangeMany: (assetIds: string[]) => void\n onOpenChange: (open: boolean) => void\n}\n\n/**\n * Commit EVERY asset a settled upload batch added (EC-460) and close the picker —\n * the `many`-field counterpart of {@link commitPickerSelection}, so selecting N\n * files in one picker interaction attaches all N, not just the first to land.\n */\nexport function commitPickerSelectionMany(\n assetIds: string[],\n { onChangeMany, onOpenChange }: PickerCommitMany,\n): void {\n onChangeMany(assetIds)\n onOpenChange(false)\n}\n\n/**\n * The picker props a field's value position needs, derived from the field alone\n * (EC-570 #1 → EC-588): the selected id, the field's structured `accept`, whether it\n * holds many, and the commits — a pick through the carry rule (EC-558), a settled\n * upload batch appended for a `many` field (EC-460).\n */\nexport interface AssetPickerBinding {\n value: string | null\n accept?: readonly string[]\n multiple: boolean\n onChange: (assetId: string | null) => void\n onChangeMany?: (assetIds: string[]) => void\n}\n\n/**\n * ONE picker configuration for every surface that edits an asset field — the detail\n * editor and the table cell — so the two cannot drift. Before, the cell opened the\n * picker with no `accept`, no `multiple` and no batch commit: its grid offered PDFs to\n * an image field and an N-file upload attached one (EC-570 #1). `write` receives the\n * next stored value; the caller decides how to land it (form state, cell commit).\n */\nexport function assetPickerBinding(\n field: Pick<AssetFieldDef, 'multiple' | 'accept' | 'fields'>,\n value: unknown,\n write: (next: unknown) => void,\n): AssetPickerBinding {\n const form = assetPickForm(field)\n const multiple = field.multiple\n return {\n value: multiple ? null : singleAssetIdOf(value),\n ...(field.accept ? { accept: field.accept } : {}),\n multiple,\n onChange: (picked) => write(applyAssetSelection(value, picked, multiple, form)),\n ...(multiple ? { onChangeMany: (picked: string[]) => write(appendAssetSelections(value, picked, form)) } : {}),\n }\n}\n"]}
@@ -1,3 +1,17 @@
1
+ import type { AssetsStore } from '../assets/assets-store.js';
2
+ /** The picker's props — see {@link AssetPicker}. */
3
+ export interface AssetPickerProps {
4
+ value: string | null;
5
+ open: boolean;
6
+ onOpenChange: (open: boolean) => void;
7
+ onChange: (assetId: string | null) => void;
8
+ /** EC-460: commit a whole settled upload batch (a `many` field passes this). */
9
+ onChangeMany?: (assetIds: string[]) => void;
10
+ /** Whether the consuming field holds many assets (enables batch commit). */
11
+ multiple?: boolean;
12
+ /** The field's `accept` list (EC-588) — absent means any file. */
13
+ accept?: readonly string[];
14
+ }
1
15
  /**
2
16
  * Pick an asset for a CMS `asset` field (brief §8.4, §8.6, EC-075 → EC-152).
3
17
  * Backed by the *real* asset library (the assets store over the operations
@@ -14,24 +28,39 @@
14
28
  * expanded rich-text stage toolbar it rides ABOVE that stage on the EC-392 layer
15
29
  * stack — Escape closes the browser first, the rich-text stage stays.
16
30
  *
17
- * The component CONTRACT is unchanged (`{ open, onOpenChange, value, accept,
18
- * onChange }`) so every call site — asset fields, rich-text image insertion,
19
- * editable cells — works with zero edits, just a bigger surface. EC-460 adds an
20
- * OPTIONAL multi-commit pair (`multiple` + `onChangeMany`): a `many` asset field
21
- * passes both so uploading N files in ONE picker interaction attaches ALL N
22
- * (the old flow committed the first upload to land and closed — the rest
23
- * reached the library but never the field, silent partial data loss). Grid
24
- * clicks stay single-pick-and-close in every mode.
31
+ * The component CONTRACT (`{ open, onOpenChange, value, accept, onChange }`) serves
32
+ * every call site — asset fields, rich-text image insertion, editable cells. EC-460
33
+ * adds an OPTIONAL multi-commit pair (`multiple` + `onChangeMany`): a `many` asset
34
+ * field passes both so uploading N files in ONE picker interaction attaches ALL N
35
+ * (the old flow committed the first upload to land and closed — the rest reached the
36
+ * library but never the field, silent partial data loss). Grid clicks stay
37
+ * single-pick-and-close in every mode.
38
+ *
39
+ * EC-588: `accept` is the field's STRUCTURED list (it was a file-input string that the
40
+ * core re-split — two shapes for one fact). The grid filter, the file-input attribute
41
+ * and the drop/upload split all derive from it, and an absent `accept` is unrestricted
42
+ * everywhere (the Upload dialog used to fall back to `image/*`). A field-backed caller
43
+ * passes `assetPickerBinding`'s output (`asset-picker-core`) rather than wiring props
44
+ * by hand.
25
45
  */
26
- export declare function AssetPicker({ value, open, onOpenChange, onChange, onChangeMany, multiple, accept, }: {
27
- value: string | null;
28
- open: boolean;
29
- onOpenChange: (open: boolean) => void;
30
- onChange: (assetId: string | null) => void;
31
- /** EC-460: commit a whole settled upload batch (a `many` field passes this). */
32
- onChangeMany?: (assetIds: string[]) => void;
33
- /** Whether the consuming field holds many assets (enables batch commit). */
34
- multiple?: boolean;
35
- /** The file input's `accept` attribute for in-picker upload + grid filter (field-derived). */
36
- accept?: string;
46
+ export declare function AssetPicker({ value, open, onOpenChange, onChange, onChangeMany, multiple, accept, }: AssetPickerProps): import("react").JSX.Element | null;
47
+ /** The slice of the assets store the browser reads and drives. */
48
+ export type AssetPickerStore = Pick<AssetsStore, 'status' | 'errorMessage' | 'assets' | 'uploads' | 'upload' | 'landedAssetId'>;
49
+ /**
50
+ * The media browser itself — search, Upload, the grid, the drop zone — rendered inside
51
+ * the stage. A named component of its own (EC-588) so its markup is reachable by a
52
+ * node-env smoke render (the stage portal is `null` there): the file input's `accept`
53
+ * attribute and the grid's filter are pinned against the field's `accept`.
54
+ */
55
+ export declare function AssetPickerBrowser({ store, value, onOpenChange, onChange, onChangeMany, multiple, accept, }: Omit<AssetPickerProps, 'open'> & {
56
+ store: AssetPickerStore;
57
+ }): import("react").JSX.Element;
58
+ /**
59
+ * The visible notice for files an upload path refused against the field's `accept`
60
+ * (EC-301 → EC-588): named, never silently dropped. Shared by the field's drop zone and
61
+ * the picker, which apply the same rule. Renders nothing when nothing was refused.
62
+ */
63
+ export declare function AssetRejectedNotice({ rejected, accept, }: {
64
+ rejected: readonly string[];
65
+ accept: readonly string[] | undefined;
37
66
  }): import("react").JSX.Element | null;
@@ -1,13 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { AlertTriangle, Check, ImageIcon, Search, Upload } from 'lucide-react';
3
- import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { useEffect, useRef, useState } from 'react';
4
+ import { assetAcceptAttr, describeAccept } from '@elytracms/core/cms-core';
4
5
  import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, ExpandableSurface, Input, Row, Spacer, Text, } from '@elytracms/studio/ui';
5
6
  import { cn } from '@elytracms/studio/lib/utils';
6
7
  import { translate } from '@elytracms/studio/lib/i18n';
7
8
  import { AssetThumb } from '../assets/asset-thumb.js';
8
9
  import { AssetsProvider, useAssets, useOptionalAssets } from '../assets/assets-store.js';
9
10
  import { useCollectionView } from '../collection-view/collection-view-context.js';
10
- import { ASSET_PICKER_STAGE_PRESET, commitPickerSelection, commitPickerSelectionMany, uploadBatchProgress, visiblePickerAssets, } from './asset-picker-core.js';
11
+ import { ASSET_PICKER_STAGE_PRESET, commitPickerSelection, commitPickerSelectionMany, splitByAccept, uploadBatchProgress, visiblePickerAssets, } from './asset-picker-core.js';
11
12
  /**
12
13
  * Pick an asset for a CMS `asset` field (brief §8.4, §8.6, EC-075 → EC-152).
13
14
  * Backed by the *real* asset library (the assets store over the operations
@@ -24,14 +25,20 @@ import { ASSET_PICKER_STAGE_PRESET, commitPickerSelection, commitPickerSelection
24
25
  * expanded rich-text stage toolbar it rides ABOVE that stage on the EC-392 layer
25
26
  * stack — Escape closes the browser first, the rich-text stage stays.
26
27
  *
27
- * The component CONTRACT is unchanged (`{ open, onOpenChange, value, accept,
28
- * onChange }`) so every call site — asset fields, rich-text image insertion,
29
- * editable cells — works with zero edits, just a bigger surface. EC-460 adds an
30
- * OPTIONAL multi-commit pair (`multiple` + `onChangeMany`): a `many` asset field
31
- * passes both so uploading N files in ONE picker interaction attaches ALL N
32
- * (the old flow committed the first upload to land and closed — the rest
33
- * reached the library but never the field, silent partial data loss). Grid
34
- * clicks stay single-pick-and-close in every mode.
28
+ * The component CONTRACT (`{ open, onOpenChange, value, accept, onChange }`) serves
29
+ * every call site — asset fields, rich-text image insertion, editable cells. EC-460
30
+ * adds an OPTIONAL multi-commit pair (`multiple` + `onChangeMany`): a `many` asset
31
+ * field passes both so uploading N files in ONE picker interaction attaches ALL N
32
+ * (the old flow committed the first upload to land and closed — the rest reached the
33
+ * library but never the field, silent partial data loss). Grid clicks stay
34
+ * single-pick-and-close in every mode.
35
+ *
36
+ * EC-588: `accept` is the field's STRUCTURED list (it was a file-input string that the
37
+ * core re-split — two shapes for one fact). The grid filter, the file-input attribute
38
+ * and the drop/upload split all derive from it, and an absent `accept` is unrestricted
39
+ * everywhere (the Upload dialog used to fall back to `image/*`). A field-backed caller
40
+ * passes `assetPickerBinding`'s output (`asset-picker-core`) rather than wiring props
41
+ * by hand.
35
42
  */
36
43
  export function AssetPicker({ value, open, onOpenChange, onChange, onChangeMany, multiple = false, accept, }) {
37
44
  const source = useCollectionView();
@@ -51,52 +58,69 @@ export function AssetPicker({ value, open, onOpenChange, onChange, onChangeMany,
51
58
  return (_jsx(AssetsProvider, { projectId: source.projectId, children: _jsx(AssetPickerStage, { value: value, onOpenChange: onOpenChange, onChange: onChange, ...(onChangeMany ? { onChangeMany } : {}), multiple: multiple, accept: accept }) }));
52
59
  }
53
60
  /** The open media-browser stage over a mounted assets store. */
54
- function AssetPickerStage({ value, onOpenChange, onChange, onChangeMany, multiple = false, accept, }) {
61
+ function AssetPickerStage(props) {
55
62
  const store = useAssets();
63
+ return (_jsx(ExpandableSurface, { title: translate('cms.asset.pickTitle'), closeLabel: translate('common.close'), variant: "stage", preset: ASSET_PICKER_STAGE_PRESET, onDismiss: () => props.onOpenChange(false), headerLeading: _jsx(ImageIcon, { className: "size-4 text-muted-foreground", "aria-hidden": true }), children: _jsx(AssetPickerBrowser, { store: store, ...props }) }));
64
+ }
65
+ /**
66
+ * The media browser itself — search, Upload, the grid, the drop zone — rendered inside
67
+ * the stage. A named component of its own (EC-588) so its markup is reachable by a
68
+ * node-env smoke render (the stage portal is `null` there): the file input's `accept`
69
+ * attribute and the grid's filter are pinned against the field's `accept`.
70
+ */
71
+ export function AssetPickerBrowser({ store, value, onOpenChange, onChange, onChangeMany, multiple = false, accept, }) {
56
72
  // Fresh per open: the picker unmounts entirely while closed.
57
73
  const [query, setQuery] = useState('');
58
74
  const [dragging, setDragging] = useState(false);
75
+ // Names of files a drop/upload refused against the field's `accept` (EC-588) — shown
76
+ // as a visible notice and never uploaded. Cleared by the next upload attempt.
77
+ const [rejected, setRejected] = useState([]);
59
78
  const fileInputRef = useRef(null);
60
- // The active upload BATCH (EC-460): pre-upload library snapshot + how many
61
- // files the interaction submitted, so the effect below can attach the WHOLE
62
- // batch (the `upload()` API returns nothing — the library diff is the signal).
79
+ // The active upload BATCH (EC-460): exactly the upload ids this interaction started
80
+ // (EC-588 A7), so the effect below attaches this batch's uploads and nothing else.
63
81
  const pendingRef = useRef(null);
64
82
  // Drag depth (EC-301): keeps the drop overlay steady as the pointer crosses
65
83
  // child boundaries — the same per-element dragenter/dragleave flicker the field
66
84
  // fixes. Only hide when the count returns to zero.
67
85
  const dragDepth = useRef(0);
68
86
  const assets = visiblePickerAssets(store.assets, query, accept);
69
- const libraryIds = useMemo(() => store.assets.map((a) => a.id), [store.assets]);
70
- // Uploads still running (failed entries are terminal — they stay in the queue
71
- // for retry, and must not keep a batch waiting forever).
72
- const uploadsInFlight = store.uploads.filter((u) => u.state.phase !== 'failed').length;
73
87
  // Auto-select what an in-picker upload added, then close. A `many` consumer
74
88
  // (multiple + onChangeMany) waits for the batch to SETTLE and commits every
75
89
  // added id — uploading 2 files attaches 2 (EC-460); a single consumer keeps
76
- // the first-to-land commit.
90
+ // the first-to-land commit. Only uploads whose record satisfies `accept` commit.
77
91
  useEffect(() => {
78
92
  const batch = pendingRef.current;
79
93
  if (!batch)
80
94
  return;
81
- const progress = uploadBatchProgress(batch, libraryIds, uploadsInFlight);
95
+ const progress = uploadBatchProgress(batch, store, accept);
82
96
  if (multiple && onChangeMany) {
83
- if (!progress.settled || progress.added.length === 0)
97
+ if (!progress.settled)
84
98
  return;
85
99
  pendingRef.current = null;
86
- commitPickerSelectionMany(progress.added, { onChangeMany, onOpenChange });
100
+ if (progress.added.length > 0) {
101
+ commitPickerSelectionMany(progress.added, { onChangeMany, onOpenChange });
102
+ }
87
103
  return;
88
104
  }
89
105
  const picked = progress.added[0] ?? null;
90
- if (picked === null)
106
+ if (picked === null) {
107
+ if (progress.settled)
108
+ pendingRef.current = null; // everything failed — disarm
91
109
  return;
110
+ }
92
111
  pendingRef.current = null;
93
112
  commitPickerSelection(picked, { onChange, onOpenChange });
94
- }, [libraryIds, uploadsInFlight, multiple, onChange, onChangeMany, onOpenChange]);
113
+ }, [store, accept, multiple, onChange, onChangeMany, onOpenChange]);
95
114
  function handleUpload(files) {
96
115
  if (files.length === 0)
97
116
  return;
98
- pendingRef.current = { before: new Set(libraryIds), expected: files.length };
99
- store.upload(files);
117
+ // EC-588: the drop rule — the same split the field's drop zone uses. A drop
118
+ // bypasses the dialog's soft filter; refused files are named, never uploaded.
119
+ const { accepted, rejected: refused } = splitByAccept(files, accept);
120
+ setRejected(refused);
121
+ if (accepted.length === 0)
122
+ return;
123
+ pendingRef.current = { uploadIds: store.upload(accepted) };
100
124
  }
101
125
  // The stage portals to document.body, but React synthetic events still bubble up
102
126
  // the COMPONENT tree — and this picker is rendered as a child of the asset field,
@@ -129,10 +153,25 @@ function AssetPickerStage({ value, onOpenChange, onChange, onChangeMany, multipl
129
153
  setDragging(false);
130
154
  handleUpload(Array.from(e.dataTransfer.files));
131
155
  }
132
- return (_jsx(ExpandableSurface, { title: translate('cms.asset.pickTitle'), closeLabel: translate('common.close'), variant: "stage", preset: ASSET_PICKER_STAGE_PRESET, onDismiss: () => onOpenChange(false), headerLeading: _jsx(ImageIcon, { className: "size-4 text-muted-foreground", "aria-hidden": true }), children: _jsxs("div", { className: "relative flex min-h-0 flex-1 flex-col gap-3 p-4", onDragEnter: onDragEnter, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: [_jsxs(Row, { gap: "2", align: "center", children: [_jsxs("span", { className: "relative flex-1", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { autoFocus: true, value: query, onChange: (e) => setQuery(e.target.value), placeholder: translate('cms.asset.searchPlaceholder'), className: "pl-8", "data-testid": "asset-picker-search" })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => fileInputRef.current?.click(), children: [_jsx(Upload, {}), " ", translate('common.upload')] }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, accept: accept ?? 'image/*', className: "hidden", onChange: (e) => {
133
- handleUpload(Array.from(e.target.files ?? []));
134
- e.target.value = '';
135
- } })] }), _jsx("div", { className: "grid min-h-0 flex-1 auto-rows-min grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5", "data-testid": "asset-picker-grid", children: store.status === 'loading' ? (_jsx(Text, { size: "sm", tone: "muted", className: "col-span-full p-3", children: translate('cms.asset.loading') })) : store.status === 'error' ? (_jsxs(Row, { gap: "2", className: "col-span-full p-3", "data-testid": "asset-picker-error", children: [_jsx(AlertTriangle, { className: "size-4 text-destructive" }), _jsx(Text, { size: "sm", tone: "muted", children: store.errorMessage ?? translate('cms.asset.loadError') })] })) : assets.length === 0 ? (_jsx(Text, { size: "sm", tone: "muted", className: "col-span-full p-3", children: query.trim() ? translate('cms.asset.noMatches') : translate('cms.asset.empty') })) : (assets.map((asset) => (_jsx(AssetPickerCard, { asset: asset, selected: asset.id === value, onPick: () => commitPickerSelection(asset.id, { onChange, onOpenChange }) }, asset.id)))) }), _jsxs(Row, { gap: "2", children: [_jsx(Spacer, {}), value ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => commitPickerSelection(null, { onChange, onOpenChange }), children: translate('common.clear') })) : null] }), dragging ? (_jsx("div", { className: "pointer-events-none absolute inset-0 z-10 grid place-items-center rounded-lg border-2 border-dashed border-primary bg-background/85", children: _jsxs(Row, { gap: "2", align: "center", children: [_jsx(Upload, { className: "size-5 text-primary" }), _jsx(Text, { className: "font-medium", children: translate('cms.asset.dropToUpload') })] }) })) : null] }) }));
156
+ return (_jsxs("div", { className: "relative flex min-h-0 flex-1 flex-col gap-3 p-4", onDragEnter: onDragEnter, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: [_jsxs(Row, { gap: "2", align: "center", children: [_jsxs("span", { className: "relative flex-1", children: [_jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { autoFocus: true, value: query, onChange: (e) => setQuery(e.target.value), placeholder: translate('cms.asset.searchPlaceholder'), className: "pl-8", "data-testid": "asset-picker-search" })] }), _jsxs(Button, { variant: "outline", size: "sm", onClick: () => fileInputRef.current?.click(), children: [_jsx(Upload, {}), " ", translate('common.upload')] }), _jsx("input", { ref: fileInputRef, type: "file", multiple: true, accept: assetAcceptAttr(accept), className: "hidden", "data-testid": "asset-picker-file-input", onChange: (e) => {
157
+ handleUpload(Array.from(e.target.files ?? []));
158
+ e.target.value = '';
159
+ } })] }), _jsx(AssetRejectedNotice, { rejected: rejected, accept: accept }), _jsx("div", { className: "grid min-h-0 flex-1 auto-rows-min grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5", "data-testid": "asset-picker-grid", children: store.status === 'loading' ? (_jsx(Text, { size: "sm", tone: "muted", className: "col-span-full p-3", children: translate('cms.asset.loading') })) : store.status === 'error' ? (_jsxs(Row, { gap: "2", className: "col-span-full p-3", "data-testid": "asset-picker-error", children: [_jsx(AlertTriangle, { className: "size-4 text-destructive" }), _jsx(Text, { size: "sm", tone: "muted", children: store.errorMessage ?? translate('cms.asset.loadError') })] })) : assets.length === 0 ? (_jsx(Text, { size: "sm", tone: "muted", className: "col-span-full p-3", children: query.trim() ? translate('cms.asset.noMatches') : translate('cms.asset.empty') })) : (assets.map((asset) => (_jsx(AssetPickerCard, { asset: asset, selected: asset.id === value, onPick: () => commitPickerSelection(asset.id, { onChange, onOpenChange }) }, asset.id)))) }), _jsxs(Row, { gap: "2", children: [_jsx(Spacer, {}), value ? (_jsx(Button, { variant: "ghost", size: "sm", onClick: () => commitPickerSelection(null, { onChange, onOpenChange }), children: translate('common.clear') })) : null] }), dragging ? (_jsx("div", { className: "pointer-events-none absolute inset-0 z-10 grid place-items-center rounded-lg border-2 border-dashed border-primary bg-background/85", children: _jsxs(Row, { gap: "2", align: "center", children: [_jsx(Upload, { className: "size-5 text-primary" }), _jsx(Text, { className: "font-medium", children: translate('cms.asset.dropToUpload') })] }) })) : null] }));
160
+ }
161
+ /**
162
+ * The visible notice for files an upload path refused against the field's `accept`
163
+ * (EC-301 → EC-588): named, never silently dropped. Shared by the field's drop zone and
164
+ * the picker, which apply the same rule. Renders nothing when nothing was refused.
165
+ */
166
+ export function AssetRejectedNotice({ rejected, accept, }) {
167
+ if (rejected.length === 0)
168
+ return null;
169
+ return (_jsxs(Row, { gap: "2", align: "center", "data-testid": "asset-rejected-notice", children: [_jsx(AlertTriangle, { className: "size-4 shrink-0 text-warning" }), _jsx(Text, { size: "xs", tone: "muted", children: rejected.length === 1
170
+ ? translate('cms.asset.rejected.one', { name: rejected[0], types: describeAccept(accept) })
171
+ : translate('cms.asset.rejected.other', {
172
+ count: rejected.length,
173
+ types: describeAccept(accept),
174
+ }) })] }));
136
175
  }
137
176
  /** One selectable asset: EC-400 thumb tile + title/filename + alt text. The
138
177
  * internal asset id is intentionally not shown — it's noise to editors (EC-301). */