@elytracms/studio 0.0.87 → 0.0.88
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/ai/ai-chat-panel.js +8 -1
- package/dist/components/ai/ai-chat-panel.js.map +1 -1
- package/dist/components/assets/assets-store.d.ts +11 -1
- package/dist/components/assets/assets-store.js +10 -0
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/assets/focal-point.d.ts +0 -7
- package/dist/components/assets/focal-point.js +0 -10
- package/dist/components/assets/focal-point.js.map +1 -1
- package/dist/components/builder/canvas-render-context.d.ts +12 -1
- package/dist/components/builder/canvas-render-context.js +20 -16
- package/dist/components/builder/canvas-render-context.js.map +1 -1
- package/dist/components/cms/asset-field.d.ts +2 -1
- package/dist/components/cms/asset-field.js +27 -59
- package/dist/components/cms/asset-field.js.map +1 -1
- package/dist/components/cms/asset-picker-core.d.ts +77 -40
- package/dist/components/cms/asset-picker-core.js +73 -46
- package/dist/components/cms/asset-picker-core.js.map +1 -1
- package/dist/components/cms/asset-picker.d.ts +48 -19
- package/dist/components/cms/asset-picker.js +69 -30
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/cell-edit.d.ts +3 -1
- package/dist/components/cms/cell-edit.js +2 -2
- package/dist/components/cms/cell-edit.js.map +1 -1
- package/dist/components/cms/cms-store.js +15 -23
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/editable-cell.js +3 -6
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/gate-validation.d.ts +27 -2
- package/dist/components/cms/gate-validation.js +19 -1
- package/dist/components/cms/gate-validation.js.map +1 -1
- package/dist/components/cms/rich-text/editor.js +2 -2
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/insert.d.ts +7 -0
- package/dist/components/cms/rich-text/insert.js +7 -0
- package/dist/components/cms/rich-text/insert.js.map +1 -1
- package/dist/components/nav-issues.d.ts +23 -3
- package/dist/components/nav-issues.js +38 -2
- package/dist/components/nav-issues.js.map +1 -1
- package/dist/components/section-nav.js +26 -2
- package/dist/components/section-nav.js.map +1 -1
- package/dist/lib/ai/tools.d.ts +10 -4
- package/dist/lib/ai/tools.js +12 -4
- package/dist/lib/ai/tools.js.map +1 -1
- package/dist/lib/data/collection-content.d.ts +22 -1
- package/dist/lib/data/collection-content.js +15 -0
- package/dist/lib/data/collection-content.js.map +1 -1
- package/dist/lib/engine-version.d.ts +3 -3
- package/dist/lib/engine-version.js +3 -3
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +5 -0
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +4 -0
- package/dist/lib/i18n/messages.en.js +6 -0
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/operations/session-hydration.d.ts +24 -26
- package/dist/lib/operations/session-hydration.js +15 -53
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/workspace/config-issues.d.ts +6 -0
- package/dist/lib/workspace/config-issues.js +8 -4
- package/dist/lib/workspace/config-issues.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +1 -1
- package/dist/lib/workspace/index.js +1 -1
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/router/navigate-once.d.ts +19 -4
- package/dist/router/navigate-once.js +22 -5
- package/dist/router/navigate-once.js.map +1 -1
- package/package.json +3 -3
|
@@ -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"
|
|
5
|
-
* commits" are unit-testable without a browser — the picker
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* the
|
|
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
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
/**
|
|
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:
|
|
59
|
-
*
|
|
60
|
-
* forever
|
|
61
|
-
* targets; a single-select target
|
|
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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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,
|
|
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 {
|
|
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"
|
|
7
|
-
* commits" are unit-testable without a browser — the picker
|
|
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) =>
|
|
30
|
+
return filterAssets(assets, query).filter((asset) => assetMatchesAccept(asset, accept));
|
|
51
31
|
}
|
|
52
32
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* the
|
|
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
|
|
58
|
-
|
|
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
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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,
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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(
|
|
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):
|
|
61
|
-
//
|
|
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,
|
|
95
|
+
const progress = uploadBatchProgress(batch, store, accept);
|
|
82
96
|
if (multiple && onChangeMany) {
|
|
83
|
-
if (!progress.settled
|
|
97
|
+
if (!progress.settled)
|
|
84
98
|
return;
|
|
85
99
|
pendingRef.current = null;
|
|
86
|
-
|
|
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
|
-
}, [
|
|
113
|
+
}, [store, accept, multiple, onChange, onChangeMany, onOpenChange]);
|
|
95
114
|
function handleUpload(files) {
|
|
96
115
|
if (files.length === 0)
|
|
97
116
|
return;
|
|
98
|
-
|
|
99
|
-
|
|
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 (
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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). */
|