@elytracms/studio 0.0.94 → 0.0.95

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 (126) hide show
  1. package/convex/asset-alt-migration.test.ts +2 -2
  2. package/convex/asset-meta-patch.test.ts +218 -0
  3. package/convex/asset-replace-delete-race.test.ts +127 -0
  4. package/convex/assets.ts +27 -2
  5. package/convex/delivery-redirects.test.ts +167 -0
  6. package/convex/delivery.ts +15 -5
  7. package/convex/rail-write-parity.test.ts +6 -6
  8. package/convex/redirects.ts +1 -1
  9. package/convex/schema.ts +6 -0
  10. package/dist/addon.d.ts +1 -1
  11. package/dist/addon.js.map +1 -1
  12. package/dist/components/assets/asset-detail.js +26 -11
  13. package/dist/components/assets/asset-detail.js.map +1 -1
  14. package/dist/components/assets/asset-locale.d.ts +9 -0
  15. package/dist/components/assets/asset-locale.js +16 -0
  16. package/dist/components/assets/asset-locale.js.map +1 -0
  17. package/dist/components/assets/asset-meta-label.d.ts +12 -0
  18. package/dist/components/assets/asset-meta-label.js +15 -0
  19. package/dist/components/assets/asset-meta-label.js.map +1 -0
  20. package/dist/components/assets/asset-metadata.d.ts +69 -15
  21. package/dist/components/assets/asset-metadata.js +99 -11
  22. package/dist/components/assets/asset-metadata.js.map +1 -1
  23. package/dist/components/assets/asset-search.js +8 -2
  24. package/dist/components/assets/asset-search.js.map +1 -1
  25. package/dist/components/assets/asset-thumb.js +6 -2
  26. package/dist/components/assets/asset-thumb.js.map +1 -1
  27. package/dist/components/assets/assets-collection-view-source.d.ts +23 -9
  28. package/dist/components/assets/assets-collection-view-source.js +78 -26
  29. package/dist/components/assets/assets-collection-view-source.js.map +1 -1
  30. package/dist/components/assets/assets-store.js +17 -4
  31. package/dist/components/assets/assets-store.js.map +1 -1
  32. package/dist/components/builder/builder-store.d.ts +21 -0
  33. package/dist/components/builder/builder-store.js +22 -0
  34. package/dist/components/builder/builder-store.js.map +1 -1
  35. package/dist/components/builder/canvas-commit-boundary.d.ts +22 -0
  36. package/dist/components/builder/canvas-commit-boundary.js +27 -0
  37. package/dist/components/builder/canvas-commit-boundary.js.map +1 -0
  38. package/dist/components/builder/canvas-rebase.d.ts +77 -0
  39. package/dist/components/builder/canvas-rebase.js +169 -0
  40. package/dist/components/builder/canvas-rebase.js.map +1 -0
  41. package/dist/components/builder/canvas-render-context.d.ts +6 -3
  42. package/dist/components/builder/canvas-render-context.js +17 -9
  43. package/dist/components/builder/canvas-render-context.js.map +1 -1
  44. package/dist/components/builder/composition-field-editor.d.ts +20 -2
  45. package/dist/components/builder/composition-field-editor.js +45 -27
  46. package/dist/components/builder/composition-field-editor.js.map +1 -1
  47. package/dist/components/builder/node-factory.js +15 -3
  48. package/dist/components/builder/node-factory.js.map +1 -1
  49. package/dist/components/builder/property-inspector.js +3 -1
  50. package/dist/components/builder/property-inspector.js.map +1 -1
  51. package/dist/components/builder/run-inspector.js +10 -6
  52. package/dist/components/builder/run-inspector.js.map +1 -1
  53. package/dist/components/cms/asset-field.d.ts +5 -2
  54. package/dist/components/cms/asset-field.js +34 -17
  55. package/dist/components/cms/asset-field.js.map +1 -1
  56. package/dist/components/cms/asset-picker.js +3 -1
  57. package/dist/components/cms/asset-picker.js.map +1 -1
  58. package/dist/components/cms/canvas-commit.d.ts +56 -0
  59. package/dist/components/cms/canvas-commit.js +35 -0
  60. package/dist/components/cms/canvas-commit.js.map +1 -0
  61. package/dist/components/cms/collection-table.js +23 -7
  62. package/dist/components/cms/collection-table.js.map +1 -1
  63. package/dist/components/cms/commit-policy.d.ts +6 -2
  64. package/dist/components/cms/commit-policy.js +1 -1
  65. package/dist/components/cms/commit-policy.js.map +1 -1
  66. package/dist/components/cms/detail-editor.js +11 -1
  67. package/dist/components/cms/detail-editor.js.map +1 -1
  68. package/dist/components/cms/use-document-draft.d.ts +62 -4
  69. package/dist/components/cms/use-document-draft.js +102 -32
  70. package/dist/components/cms/use-document-draft.js.map +1 -1
  71. package/dist/components/cms/use-live-url.d.ts +6 -1
  72. package/dist/components/cms/use-live-url.js +12 -9
  73. package/dist/components/cms/use-live-url.js.map +1 -1
  74. package/dist/components/collection-view/cms-collection-view-source.js +9 -5
  75. package/dist/components/collection-view/cms-collection-view-source.js.map +1 -1
  76. package/dist/components/deployment-setup-gate.js +10 -1
  77. package/dist/components/deployment-setup-gate.js.map +1 -1
  78. package/dist/components/records/record-lens.js +3 -8
  79. package/dist/components/records/record-lens.js.map +1 -1
  80. package/dist/lib/assets/upload.d.ts +25 -7
  81. package/dist/lib/assets/upload.js +30 -21
  82. package/dist/lib/assets/upload.js.map +1 -1
  83. package/dist/lib/cms/published-documents.d.ts +24 -6
  84. package/dist/lib/cms/published-documents.js +39 -6
  85. package/dist/lib/cms/published-documents.js.map +1 -1
  86. package/dist/lib/cms-views/view-model.d.ts +14 -101
  87. package/dist/lib/cms-views/view-model.js +20 -101
  88. package/dist/lib/cms-views/view-model.js.map +1 -1
  89. package/dist/lib/collection-view/collection-view-source.d.ts +6 -1
  90. package/dist/lib/collection-view/collection-view-source.js.map +1 -1
  91. package/dist/lib/engine-version.d.ts +1 -1
  92. package/dist/lib/engine-version.js +1 -1
  93. package/dist/lib/engine-version.js.map +1 -1
  94. package/dist/lib/i18n/messages.de.js +7 -3
  95. package/dist/lib/i18n/messages.de.js.map +1 -1
  96. package/dist/lib/i18n/messages.en.d.ts +5 -1
  97. package/dist/lib/i18n/messages.en.js +8 -4
  98. package/dist/lib/i18n/messages.en.js.map +1 -1
  99. package/dist/lib/operations/config-manifest-lookup.d.ts +1 -1
  100. package/dist/lib/operations/config-manifest-lookup.js +3 -3
  101. package/dist/lib/operations/config-manifest-lookup.js.map +1 -1
  102. package/dist/lib/persistence/deployment-readiness.js +14 -11
  103. package/dist/lib/persistence/deployment-readiness.js.map +1 -1
  104. package/dist/lib/persona/labels.js +1 -0
  105. package/dist/lib/persona/labels.js.map +1 -1
  106. package/dist/lib/query/index.d.ts +2 -2
  107. package/dist/lib/query/index.js +1 -1
  108. package/dist/lib/query/index.js.map +1 -1
  109. package/dist/lib/query/operations-query.d.ts +26 -0
  110. package/dist/lib/query/operations-query.js +12 -0
  111. package/dist/lib/query/operations-query.js.map +1 -1
  112. package/dist/lib/registry/index.js +2 -2
  113. package/dist/lib/registry/index.js.map +1 -1
  114. package/dist/lib/registry/preview.d.ts +10 -0
  115. package/dist/lib/registry/preview.js +13 -4
  116. package/dist/lib/registry/preview.js.map +1 -1
  117. package/dist/lib/routes/route-issues.js +6 -3
  118. package/dist/lib/routes/route-issues.js.map +1 -1
  119. package/dist/lib/workspace/workspace-source.js +6 -7
  120. package/dist/lib/workspace/workspace-source.js.map +1 -1
  121. package/dist/previews/entry-node.d.ts +14 -0
  122. package/dist/previews/entry-node.js +22 -0
  123. package/dist/previews/entry-node.js.map +1 -0
  124. package/dist/previews/harness.js +2 -16
  125. package/dist/previews/harness.js.map +1 -1
  126. package/package.json +3 -3
@@ -1,4 +1,4 @@
1
- import { liftLegacyAlt } from '@elytracms/core/persistence';
1
+ import { liftLegacyAlt, metaPatchBetween } from '@elytracms/core/persistence';
2
2
  /**
3
3
  * Asset metadata editing (EC-152). A patch is applied through the
4
4
  * {@link AssetRepository} interface — in the studio that is the EC-141
@@ -44,12 +44,96 @@ export function formatBytes(byteSize) {
44
44
  return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`;
45
45
  }
46
46
  /**
47
- * The meta bag an editor starts from (EC-587): the stored `meta` with a legacy `alt`
48
- * column lifted in (core `liftLegacyAlt`), so a not-yet-migrated asset shows the alt
49
- * every reader shows — and the save writes it to `meta`, its home.
47
+ * The draft an editor opens (EC-587 + EC-632): the stored bags, with a legacy `alt` column
48
+ * lifted into the shared one (core `liftLegacyAlt`), so a not-yet-migrated asset shows the
49
+ * alt every reader shows — and its first save moves it home.
50
50
  */
51
- export function assetMetaSeed(record) {
52
- return liftLegacyAlt(record)?.meta ?? { ...(record.meta ?? {}) };
51
+ export function assetMetaDraft(record) {
52
+ const localizedMeta = {};
53
+ for (const [locale, bucket] of Object.entries(record.localizedMeta ?? {}))
54
+ localizedMeta[locale] = { ...bucket };
55
+ return { meta: liftLegacyAlt(record)?.meta ?? { ...(record.meta ?? {}) }, localizedMeta };
56
+ }
57
+ const hasOwn = (bag, key) => bag !== undefined && Object.prototype.hasOwnProperty.call(bag, key);
58
+ /**
59
+ * What the form shows for one field (EC-632): a `localized: true` field the editor's
60
+ * locale's own value (an untranslated locale shows empty — it falls back to the default
61
+ * locale on delivery); the default locale also shows a value written before the field was
62
+ * localized (the shared bag — delivery's last-resort fallback, so it IS that locale's
63
+ * value). Any other field the shared value. Without a locale config, the shared value.
64
+ */
65
+ export function assetMetaFieldValue(draft, field, at) {
66
+ if (field.localized === true && at !== null) {
67
+ const bucket = draft.localizedMeta[at.locale];
68
+ if (hasOwn(bucket, field.name))
69
+ return bucket[field.name];
70
+ return at.locale === at.default ? draft.meta[field.name] : undefined;
71
+ }
72
+ return draft.meta[field.name];
73
+ }
74
+ /**
75
+ * Write one field of the draft (EC-632) — `undefined` removes the value:
76
+ * - a `localized: true` field goes into the editor's locale's bucket; in the DEFAULT
77
+ * locale a value written before the field was localized leaves the shared bag — the
78
+ * default bucket is its home now, so a clear there really clears it;
79
+ * - any other field goes into the shared bag, and a value a former localization left in a
80
+ * bucket is removed (one home per key — a stale bucket would shadow the shared value);
81
+ * - without a locale config (`at` null) a localized field writes the shared bag and leaves
82
+ * its translations alone.
83
+ */
84
+ export function setAssetMetaField(draft, field, at, value) {
85
+ const name = field.name;
86
+ const withValue = (bag) => {
87
+ const next = { ...(bag ?? {}) };
88
+ if (value === undefined)
89
+ delete next[name];
90
+ else
91
+ next[name] = value;
92
+ return next;
93
+ };
94
+ const without = (bag) => {
95
+ const next = { ...bag };
96
+ delete next[name];
97
+ return next;
98
+ };
99
+ if (field.localized === true) {
100
+ // No locale config installed yet: the shared bag, and the translations left untouched.
101
+ if (at === null)
102
+ return { meta: withValue(draft.meta), localizedMeta: draft.localizedMeta };
103
+ const bucket = withValue(draft.localizedMeta[at.locale]);
104
+ const localizedMeta = { ...draft.localizedMeta, [at.locale]: bucket };
105
+ const meta = at.locale === at.default && hasOwn(draft.meta, name) ? without(draft.meta) : draft.meta;
106
+ return { meta, localizedMeta };
107
+ }
108
+ const localizedMeta = {};
109
+ for (const [locale, bucket] of Object.entries(draft.localizedMeta)) {
110
+ localizedMeta[locale] = hasOwn(bucket, name) ? without(bucket) : bucket;
111
+ }
112
+ return { meta: withValue(draft.meta), localizedMeta };
113
+ }
114
+ /**
115
+ * The per-key, per-locale patch of a metadata edit (EC-634 × EC-632): what changed between
116
+ * the draft the editor OPENED ({@link assetMetaDraft} at open, or the draft of its last
117
+ * successful save) and the draft it holds — `undefined` values pruned, a removed key
118
+ * `null`, a locale it did not touch absent. Every asset metadata editor (the image options
119
+ * dialog, the asset library's detail, the assets rail) sends this instead of its buffer.
120
+ */
121
+ export function assetMetaDraftPatch(opened, edited) {
122
+ const metaPatch = metaPatchBetween(pruneMeta({ ...opened.meta }), pruneMeta({ ...edited.meta }));
123
+ const localizedMetaPatch = {};
124
+ for (const locale of new Set([...Object.keys(opened.localizedMeta), ...Object.keys(edited.localizedMeta)])) {
125
+ const patch = metaPatchBetween(pruneMeta({ ...(opened.localizedMeta[locale] ?? {}) }), pruneMeta({ ...(edited.localizedMeta[locale] ?? {}) }));
126
+ if (Object.keys(patch).length > 0)
127
+ localizedMetaPatch[locale] = patch;
128
+ }
129
+ return {
130
+ ...(Object.keys(metaPatch).length > 0 ? { metaPatch } : {}),
131
+ ...(Object.keys(localizedMetaPatch).length > 0 ? { localizedMetaPatch } : {}),
132
+ };
133
+ }
134
+ /** The form's flat view of a draft in one locale — `{ [field]: value }` for the given fields. */
135
+ export function assetMetaFormValues(draft, fields, at) {
136
+ return Object.fromEntries(fields.map((field) => [field.name, assetMetaFieldValue(draft, field, at)]));
53
137
  }
54
138
  /** A text column's write: a trimmed value sets, `null`/empty clears. */
55
139
  function textWrite(value) {
@@ -63,17 +147,21 @@ function textWrite(value) {
63
147
  * overwrite a preview backfill that finished while the dialog was open, and a
64
148
  * clear expressed as an omitted key is exactly what never persisted on Convex.
65
149
  *
66
- * EC-587: a `meta` write also clears the legacy `alt` column. The bag was seeded with
67
- * that column lifted in ({@link assetMetaSeed}), so the alt the editor saw is in the bag
68
- * (or was deliberately cleared): the column has nothing left to say, and leaving it
69
- * would re-surface a cleared alt through the read fallback (ASSETS D21).
150
+ * EC-634: `meta` travels as the per-key `metaPatch` — never the whole bag, which would
151
+ * carry a stale copy of every key the editor did not change. An empty patch is not sent.
152
+ * The legacy `alt` column (EC-587) is lifted into the stored bag and cleared by the write
153
+ * rule itself, in the same transaction, whenever a patch is applied — so an alt the editor
154
+ * did not touch survives, and one the editor cleared stays cleared (ASSETS D21).
70
155
  */
71
156
  export function assetMetadataWrite(assetId, patch) {
157
+ const metaPatch = patch.metaPatch;
158
+ const localizedMetaPatch = patch.localizedMetaPatch;
72
159
  return {
73
160
  id: assetId,
74
161
  ...(patch.title !== undefined ? { title: textWrite(patch.title) } : {}),
75
162
  ...(patch.focalPoint !== undefined ? { focalPoint: patch.focalPoint } : {}),
76
- ...(patch.meta !== undefined ? { meta: patch.meta, alt: null } : {}),
163
+ ...(metaPatch !== undefined && Object.keys(metaPatch).length > 0 ? { metaPatch } : {}),
164
+ ...(localizedMetaPatch !== undefined && Object.keys(localizedMetaPatch).length > 0 ? { localizedMetaPatch } : {}),
77
165
  };
78
166
  }
79
167
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"asset-metadata.js","sourceRoot":"","sources":["../../../src/components/assets/asset-metadata.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport { liftLegacyAlt } from '@elytracms/core/persistence'\nimport type { AssetRecord, AssetRepository, AssetWriteInput } from '@elytracms/core/persistence'\n\n/**\n * Asset metadata editing (EC-152). A patch is applied through the\n * {@link AssetRepository} interface — in the studio that is the EC-141\n * operations facade, so every edit runs the `assets.upsert` command\n * (validate → authorize → mutate → event). Pure orchestration; injectable\n * for tests.\n */\n\n/**\n * System columns the upload pipeline owns — never editable metadata, so they're\n * excluded from the asset collection's schema-driven forms: the image field's\n * options dialog (EC-301, `field-control.tsx`) and the assets rail's detail\n * surface (EC-319, `asset-detail.tsx`) share this ONE reserved set so neither\n * drifts from the other. Everything else a project declares on the `kind:'asset'`\n * collection round-trips through the record's open `meta` bag.\n */\nexport const SYSTEM_ASSET_FIELDS: ReadonlySet<string> = new Set([\n 'filename',\n 'url',\n 'contentType',\n 'byteSize',\n 'width',\n 'height',\n 'storageKey',\n 'title',\n])\n\n/**\n * The asset collection's EDITABLE metadata fields — declared fields minus the\n * system columns above and anything explicitly `readOnly` (EC-301/EC-319).\n */\nexport function assetMetaFieldsOf(collection: CollectionDef): FieldDef[] {\n return collection.fields.filter((f) => !SYSTEM_ASSET_FIELDS.has(f.name) && !f.form?.readOnly)\n}\n\n/** Drop `undefined` values so a persisted meta bag stays JSON/Convex-clean. */\nexport function pruneMeta(meta: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined))\n}\n\n/** Human-readable byte size (`1536` → `1.5 KB`). Shared by the asset grid + detail. */\nexport function formatBytes(byteSize: number): string {\n if (byteSize < 1024) return `${byteSize} B`\n if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} KB`\n return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`\n}\n\n/**\n * An editor's metadata change, in the EC-577 write rule: an ABSENT key leaves the\n * stored value untouched, `null` clears it, a value sets it.\n */\nexport interface AssetMetadataPatch {\n /** New display title: a value sets (trimmed), `null` or a trimmed-empty string clears. */\n title?: string | null\n /**\n * New focal point (EC-248), the normalized 0–1 hotspot threaded to delivery's\n * `object-position` (EC-230). `{x,y}` sets it, `null` clears it (\"Reset to\n * centre\").\n */\n focalPoint?: { x: number; y: number } | null\n /**\n * Schema-driven asset metadata bag (EC-301): alt, copyright, credit, … keyed by\n * the asset collection's `fields`. An object **replaces** the whole bag (the\n * image options dialog holds the complete edited meta, seeded through\n * {@link assetMetaSeed}); `null` clears it. `meta.alt` is the asset's ONE alt\n * (EC-587) — there is no separate alt key.\n */\n meta?: Record<string, unknown> | null\n}\n\n/**\n * The meta bag an editor starts from (EC-587): the stored `meta` with a legacy `alt`\n * column lifted in (core `liftLegacyAlt`), so a not-yet-migrated asset shows the alt\n * every reader shows — and the save writes it to `meta`, its home.\n */\nexport function assetMetaSeed(record: Pick<AssetRecord, 'alt' | 'meta'>): Record<string, unknown> {\n return liftLegacyAlt(record)?.meta ?? { ...(record.meta ?? {}) }\n}\n\n/** A text column's write: a trimmed value sets, `null`/empty clears. */\nfunction textWrite(value: string | null): string | null {\n const trimmed = value?.trim() ?? ''\n return trimmed.length > 0 ? trimmed : null\n}\n\n/**\n * The asset write for a metadata edit (EC-577): ONLY the keys the editor changed,\n * with `null` for a clear. It deliberately restates nothing else — not the\n * upload-owned columns, and never `thumb`/`lqip`: a stale copy of those would\n * overwrite a preview backfill that finished while the dialog was open, and a\n * clear expressed as an omitted key is exactly what never persisted on Convex.\n *\n * EC-587: a `meta` write also clears the legacy `alt` column. The bag was seeded with\n * that column lifted in ({@link assetMetaSeed}), so the alt the editor saw is in the bag\n * (or was deliberately cleared): the column has nothing left to say, and leaving it\n * would re-surface a cleared alt through the read fallback (ASSETS D21).\n */\nexport function assetMetadataWrite(assetId: string, patch: AssetMetadataPatch): AssetWriteInput {\n return {\n id: assetId,\n ...(patch.title !== undefined ? { title: textWrite(patch.title) } : {}),\n ...(patch.focalPoint !== undefined ? { focalPoint: patch.focalPoint } : {}),\n ...(patch.meta !== undefined ? { meta: patch.meta, alt: null } : {}),\n }\n}\n\nexport type UpdateAssetMetadataResult =\n | { ok: true; asset: AssetRecord }\n | { ok: false; message: string }\n\n/**\n * Persist a metadata edit via the repository (the operations facade in the\n * studio). No read first: the write carries only the change, and the adapter\n * resolves it against the stored record inside its own transaction (EC-577). An\n * asset that no longer exists is refused there (a metadata write cannot create\n * one). Failures come back as a structured result — never a throw the UI has to\n * guess about.\n */\nexport async function updateAssetMetadata(\n deps: { assets: AssetRepository },\n projectId: string,\n assetId: string,\n patch: AssetMetadataPatch,\n): Promise<UpdateAssetMetadataResult> {\n try {\n const asset = await deps.assets.upsertAsset(projectId, assetMetadataWrite(assetId, patch))\n return { ok: true, asset }\n } catch (cause) {\n return { ok: false, message: cause instanceof Error ? cause.message : String(cause) }\n }\n}\n"]}
1
+ {"version":3,"file":"asset-metadata.js","sourceRoot":"","sources":["../../../src/components/assets/asset-metadata.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport { liftLegacyAlt, metaPatchBetween } from '@elytracms/core/persistence'\nimport type { AssetReadLocale, AssetRecord, AssetRepository, AssetWriteInput } from '@elytracms/core/persistence'\n\n/**\n * Asset metadata editing (EC-152). A patch is applied through the\n * {@link AssetRepository} interface — in the studio that is the EC-141\n * operations facade, so every edit runs the `assets.upsert` command\n * (validate → authorize → mutate → event). Pure orchestration; injectable\n * for tests.\n */\n\n/**\n * System columns the upload pipeline owns — never editable metadata, so they're\n * excluded from the asset collection's schema-driven forms: the image field's\n * options dialog (EC-301, `field-control.tsx`) and the assets rail's detail\n * surface (EC-319, `asset-detail.tsx`) share this ONE reserved set so neither\n * drifts from the other. Everything else a project declares on the `kind:'asset'`\n * collection round-trips through the record's open `meta` bag.\n */\nexport const SYSTEM_ASSET_FIELDS: ReadonlySet<string> = new Set([\n 'filename',\n 'url',\n 'contentType',\n 'byteSize',\n 'width',\n 'height',\n 'storageKey',\n 'title',\n])\n\n/**\n * The asset collection's EDITABLE metadata fields — declared fields minus the\n * system columns above and anything explicitly `readOnly` (EC-301/EC-319).\n */\nexport function assetMetaFieldsOf(collection: CollectionDef): FieldDef[] {\n return collection.fields.filter((f) => !SYSTEM_ASSET_FIELDS.has(f.name) && !f.form?.readOnly)\n}\n\n/** Drop `undefined` values so a persisted meta bag stays JSON/Convex-clean. */\nexport function pruneMeta(meta: Record<string, unknown>): Record<string, unknown> {\n return Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined))\n}\n\n/** Human-readable byte size (`1536` → `1.5 KB`). Shared by the asset grid + detail. */\nexport function formatBytes(byteSize: number): string {\n if (byteSize < 1024) return `${byteSize} B`\n if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} KB`\n return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`\n}\n\n/**\n * An editor's metadata change, in the EC-577 write rule: an ABSENT key leaves the\n * stored value untouched, `null` clears it, a value sets it.\n */\nexport interface AssetMetadataPatch {\n /** New display title: a value sets (trimmed), `null` or a trimmed-empty string clears. */\n title?: string | null\n /**\n * New focal point (EC-248), the normalized 0–1 hotspot threaded to delivery's\n * `object-position` (EC-230). `{x,y}` sets it, `null` clears it (\"Reset to\n * centre\").\n */\n focalPoint?: { x: number; y: number } | null\n /**\n * A per-key change to the schema-driven metadata bag (EC-301 → EC-634): alt,\n * copyright, credit, … keyed by the asset collection's `fields`. A key with a value\n * sets it, `null` removes it, a key the patch does not name keeps its STORED value —\n * applied to the bag as stored, inside the write (core `resolveAssetWrite`), so an\n * editor never writes back a stale copy of a key another session changed. Build it\n * with {@link assetMetaDraftPatch} from the draft the editor opened and the one it holds.\n * `meta.alt` is the asset's ONE shared alt (EC-587) — there is no separate alt key. There\n * is no whole-bag form here: every studio editor of an asset's metadata patches per key.\n */\n metaPatch?: Record<string, unknown>\n /**\n * EC-632 — the same per-key change to the per-locale buckets of the asset collection's\n * `localized: true` fields: `{ [locale]: { [field]: value | null } }`, applied per locale\n * and key to the stored buckets inside the write.\n */\n localizedMetaPatch?: Record<string, Record<string, unknown>>\n}\n\n/**\n * An asset's metadata as an editor holds it (EC-632) — the DOCUMENT shape, so editing it\n * is the mechanism documents use: the shared bag (a document's `values`) and the\n * per-locale buckets of the `localized: true` fields (its `localized`). The form reads and\n * writes one field in one locale ({@link assetMetaFieldValue} / {@link setAssetMetaField});\n * the save is a structural diff of the whole draft ({@link assetMetaDraftPatch}), so it\n * needs no config and never writes a key or a locale the editor did not change.\n */\nexport interface AssetMetaDraft {\n readonly meta: Readonly<Record<string, unknown>>\n readonly localizedMeta: Readonly<Record<string, Readonly<Record<string, unknown>>>>\n}\n\n/** The fields of the asset collection an editor edits by — only the name and the locale flag matter here. */\ntype MetaField = Pick<FieldDef, 'name' | 'localized'>\n\n/**\n * The draft an editor opens (EC-587 + EC-632): the stored bags, with a legacy `alt` column\n * lifted into the shared one (core `liftLegacyAlt`), so a not-yet-migrated asset shows the\n * alt every reader shows — and its first save moves it home.\n */\nexport function assetMetaDraft(record: Pick<AssetRecord, 'alt' | 'meta' | 'localizedMeta'>): AssetMetaDraft {\n const localizedMeta: Record<string, Record<string, unknown>> = {}\n for (const [locale, bucket] of Object.entries(record.localizedMeta ?? {})) localizedMeta[locale] = { ...bucket }\n return { meta: liftLegacyAlt(record)?.meta ?? { ...(record.meta ?? {}) }, localizedMeta }\n}\n\nconst hasOwn = (bag: Readonly<Record<string, unknown>> | undefined, key: string): boolean =>\n bag !== undefined && Object.prototype.hasOwnProperty.call(bag, key)\n\n/**\n * What the form shows for one field (EC-632): a `localized: true` field the editor's\n * locale's own value (an untranslated locale shows empty — it falls back to the default\n * locale on delivery); the default locale also shows a value written before the field was\n * localized (the shared bag — delivery's last-resort fallback, so it IS that locale's\n * value). Any other field the shared value. Without a locale config, the shared value.\n */\nexport function assetMetaFieldValue(draft: AssetMetaDraft, field: MetaField, at: AssetReadLocale | null): unknown {\n if (field.localized === true && at !== null) {\n const bucket = draft.localizedMeta[at.locale]\n if (hasOwn(bucket, field.name)) return bucket![field.name]\n return at.locale === at.default ? draft.meta[field.name] : undefined\n }\n return draft.meta[field.name]\n}\n\n/**\n * Write one field of the draft (EC-632) — `undefined` removes the value:\n * - a `localized: true` field goes into the editor's locale's bucket; in the DEFAULT\n * locale a value written before the field was localized leaves the shared bag — the\n * default bucket is its home now, so a clear there really clears it;\n * - any other field goes into the shared bag, and a value a former localization left in a\n * bucket is removed (one home per key — a stale bucket would shadow the shared value);\n * - without a locale config (`at` null) a localized field writes the shared bag and leaves\n * its translations alone.\n */\nexport function setAssetMetaField(\n draft: AssetMetaDraft,\n field: MetaField,\n at: AssetReadLocale | null,\n value: unknown,\n): AssetMetaDraft {\n const name = field.name\n const withValue = (bag: Readonly<Record<string, unknown>> | undefined): Record<string, unknown> => {\n const next = { ...(bag ?? {}) }\n if (value === undefined) delete next[name]\n else next[name] = value\n return next\n }\n const without = (bag: Readonly<Record<string, unknown>>): Record<string, unknown> => {\n const next = { ...bag }\n delete next[name]\n return next\n }\n if (field.localized === true) {\n // No locale config installed yet: the shared bag, and the translations left untouched.\n if (at === null) return { meta: withValue(draft.meta), localizedMeta: draft.localizedMeta }\n const bucket = withValue(draft.localizedMeta[at.locale])\n const localizedMeta = { ...draft.localizedMeta, [at.locale]: bucket }\n const meta = at.locale === at.default && hasOwn(draft.meta, name) ? without(draft.meta) : draft.meta\n return { meta, localizedMeta }\n }\n const localizedMeta: Record<string, Readonly<Record<string, unknown>>> = {}\n for (const [locale, bucket] of Object.entries(draft.localizedMeta)) {\n localizedMeta[locale] = hasOwn(bucket, name) ? without(bucket) : bucket\n }\n return { meta: withValue(draft.meta), localizedMeta }\n}\n\n/**\n * The per-key, per-locale patch of a metadata edit (EC-634 × EC-632): what changed between\n * the draft the editor OPENED ({@link assetMetaDraft} at open, or the draft of its last\n * successful save) and the draft it holds — `undefined` values pruned, a removed key\n * `null`, a locale it did not touch absent. Every asset metadata editor (the image options\n * dialog, the asset library's detail, the assets rail) sends this instead of its buffer.\n */\nexport function assetMetaDraftPatch(\n opened: AssetMetaDraft,\n edited: AssetMetaDraft,\n): Pick<AssetMetadataPatch, 'metaPatch' | 'localizedMetaPatch'> {\n const metaPatch = metaPatchBetween(pruneMeta({ ...opened.meta }), pruneMeta({ ...edited.meta }))\n const localizedMetaPatch: Record<string, Record<string, unknown>> = {}\n for (const locale of new Set([...Object.keys(opened.localizedMeta), ...Object.keys(edited.localizedMeta)])) {\n const patch = metaPatchBetween(\n pruneMeta({ ...(opened.localizedMeta[locale] ?? {}) }),\n pruneMeta({ ...(edited.localizedMeta[locale] ?? {}) }),\n )\n if (Object.keys(patch).length > 0) localizedMetaPatch[locale] = patch\n }\n return {\n ...(Object.keys(metaPatch).length > 0 ? { metaPatch } : {}),\n ...(Object.keys(localizedMetaPatch).length > 0 ? { localizedMetaPatch } : {}),\n }\n}\n\n/** The form's flat view of a draft in one locale — `{ [field]: value }` for the given fields. */\nexport function assetMetaFormValues(\n draft: AssetMetaDraft,\n fields: readonly MetaField[],\n at: AssetReadLocale | null,\n): Record<string, unknown> {\n return Object.fromEntries(fields.map((field) => [field.name, assetMetaFieldValue(draft, field, at)]))\n}\n\n/** A text column's write: a trimmed value sets, `null`/empty clears. */\nfunction textWrite(value: string | null): string | null {\n const trimmed = value?.trim() ?? ''\n return trimmed.length > 0 ? trimmed : null\n}\n\n/**\n * The asset write for a metadata edit (EC-577): ONLY the keys the editor changed,\n * with `null` for a clear. It deliberately restates nothing else — not the\n * upload-owned columns, and never `thumb`/`lqip`: a stale copy of those would\n * overwrite a preview backfill that finished while the dialog was open, and a\n * clear expressed as an omitted key is exactly what never persisted on Convex.\n *\n * EC-634: `meta` travels as the per-key `metaPatch` — never the whole bag, which would\n * carry a stale copy of every key the editor did not change. An empty patch is not sent.\n * The legacy `alt` column (EC-587) is lifted into the stored bag and cleared by the write\n * rule itself, in the same transaction, whenever a patch is applied — so an alt the editor\n * did not touch survives, and one the editor cleared stays cleared (ASSETS D21).\n */\nexport function assetMetadataWrite(assetId: string, patch: AssetMetadataPatch): AssetWriteInput {\n const metaPatch = patch.metaPatch\n const localizedMetaPatch = patch.localizedMetaPatch\n return {\n id: assetId,\n ...(patch.title !== undefined ? { title: textWrite(patch.title) } : {}),\n ...(patch.focalPoint !== undefined ? { focalPoint: patch.focalPoint } : {}),\n ...(metaPatch !== undefined && Object.keys(metaPatch).length > 0 ? { metaPatch } : {}),\n ...(localizedMetaPatch !== undefined && Object.keys(localizedMetaPatch).length > 0 ? { localizedMetaPatch } : {}),\n }\n}\n\nexport type UpdateAssetMetadataResult =\n | { ok: true; asset: AssetRecord }\n | { ok: false; message: string }\n\n/**\n * Persist a metadata edit via the repository (the operations facade in the\n * studio). No read first: the write carries only the change, and the adapter\n * resolves it against the stored record inside its own transaction (EC-577). An\n * asset that no longer exists is refused there (a metadata write cannot create\n * one). Failures come back as a structured result — never a throw the UI has to\n * guess about.\n */\nexport async function updateAssetMetadata(\n deps: { assets: AssetRepository },\n projectId: string,\n assetId: string,\n patch: AssetMetadataPatch,\n): Promise<UpdateAssetMetadataResult> {\n try {\n const asset = await deps.assets.upsertAsset(projectId, assetMetadataWrite(assetId, patch))\n return { ok: true, asset }\n } catch (cause) {\n return { ok: false, message: cause instanceof Error ? cause.message : String(cause) }\n }\n}\n"]}
@@ -9,7 +9,13 @@ export function filterAssets(assets, query) {
9
9
  if (q.length === 0)
10
10
  return [...assets];
11
11
  return assets.filter((asset) =>
12
- // EC-587 (ASSETS D20): the asset's alt through THE reader (`meta.alt`, its home).
13
- [asset.filename, assetAlt(asset), asset.title].some((value) => value != null && value.toLowerCase().includes(q)));
12
+ // EC-587 (ASSETS D20): the asset's alt through THE reader (`meta.alt`, its home) —
13
+ // EC-632: in every locale it is translated into, so a search finds it from any tab.
14
+ [asset.filename, ...searchableAlts(asset), asset.title].some((value) => value != null && value.toLowerCase().includes(q)));
15
+ }
16
+ /** An asset's alt in the shared bag and in each locale it holds a bucket for (EC-632), through THE reader. */
17
+ function searchableAlts(asset) {
18
+ const locales = Object.keys(asset.localizedMeta ?? {});
19
+ return [assetAlt(asset, null), ...locales.map((locale) => assetAlt(asset, { locale, default: locale }))];
14
20
  }
15
21
  //# sourceMappingURL=asset-search.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"asset-search.js","sourceRoot":"","sources":["../../../src/components/assets/asset-search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAA;AAGtD;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,MAA8B,EAAE,KAAa;IACxE,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;IACpC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,GAAG,MAAM,CAAC,CAAA;IACtC,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE;IAC7B,kFAAkF;IAClF,CAAC,KAAK,CAAC,QAAQ,EAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CACjD,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,IAAI,IAAI,IAAI,KAAK,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAC5D,CACF,CAAA;AACH,CAAC","sourcesContent":["import { assetAlt } from '@elytracms/core/persistence'\nimport type { AssetRecord } from '@elytracms/core/persistence'\n\n/**\n * Client-side asset search (EC-152): case-insensitive substring match over\n * filename, alt text, and title. Pure and deterministic — input order is\n * preserved; an empty/whitespace query matches everything.\n */\nexport function filterAssets(assets: readonly AssetRecord[], query: string): AssetRecord[] {\n const q = query.trim().toLowerCase()\n if (q.length === 0) return [...assets]\n return assets.filter((asset) =>\n // EC-587 (ASSETS D20): the asset's alt through THE reader (`meta.alt`, its home).\n [asset.filename, assetAlt(asset), asset.title].some(\n (value) => value != null && value.toLowerCase().includes(q),\n ),\n )\n}\n"]}
1
+ {"version":3,"file":"asset-search.js","sourceRoot":"","sources":["../../../src/components/assets/asset-search.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAA;AAGtD;;;;GAIG;AACH,MAAM,UAAU,YAAY,CAAC,MAA8B,EAAE,KAAa;IACxE,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;IACpC,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,CAAC,GAAG,MAAM,CAAC,CAAA;IACtC,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE;IAC7B,mFAAmF;IACnF,oFAAoF;IACpF,CAAC,KAAK,CAAC,QAAQ,EAAE,GAAG,cAAc,CAAC,KAAK,CAAC,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,CAC1D,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,IAAI,IAAI,IAAI,KAAK,CAAC,WAAW,EAAE,CAAC,QAAQ,CAAC,CAAC,CAAC,CAC5D,CACF,CAAA;AACH,CAAC;AAED,8GAA8G;AAC9G,SAAS,cAAc,CAAC,KAAkB;IACxC,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,aAAa,IAAI,EAAE,CAAC,CAAA;IACtD,OAAO,CAAC,QAAQ,CAAC,KAAK,EAAE,IAAI,CAAC,EAAE,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,QAAQ,CAAC,KAAK,EAAE,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,CAAA;AAC1G,CAAC","sourcesContent":["import { assetAlt } from '@elytracms/core/persistence'\nimport type { AssetRecord } from '@elytracms/core/persistence'\n\n/**\n * Client-side asset search (EC-152): case-insensitive substring match over\n * filename, alt text, and title. Pure and deterministic — input order is\n * preserved; an empty/whitespace query matches everything.\n */\nexport function filterAssets(assets: readonly AssetRecord[], query: string): AssetRecord[] {\n const q = query.trim().toLowerCase()\n if (q.length === 0) return [...assets]\n return assets.filter((asset) =>\n // EC-587 (ASSETS D20): the asset's alt through THE reader (`meta.alt`, its home) —\n // EC-632: in every locale it is translated into, so a search finds it from any tab.\n [asset.filename, ...searchableAlts(asset), asset.title].some(\n (value) => value != null && value.toLowerCase().includes(q),\n ),\n )\n}\n\n/** An asset's alt in the shared bag and in each locale it holds a bucket for (EC-632), through THE reader. */\nfunction searchableAlts(asset: AssetRecord): (string | null)[] {\n const locales = Object.keys(asset.localizedMeta ?? {})\n return [assetAlt(asset, null), ...locales.map((locale) => assetAlt(asset, { locale, default: locale }))]\n}\n"]}
@@ -6,6 +6,7 @@ import { Text } from '@elytracms/studio/ui';
6
6
  import { translate } from '../../lib/i18n/index.js';
7
7
  import { cn } from '../../lib/utils.js';
8
8
  import { useOptionalAssets } from './assets-store.js';
9
+ import { useAssetReadLocale } from './asset-locale.js';
9
10
  import { isDisplayableAssetUrl } from '../../lib/assets/index.js';
10
11
  import { CENTER_FOCAL_POINT, focalPointFromPoint, focalPointObjectPosition, } from './focal-point.js';
11
12
  /** Resolve an asset record by id from the ambient library, if one is mounted. */
@@ -53,8 +54,10 @@ function thumbnailSrc(record) {
53
54
  */
54
55
  export function AssetThumb({ record, className, alt, }) {
55
56
  const src = thumbnailSrc(record);
57
+ // EC-632: the alt in the studio's locale (a localized alt shows the open locale's).
58
+ const at = useAssetReadLocale();
56
59
  if (record && src) {
57
- return (_jsx("img", { src: src, alt: alt ?? assetAlt(record) ?? record.title ?? record.filename, loading: "lazy", style: { objectPosition: focalPointObjectPosition(record.focalPoint) }, className: cn('rounded border border-border bg-muted object-cover', className) }));
60
+ return (_jsx("img", { src: src, alt: alt ?? assetAlt(record, at) ?? record.title ?? record.filename, loading: "lazy", style: { objectPosition: focalPointObjectPosition(record.focalPoint) }, className: cn('rounded border border-border bg-muted object-cover', className) }));
58
61
  }
59
62
  return (_jsx("div", { className: cn('flex items-center justify-center rounded border border-border bg-muted text-muted-foreground', className), children: _jsx(Image, { className: "size-4", "aria-hidden": true }) }));
60
63
  }
@@ -70,6 +73,7 @@ export function FocalPointPicker({ record, point, onChange, onPreview, disabled,
70
73
  const marker = point ?? CENTER_FOCAL_POINT;
71
74
  const dragging = useRef(false);
72
75
  const [loaded, setLoaded] = useState(false);
76
+ const at = useAssetReadLocale(); // EC-632: the alt in the studio's locale
73
77
  if (!isResolvableImage(record)) {
74
78
  return (_jsx(Text, { size: "xs", tone: "subtle", children: translate('assets.focalPoint.noPreview') }));
75
79
  }
@@ -93,7 +97,7 @@ export function FocalPointPicker({ record, point, onChange, onPreview, disabled,
93
97
  e.currentTarget.releasePointerCapture(e.pointerId);
94
98
  onChange(pointFrom(e)); // commit on release only
95
99
  }
96
- return (_jsxs("button", { type: "button", "aria-label": translate('assets.focalPoint.setLabel'), disabled: disabled, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, className: cn('relative block w-full touch-none select-none overflow-hidden rounded border border-border bg-muted', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40', disabled ? 'cursor-default opacity-60' : 'cursor-crosshair'), children: [_jsx("img", { src: record.url, alt: assetAlt(record) ?? record.filename, draggable: false, onLoad: () => setLoaded(true), className: cn('block h-auto w-full select-none transition-opacity', loaded ? 'opacity-100' : 'opacity-0') }), !loaded ? (_jsx("span", { className: "absolute inset-0 grid aspect-video place-items-center bg-muted", children: _jsx(Image, { className: "size-6 animate-pulse text-muted-foreground", "aria-hidden": true }) })) : (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.55)] ring-1 ring-black/40", style: { left: `${marker.x * 100}%`, top: `${marker.y * 100}%` } }))] }));
100
+ return (_jsxs("button", { type: "button", "aria-label": translate('assets.focalPoint.setLabel'), disabled: disabled, onPointerDown: onPointerDown, onPointerMove: onPointerMove, onPointerUp: onPointerUp, className: cn('relative block w-full touch-none select-none overflow-hidden rounded border border-border bg-muted', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40', disabled ? 'cursor-default opacity-60' : 'cursor-crosshair'), children: [_jsx("img", { src: record.url, alt: assetAlt(record, at) ?? record.filename, draggable: false, onLoad: () => setLoaded(true), className: cn('block h-auto w-full select-none transition-opacity', loaded ? 'opacity-100' : 'opacity-0') }), !loaded ? (_jsx("span", { className: "absolute inset-0 grid aspect-video place-items-center bg-muted", children: _jsx(Image, { className: "size-6 animate-pulse text-muted-foreground", "aria-hidden": true }) })) : (_jsx("span", { "aria-hidden": true, className: "pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.55)] ring-1 ring-black/40", style: { left: `${marker.x * 100}%`, top: `${marker.y * 100}%` } }))] }));
97
101
  }
98
102
  /** Common crop aspect ratios shown as focal-aware previews (EC-301). */
99
103
  const FOCAL_CROP_RATIOS = [
@@ -1 +1 @@
1
- {"version":3,"file":"asset-thumb.js","sourceRoot":"","sources":["../../../src/components/assets/asset-thumb.tsx"],"names":[],"mappings":"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","sourcesContent":["import { useMemo, useRef, useState } from 'react'\nimport { Image } from 'lucide-react'\nimport { assetAlt } from '@elytracms/core/persistence'\nimport type { AssetRecord } from '@elytracms/core/persistence'\nimport { Text } from '@elytracms/studio/ui'\nimport { translate } from '../../lib/i18n'\nimport { cn } from '../../lib/utils'\nimport { useOptionalAssets } from './assets-store'\nimport { isDisplayableAssetUrl } from '../../lib/assets'\nimport {\n CENTER_FOCAL_POINT,\n focalPointFromPoint,\n focalPointObjectPosition,\n} from './focal-point'\nimport type { FocalPoint } from './focal-point'\n\n/** Resolve an asset record by id from the ambient library, if one is mounted. */\nexport function useAssetRecord(assetId: string | null | undefined): AssetRecord | undefined {\n const store = useOptionalAssets()\n return useMemo(() => {\n if (!assetId || !store || store.status !== 'ready') return undefined\n return store.assets.find((a) => a.id === assetId)\n }, [assetId, store])\n}\n\n/** True when the record is an image with a browser-displayable URL (EC-212, EC-459). */\nfunction isResolvableImage(record: AssetRecord | undefined): record is AssetRecord & { url: string } {\n return (\n record !== undefined &&\n record.contentType.startsWith('image/') &&\n isDisplayableAssetUrl(record.url)\n )\n}\n\n/**\n * The best studio thumbnail src for an image record (EC-400): prefer the stored\n * downscaled `thumb` variant when it is browser-displayable, else fall back to\n * the full original `url` (EC-459: `http(s)`, `blob:`, and `data:` all qualify —\n * an opaque backend locator like `memory://` does not). A missing/broken thumb\n * therefore degrades to the original — never a broken tile (scope §4). Non-image\n * / unresolvable records yield `undefined` (the icon placeholder). Focal-point\n * editing + the canvas keep the original elsewhere (delivery fidelity) — this\n * preference is thumbnail-only.\n */\nfunction thumbnailSrc(record: AssetRecord | undefined): string | undefined {\n if (!record || !record.contentType.startsWith('image/')) return undefined\n const thumbUrl = record.thumb?.url\n if (isDisplayableAssetUrl(thumbUrl)) return thumbUrl\n if (isDisplayableAssetUrl(record.url)) return record.url\n return undefined\n}\n\n/**\n * A read-only image thumbnail honouring the asset's focal point (EC-248): the\n * preview crops with the same `object-position` delivery uses (EC-230), so the\n * editor sees exactly what a focal-point change does. Prefers the EC-400\n * downscaled `thumb` variant (light egress) and falls back to the original.\n * Non-image / unresolvable assets fall back to the labelled icon, matching the\n * table cell's preview.\n */\nexport function AssetThumb({\n record,\n className,\n alt,\n}: {\n record: AssetRecord | undefined\n className?: string\n alt?: string\n}) {\n const src = thumbnailSrc(record)\n if (record && src) {\n return (\n <img\n src={src}\n alt={alt ?? assetAlt(record) ?? record.title ?? record.filename}\n loading=\"lazy\"\n style={{ objectPosition: focalPointObjectPosition(record.focalPoint) }}\n className={cn('rounded border border-border bg-muted object-cover', className)}\n />\n )\n }\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded border border-border bg-muted text-muted-foreground',\n className,\n )}\n >\n <Image className=\"size-4\" aria-hidden />\n </div>\n )\n}\n\n/**\n * The focal-point picker (EC-248/EC-301): click or **drag** anywhere on the image\n * to place the normalized 0–1 hotspot delivery threads to `object-position`\n * (EC-230). The marker follows the pointer live while dragging and only commits on\n * release (`onPreview` updates the marker, `onChange` commits). `onChange(null)`\n * resets to centre. Shows a placeholder until the image loads; renders a muted note\n * when the asset has no resolvable image.\n */\nexport function FocalPointPicker({\n record,\n point,\n onChange,\n onPreview,\n disabled,\n}: {\n record: AssetRecord | undefined\n point: FocalPoint | undefined\n onChange: (point: FocalPoint | null) => void\n /** Called continuously while dragging to move the marker without committing. */\n onPreview?: (point: FocalPoint) => void\n disabled?: boolean\n}) {\n const marker = point ?? CENTER_FOCAL_POINT\n const dragging = useRef(false)\n const [loaded, setLoaded] = useState(false)\n\n if (!isResolvableImage(record)) {\n return (\n <Text size=\"xs\" tone=\"subtle\">\n {translate('assets.focalPoint.noPreview')}\n </Text>\n )\n }\n\n const pointFrom = (e: React.PointerEvent<HTMLElement>): FocalPoint =>\n focalPointFromPoint(e.currentTarget.getBoundingClientRect(), e.clientX, e.clientY)\n\n function onPointerDown(e: React.PointerEvent<HTMLButtonElement>) {\n if (disabled) return\n dragging.current = true\n e.currentTarget.setPointerCapture(e.pointerId)\n ;(onPreview ?? onChange)(pointFrom(e))\n }\n function onPointerMove(e: React.PointerEvent<HTMLButtonElement>) {\n if (disabled || !dragging.current) return\n ;(onPreview ?? onChange)(pointFrom(e))\n }\n function onPointerUp(e: React.PointerEvent<HTMLButtonElement>) {\n if (disabled || !dragging.current) return\n dragging.current = false\n e.currentTarget.releasePointerCapture(e.pointerId)\n onChange(pointFrom(e)) // commit on release only\n }\n\n return (\n <button\n type=\"button\"\n aria-label={translate('assets.focalPoint.setLabel')}\n disabled={disabled}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n className={cn(\n 'relative block w-full touch-none select-none overflow-hidden rounded border border-border bg-muted',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',\n disabled ? 'cursor-default opacity-60' : 'cursor-crosshair',\n )}\n >\n {/* Width-constrained, natural aspect (no object-fit), so the button wraps\n the image tightly and the bounding rect equals the image box — the\n normalized pointer coordinates are then exact, with no letterbox offset. */}\n <img\n src={record.url}\n alt={assetAlt(record) ?? record.filename}\n draggable={false}\n onLoad={() => setLoaded(true)}\n className={cn('block h-auto w-full select-none transition-opacity', loaded ? 'opacity-100' : 'opacity-0')}\n />\n {!loaded ? (\n <span className=\"absolute inset-0 grid aspect-video place-items-center bg-muted\">\n <Image className=\"size-6 animate-pulse text-muted-foreground\" aria-hidden />\n </span>\n ) : (\n <span\n aria-hidden\n className=\"pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.55)] ring-1 ring-black/40\"\n style={{ left: `${marker.x * 100}%`, top: `${marker.y * 100}%` }}\n />\n )}\n </button>\n )\n}\n\n/** Common crop aspect ratios shown as focal-aware previews (EC-301). */\nconst FOCAL_CROP_RATIOS = [\n { label: '9:16', className: 'aspect-[9/16]' },\n { label: '3:4', className: 'aspect-[3/4]' },\n { label: '1:1', className: 'aspect-square' },\n { label: '4:3', className: 'aspect-[4/3]' },\n { label: '16:9', className: 'aspect-video' },\n] as const\n\n/**\n * A row of common-ratio crop previews (EC-301), each cropping the image with the\n * same `object-position` delivery uses — so the editor sees how the focal point\n * holds the subject across portrait/square/landscape crops. Updates live as the\n * point is dragged.\n */\nexport function FocalCropPreviews({\n record,\n point,\n}: {\n record: AssetRecord | undefined\n point: FocalPoint | undefined\n}) {\n if (!isResolvableImage(record)) return null\n const objectPosition = focalPointObjectPosition(point)\n return (\n <div className=\"flex items-end gap-2\">\n {FOCAL_CROP_RATIOS.map((ratio) => (\n <div key={ratio.label} className=\"flex min-w-0 flex-col items-center gap-1\">\n <div className={cn('h-14 overflow-hidden rounded border border-border bg-muted', ratio.className)}>\n <img\n src={record.url}\n alt=\"\"\n draggable={false}\n style={{ objectPosition }}\n className=\"h-full w-full object-cover\"\n />\n </div>\n <Text size=\"xs\" tone=\"subtle\">\n {ratio.label}\n </Text>\n </div>\n ))}\n </div>\n )\n}\n"]}
1
+ {"version":3,"file":"asset-thumb.js","sourceRoot":"","sources":["../../../src/components/assets/asset-thumb.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAA;AACjD,OAAO,EAAE,KAAK,EAAE,MAAM,cAAc,CAAA;AACpC,OAAO,EAAE,QAAQ,EAAE,MAAM,6BAA6B,CAAA;AAEtD,OAAO,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AAC3C,OAAO,EAAE,SAAS,EAAE,MAAM,gBAAgB,CAAA;AAC1C,OAAO,EAAE,EAAE,EAAE,MAAM,iBAAiB,CAAA;AACpC,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAA;AAClD,OAAO,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AACnD,OAAO,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAA;AACxD,OAAO,EACL,kBAAkB,EAClB,mBAAmB,EACnB,wBAAwB,GACzB,MAAM,eAAe,CAAA;AAGtB,iFAAiF;AACjF,MAAM,UAAU,cAAc,CAAC,OAAkC;IAC/D,MAAM,KAAK,GAAG,iBAAiB,EAAE,CAAA;IACjC,OAAO,OAAO,CAAC,GAAG,EAAE;QAClB,IAAI,CAAC,OAAO,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,MAAM,KAAK,OAAO;YAAE,OAAO,SAAS,CAAA;QACpE,OAAO,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,OAAO,CAAC,CAAA;IACnD,CAAC,EAAE,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC,CAAA;AACtB,CAAC;AAED,wFAAwF;AACxF,SAAS,iBAAiB,CAAC,MAA+B;IACxD,OAAO,CACL,MAAM,KAAK,SAAS;QACpB,MAAM,CAAC,WAAW,CAAC,UAAU,CAAC,QAAQ,CAAC;QACvC,qBAAqB,CAAC,MAAM,CAAC,GAAG,CAAC,CAClC,CAAA;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,SAAS,YAAY,CAAC,MAA+B;IACnD,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO,SAAS,CAAA;IACzE,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,EAAE,GAAG,CAAA;IAClC,IAAI,qBAAqB,CAAC,QAAQ,CAAC;QAAE,OAAO,QAAQ,CAAA;IACpD,IAAI,qBAAqB,CAAC,MAAM,CAAC,GAAG,CAAC;QAAE,OAAO,MAAM,CAAC,GAAG,CAAA;IACxD,OAAO,SAAS,CAAA;AAClB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,UAAU,CAAC,EACzB,MAAM,EACN,SAAS,EACT,GAAG,GAKJ;IACC,MAAM,GAAG,GAAG,YAAY,CAAC,MAAM,CAAC,CAAA;IAChC,oFAAoF;IACpF,MAAM,EAAE,GAAG,kBAAkB,EAAE,CAAA;IAC/B,IAAI,MAAM,IAAI,GAAG,EAAE,CAAC;QAClB,OAAO,CACL,cACE,GAAG,EAAE,GAAG,EACR,GAAG,EAAE,GAAG,IAAI,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,QAAQ,EACnE,OAAO,EAAC,MAAM,EACd,KAAK,EAAE,EAAE,cAAc,EAAE,wBAAwB,CAAC,MAAM,CAAC,UAAU,CAAC,EAAE,EACtE,SAAS,EAAE,EAAE,CAAC,oDAAoD,EAAE,SAAS,CAAC,GAC9E,CACH,CAAA;IACH,CAAC;IACD,OAAO,CACL,cACE,SAAS,EAAE,EAAE,CACX,8FAA8F,EAC9F,SAAS,CACV,YAED,KAAC,KAAK,IAAC,SAAS,EAAC,QAAQ,wBAAe,GACpC,CACP,CAAA;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,gBAAgB,CAAC,EAC/B,MAAM,EACN,KAAK,EACL,QAAQ,EACR,SAAS,EACT,QAAQ,GAQT;IACC,MAAM,MAAM,GAAG,KAAK,IAAI,kBAAkB,CAAA;IAC1C,MAAM,QAAQ,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC9B,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,CAAA;IAC3C,MAAM,EAAE,GAAG,kBAAkB,EAAE,CAAA,CAAC,yCAAyC;IAEzE,IAAI,CAAC,iBAAiB,CAAC,MAAM,CAAC,EAAE,CAAC;QAC/B,OAAO,CACL,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,QAAQ,YAC1B,SAAS,CAAC,6BAA6B,CAAC,GACpC,CACR,CAAA;IACH,CAAC;IAED,MAAM,SAAS,GAAG,CAAC,CAAkC,EAAc,EAAE,CACnE,mBAAmB,CAAC,CAAC,CAAC,aAAa,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,OAAO,CAAC,CAAA;IAEpF,SAAS,aAAa,CAAC,CAAwC;QAC7D,IAAI,QAAQ;YAAE,OAAM;QACpB,QAAQ,CAAC,OAAO,GAAG,IAAI,CAAA;QACvB,CAAC,CAAC,aAAa,CAAC,iBAAiB,CAAC,CAAC,CAAC,SAAS,CAAC,CAC7C;QAAA,CAAC,SAAS,IAAI,QAAQ,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;IACxC,CAAC;IACD,SAAS,aAAa,CAAC,CAAwC;QAC7D,IAAI,QAAQ,IAAI,CAAC,QAAQ,CAAC,OAAO;YAAE,OAClC;QAAA,CAAC,SAAS,IAAI,QAAQ,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA;IACxC,CAAC;IACD,SAAS,WAAW,CAAC,CAAwC;QAC3D,IAAI,QAAQ,IAAI,CAAC,QAAQ,CAAC,OAAO;YAAE,OAAM;QACzC,QAAQ,CAAC,OAAO,GAAG,KAAK,CAAA;QACxB,CAAC,CAAC,aAAa,CAAC,qBAAqB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAA;QAClD,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAA,CAAC,yBAAyB;IAClD,CAAC;IAED,OAAO,CACL,kBACE,IAAI,EAAC,QAAQ,gBACD,SAAS,CAAC,4BAA4B,CAAC,EACnD,QAAQ,EAAE,QAAQ,EAClB,aAAa,EAAE,aAAa,EAC5B,aAAa,EAAE,aAAa,EAC5B,WAAW,EAAE,WAAW,EACxB,SAAS,EAAE,EAAE,CACX,oGAAoG,EACpG,4EAA4E,EAC5E,QAAQ,CAAC,CAAC,CAAC,2BAA2B,CAAC,CAAC,CAAC,kBAAkB,CAC5D,aAKD,cACE,GAAG,EAAE,MAAM,CAAC,GAAG,EACf,GAAG,EAAE,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC,IAAI,MAAM,CAAC,QAAQ,EAC5C,SAAS,EAAE,KAAK,EAChB,MAAM,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,EAC7B,SAAS,EAAE,EAAE,CAAC,oDAAoD,EAAE,MAAM,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,WAAW,CAAC,GACzG,EACD,CAAC,MAAM,CAAC,CAAC,CAAC,CACT,eAAM,SAAS,EAAC,gEAAgE,YAC9E,KAAC,KAAK,IAAC,SAAS,EAAC,4CAA4C,wBAAe,GACvE,CACR,CAAC,CAAC,CAAC,CACF,oCAEE,SAAS,EAAC,qKAAqK,EAC/K,KAAK,EAAE,EAAE,IAAI,EAAE,GAAG,MAAM,CAAC,CAAC,GAAG,GAAG,GAAG,EAAE,GAAG,EAAE,GAAG,MAAM,CAAC,CAAC,GAAG,GAAG,GAAG,EAAE,GAChE,CACH,IACM,CACV,CAAA;AACH,CAAC;AAED,wEAAwE;AACxE,MAAM,iBAAiB,GAAG;IACxB,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,eAAe,EAAE;IAC7C,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE;IAC3C,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE;IAC5C,EAAE,KAAK,EAAE,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE;IAC3C,EAAE,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,cAAc,EAAE;CACpC,CAAA;AAEV;;;;;GAKG;AACH,MAAM,UAAU,iBAAiB,CAAC,EAChC,MAAM,EACN,KAAK,GAIN;IACC,IAAI,CAAC,iBAAiB,CAAC,MAAM,CAAC;QAAE,OAAO,IAAI,CAAA;IAC3C,MAAM,cAAc,GAAG,wBAAwB,CAAC,KAAK,CAAC,CAAA;IACtD,OAAO,CACL,cAAK,SAAS,EAAC,sBAAsB,YAClC,iBAAiB,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAChC,eAAuB,SAAS,EAAC,0CAA0C,aACzE,cAAK,SAAS,EAAE,EAAE,CAAC,4DAA4D,EAAE,KAAK,CAAC,SAAS,CAAC,YAC/F,cACE,GAAG,EAAE,MAAM,CAAC,GAAG,EACf,GAAG,EAAC,EAAE,EACN,SAAS,EAAE,KAAK,EAChB,KAAK,EAAE,EAAE,cAAc,EAAE,EACzB,SAAS,EAAC,4BAA4B,GACtC,GACE,EACN,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,QAAQ,YAC1B,KAAK,CAAC,KAAK,GACP,KAZC,KAAK,CAAC,KAAK,CAaf,CACP,CAAC,GACE,CACP,CAAA;AACH,CAAC","sourcesContent":["import { useMemo, useRef, useState } from 'react'\nimport { Image } from 'lucide-react'\nimport { assetAlt } from '@elytracms/core/persistence'\nimport type { AssetRecord } from '@elytracms/core/persistence'\nimport { Text } from '@elytracms/studio/ui'\nimport { translate } from '../../lib/i18n'\nimport { cn } from '../../lib/utils'\nimport { useOptionalAssets } from './assets-store'\nimport { useAssetReadLocale } from './asset-locale'\nimport { isDisplayableAssetUrl } from '../../lib/assets'\nimport {\n CENTER_FOCAL_POINT,\n focalPointFromPoint,\n focalPointObjectPosition,\n} from './focal-point'\nimport type { FocalPoint } from './focal-point'\n\n/** Resolve an asset record by id from the ambient library, if one is mounted. */\nexport function useAssetRecord(assetId: string | null | undefined): AssetRecord | undefined {\n const store = useOptionalAssets()\n return useMemo(() => {\n if (!assetId || !store || store.status !== 'ready') return undefined\n return store.assets.find((a) => a.id === assetId)\n }, [assetId, store])\n}\n\n/** True when the record is an image with a browser-displayable URL (EC-212, EC-459). */\nfunction isResolvableImage(record: AssetRecord | undefined): record is AssetRecord & { url: string } {\n return (\n record !== undefined &&\n record.contentType.startsWith('image/') &&\n isDisplayableAssetUrl(record.url)\n )\n}\n\n/**\n * The best studio thumbnail src for an image record (EC-400): prefer the stored\n * downscaled `thumb` variant when it is browser-displayable, else fall back to\n * the full original `url` (EC-459: `http(s)`, `blob:`, and `data:` all qualify —\n * an opaque backend locator like `memory://` does not). A missing/broken thumb\n * therefore degrades to the original — never a broken tile (scope §4). Non-image\n * / unresolvable records yield `undefined` (the icon placeholder). Focal-point\n * editing + the canvas keep the original elsewhere (delivery fidelity) — this\n * preference is thumbnail-only.\n */\nfunction thumbnailSrc(record: AssetRecord | undefined): string | undefined {\n if (!record || !record.contentType.startsWith('image/')) return undefined\n const thumbUrl = record.thumb?.url\n if (isDisplayableAssetUrl(thumbUrl)) return thumbUrl\n if (isDisplayableAssetUrl(record.url)) return record.url\n return undefined\n}\n\n/**\n * A read-only image thumbnail honouring the asset's focal point (EC-248): the\n * preview crops with the same `object-position` delivery uses (EC-230), so the\n * editor sees exactly what a focal-point change does. Prefers the EC-400\n * downscaled `thumb` variant (light egress) and falls back to the original.\n * Non-image / unresolvable assets fall back to the labelled icon, matching the\n * table cell's preview.\n */\nexport function AssetThumb({\n record,\n className,\n alt,\n}: {\n record: AssetRecord | undefined\n className?: string\n alt?: string\n}) {\n const src = thumbnailSrc(record)\n // EC-632: the alt in the studio's locale (a localized alt shows the open locale's).\n const at = useAssetReadLocale()\n if (record && src) {\n return (\n <img\n src={src}\n alt={alt ?? assetAlt(record, at) ?? record.title ?? record.filename}\n loading=\"lazy\"\n style={{ objectPosition: focalPointObjectPosition(record.focalPoint) }}\n className={cn('rounded border border-border bg-muted object-cover', className)}\n />\n )\n }\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded border border-border bg-muted text-muted-foreground',\n className,\n )}\n >\n <Image className=\"size-4\" aria-hidden />\n </div>\n )\n}\n\n/**\n * The focal-point picker (EC-248/EC-301): click or **drag** anywhere on the image\n * to place the normalized 0–1 hotspot delivery threads to `object-position`\n * (EC-230). The marker follows the pointer live while dragging and only commits on\n * release (`onPreview` updates the marker, `onChange` commits). `onChange(null)`\n * resets to centre. Shows a placeholder until the image loads; renders a muted note\n * when the asset has no resolvable image.\n */\nexport function FocalPointPicker({\n record,\n point,\n onChange,\n onPreview,\n disabled,\n}: {\n record: AssetRecord | undefined\n point: FocalPoint | undefined\n onChange: (point: FocalPoint | null) => void\n /** Called continuously while dragging to move the marker without committing. */\n onPreview?: (point: FocalPoint) => void\n disabled?: boolean\n}) {\n const marker = point ?? CENTER_FOCAL_POINT\n const dragging = useRef(false)\n const [loaded, setLoaded] = useState(false)\n const at = useAssetReadLocale() // EC-632: the alt in the studio's locale\n\n if (!isResolvableImage(record)) {\n return (\n <Text size=\"xs\" tone=\"subtle\">\n {translate('assets.focalPoint.noPreview')}\n </Text>\n )\n }\n\n const pointFrom = (e: React.PointerEvent<HTMLElement>): FocalPoint =>\n focalPointFromPoint(e.currentTarget.getBoundingClientRect(), e.clientX, e.clientY)\n\n function onPointerDown(e: React.PointerEvent<HTMLButtonElement>) {\n if (disabled) return\n dragging.current = true\n e.currentTarget.setPointerCapture(e.pointerId)\n ;(onPreview ?? onChange)(pointFrom(e))\n }\n function onPointerMove(e: React.PointerEvent<HTMLButtonElement>) {\n if (disabled || !dragging.current) return\n ;(onPreview ?? onChange)(pointFrom(e))\n }\n function onPointerUp(e: React.PointerEvent<HTMLButtonElement>) {\n if (disabled || !dragging.current) return\n dragging.current = false\n e.currentTarget.releasePointerCapture(e.pointerId)\n onChange(pointFrom(e)) // commit on release only\n }\n\n return (\n <button\n type=\"button\"\n aria-label={translate('assets.focalPoint.setLabel')}\n disabled={disabled}\n onPointerDown={onPointerDown}\n onPointerMove={onPointerMove}\n onPointerUp={onPointerUp}\n className={cn(\n 'relative block w-full touch-none select-none overflow-hidden rounded border border-border bg-muted',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',\n disabled ? 'cursor-default opacity-60' : 'cursor-crosshair',\n )}\n >\n {/* Width-constrained, natural aspect (no object-fit), so the button wraps\n the image tightly and the bounding rect equals the image box — the\n normalized pointer coordinates are then exact, with no letterbox offset. */}\n <img\n src={record.url}\n alt={assetAlt(record, at) ?? record.filename}\n draggable={false}\n onLoad={() => setLoaded(true)}\n className={cn('block h-auto w-full select-none transition-opacity', loaded ? 'opacity-100' : 'opacity-0')}\n />\n {!loaded ? (\n <span className=\"absolute inset-0 grid aspect-video place-items-center bg-muted\">\n <Image className=\"size-6 animate-pulse text-muted-foreground\" aria-hidden />\n </span>\n ) : (\n <span\n aria-hidden\n className=\"pointer-events-none absolute size-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white shadow-[0_0_0_1.5px_rgba(0,0,0,0.55)] ring-1 ring-black/40\"\n style={{ left: `${marker.x * 100}%`, top: `${marker.y * 100}%` }}\n />\n )}\n </button>\n )\n}\n\n/** Common crop aspect ratios shown as focal-aware previews (EC-301). */\nconst FOCAL_CROP_RATIOS = [\n { label: '9:16', className: 'aspect-[9/16]' },\n { label: '3:4', className: 'aspect-[3/4]' },\n { label: '1:1', className: 'aspect-square' },\n { label: '4:3', className: 'aspect-[4/3]' },\n { label: '16:9', className: 'aspect-video' },\n] as const\n\n/**\n * A row of common-ratio crop previews (EC-301), each cropping the image with the\n * same `object-position` delivery uses — so the editor sees how the focal point\n * holds the subject across portrait/square/landscape crops. Updates live as the\n * point is dragged.\n */\nexport function FocalCropPreviews({\n record,\n point,\n}: {\n record: AssetRecord | undefined\n point: FocalPoint | undefined\n}) {\n if (!isResolvableImage(record)) return null\n const objectPosition = focalPointObjectPosition(point)\n return (\n <div className=\"flex items-end gap-2\">\n {FOCAL_CROP_RATIOS.map((ratio) => (\n <div key={ratio.label} className=\"flex min-w-0 flex-col items-center gap-1\">\n <div className={cn('h-14 overflow-hidden rounded border border-border bg-muted', ratio.className)}>\n <img\n src={record.url}\n alt=\"\"\n draggable={false}\n style={{ objectPosition }}\n className=\"h-full w-full object-cover\"\n />\n </div>\n <Text size=\"xs\" tone=\"subtle\">\n {ratio.label}\n </Text>\n </div>\n ))}\n </div>\n )\n}\n"]}
@@ -1,8 +1,9 @@
1
1
  import type { CmsDocument, CollectionDef, DocumentRef } from '@elytracms/core/cms-core';
2
- import type { AssetRecord, ReferenceTarget } from '@elytracms/core/persistence';
2
+ import type { AssetReadLocale, AssetRecord, ReferenceTarget } from '@elytracms/core/persistence';
3
3
  import type { OperationError } from '@elytracms/core/operations';
4
4
  import type { DeleteAssetResult } from '../../lib/assets/index.js';
5
5
  import type { CollectionViewSource, CollectionViewCapabilities } from '../../lib/collection-view/collection-view-source.js';
6
+ import type { AssetMetadataPatch } from './asset-metadata.js';
6
7
  /**
7
8
  * The capability profile of the assets rail (EC-319, EC-433). Hoisted to a
8
9
  * module-level constant (no prop/state dependency) so it's both the value the
@@ -32,18 +33,31 @@ export declare const ASSET_COLLECTION_CAPABILITIES: CollectionViewCapabilities;
32
33
  * record, and `alt` reads through THE reader (`assetAlt`, EC-587 — `meta.alt`, with a
33
34
  * not-yet-migrated legacy column lifted in), exactly what delivery and the image
34
35
  * field's options dialog show.
36
+ *
37
+ * EC-632: the values are the metadata's view in `at` (the studio's locale) — a
38
+ * `localized: true` field shows that locale's value, with delivery's fallback, held in
39
+ * the lens's one locale bucket (`localized[at.locale]`, the rail's locale), so the table
40
+ * reads it as that locale's value rather than as a fallback.
35
41
  */
36
- export declare function assetToDocument(collection: CollectionDef, record: AssetRecord): CmsDocument;
42
+ export declare function assetToDocument(collection: CollectionDef, record: AssetRecord, at: AssetReadLocale | null): CmsDocument;
37
43
  /**
38
- * The inverse of {@link assetToDocument}: the meta bag a save writes back — every
44
+ * The inverse of {@link assetToDocument}: the meta bag a lens document holds — every
39
45
  * value outside the system set (EC-301/EC-311), declared or not, through the ONE
40
- * value-keyed projection the users/redirects rails share (EC-577, RESIDUE-3d).
41
- * `meta` is written whole (it replaces the stored bag), so a key the lens never
42
- * declared must be in it: the schema-driven projection this replaced dropped them
43
- * on every rail save (D22), while the options dialog and asset detail kept them.
44
- * System columns are upload-managed and never written here.
46
+ * value-keyed projection the users/redirects rails share (EC-577, RESIDUE-3d). A key
47
+ * the lens never declared is in it (the schema-driven projection this replaced dropped
48
+ * them, D22). System columns are upload-managed and never written here.
49
+ */
50
+ export declare function documentToAssetMeta(doc: CmsDocument): Record<string, unknown>;
51
+ /**
52
+ * The rail save's metadata write (EC-634 × EC-632): only the keys whose value differs
53
+ * between the document as the editor OPENED it (`base`, forwarded by the edit shell —
54
+ * EC-616) and the one it saves, each written where its field lives — a `localized: true`
55
+ * field into `at`'s bucket, any other into the shared bag (`setAssetMetaField`, the
56
+ * editors' one rule) — as the per-key, per-locale patch against the record as stored. A
57
+ * key this editor did not change is never written. Without a base every key of the saved
58
+ * bag is set and none is removed.
45
59
  */
46
- export declare function documentToAssetMetaPatch(doc: CmsDocument): Record<string, unknown>;
60
+ export declare function assetRailMetaPatch(collection: CollectionDef, record: AssetRecord, at: AssetReadLocale | null, doc: CmsDocument, base?: CmsDocument): Pick<AssetMetadataPatch, 'metaPatch' | 'localizedMetaPatch'>;
47
61
  /** Assets' `remove()` returns an `outcome`-discriminated {@link DeleteAssetResult}
48
62
  * (not the `{ ok }` shape users/redirects use, EC-327). `'error'` surfaces as-is;
49
63
  * `'blocked'` (the unconfirmed usage gate fired — nothing was removed) surfaces
@@ -1,17 +1,23 @@
1
1
  import { useCallback, useMemo } from 'react';
2
- import { createCollectionRegistry, validateDocument } from '@elytracms/core/cms-core';
3
- import { assetAlt } from '@elytracms/core/persistence';
2
+ import { createCollectionRegistry, semanticValueEqual, validateDocument } from '@elytracms/core/cms-core';
3
+ import { assetAlt, assetMetaView } from '@elytracms/core/persistence';
4
4
  import { studioPath } from '@elytracms/studio/router';
5
5
  import { useAssets } from './assets-store.js';
6
- import { SYSTEM_ASSET_FIELDS, pruneMeta } from './asset-metadata.js';
6
+ import { SYSTEM_ASSET_FIELDS, assetMetaDraft, assetMetaDraftPatch, pruneMeta, setAssetMetaField } from './asset-metadata.js';
7
+ import { useAssetReadLocale } from './asset-locale.js';
7
8
  import { metaBagProjection, normalizeOkResult, useRailViewSource } from '../rail-view-source.js';
8
9
  import { parseLocaleConfig } from '@elytracms/core/cms-core';
9
- /** The assets rail is not localized in v1 (EC-301: asset metadata is non-localized). */
10
+ /**
11
+ * The assets rail has no locale switcher of its own (capability `locales: false`): it
12
+ * reads in ONE locale — the studio's (EC-632), a localized field's value in that locale —
13
+ * so its lens is a one-locale document in that locale. Before a project's locales are
14
+ * installed, the engine's neutral `en` (shared metadata only: nothing is localized then).
15
+ */
10
16
  const ASSETS_LOCALE = 'en';
11
- const ASSETS_LOCALE_CONFIG = parseLocaleConfig({
12
- default: ASSETS_LOCALE,
13
- locales: [ASSETS_LOCALE],
14
- });
17
+ function railLocaleConfig(at) {
18
+ const locale = at?.locale ?? ASSETS_LOCALE;
19
+ return parseLocaleConfig({ default: locale, locales: [locale] });
20
+ }
15
21
  /**
16
22
  * The capability profile of the assets rail (EC-319, EC-433). Hoisted to a
17
23
  * module-level constant (no prop/state dependency) so it's both the value the
@@ -77,12 +83,17 @@ function systemFieldValue(record, name) {
77
83
  * record, and `alt` reads through THE reader (`assetAlt`, EC-587 — `meta.alt`, with a
78
84
  * not-yet-migrated legacy column lifted in), exactly what delivery and the image
79
85
  * field's options dialog show.
86
+ *
87
+ * EC-632: the values are the metadata's view in `at` (the studio's locale) — a
88
+ * `localized: true` field shows that locale's value, with delivery's fallback, held in
89
+ * the lens's one locale bucket (`localized[at.locale]`, the rail's locale), so the table
90
+ * reads it as that locale's value rather than as a fallback.
80
91
  */
81
- export function assetToDocument(collection, record) {
82
- const values = { ...(record.meta ?? {}) };
92
+ export function assetToDocument(collection, record, at) {
93
+ const values = assetMetaView(record, at);
83
94
  for (const field of collection.fields) {
84
95
  if (field.name === 'alt') {
85
- const alt = assetAlt(record);
96
+ const alt = assetAlt(record, at);
86
97
  if (alt !== null)
87
98
  values.alt = alt;
88
99
  else
@@ -92,19 +103,52 @@ export function assetToDocument(collection, record) {
92
103
  values[field.name] = systemFieldValue(record, field.name);
93
104
  }
94
105
  }
95
- return { collection: collection.id, id: record.id, values };
106
+ if (at === null)
107
+ return { collection: collection.id, id: record.id, values };
108
+ const bucket = {};
109
+ for (const field of collection.fields) {
110
+ if (field.localized !== true || !Object.prototype.hasOwnProperty.call(values, field.name))
111
+ continue;
112
+ bucket[field.name] = values[field.name];
113
+ delete values[field.name];
114
+ }
115
+ return Object.keys(bucket).length > 0
116
+ ? { collection: collection.id, id: record.id, values, localized: { [at.locale]: bucket } }
117
+ : { collection: collection.id, id: record.id, values };
96
118
  }
97
119
  /**
98
- * The inverse of {@link assetToDocument}: the meta bag a save writes back — every
120
+ * The inverse of {@link assetToDocument}: the meta bag a lens document holds — every
99
121
  * value outside the system set (EC-301/EC-311), declared or not, through the ONE
100
- * value-keyed projection the users/redirects rails share (EC-577, RESIDUE-3d).
101
- * `meta` is written whole (it replaces the stored bag), so a key the lens never
102
- * declared must be in it: the schema-driven projection this replaced dropped them
103
- * on every rail save (D22), while the options dialog and asset detail kept them.
104
- * System columns are upload-managed and never written here.
122
+ * value-keyed projection the users/redirects rails share (EC-577, RESIDUE-3d). A key
123
+ * the lens never declared is in it (the schema-driven projection this replaced dropped
124
+ * them, D22). System columns are upload-managed and never written here.
105
125
  */
106
- export function documentToAssetMetaPatch(doc) {
107
- return pruneMeta(metaBagProjection(doc.values ?? {}, SYSTEM_ASSET_FIELDS));
126
+ export function documentToAssetMeta(doc) {
127
+ // EC-632: the lens's one locale bucket (a localized field's value) reads beside `values`.
128
+ const localized = Object.values(doc.localized ?? {}).reduce((all, bag) => ({ ...all, ...bag }), {});
129
+ return pruneMeta(metaBagProjection({ ...(doc.values ?? {}), ...localized }, SYSTEM_ASSET_FIELDS));
130
+ }
131
+ /**
132
+ * The rail save's metadata write (EC-634 × EC-632): only the keys whose value differs
133
+ * between the document as the editor OPENED it (`base`, forwarded by the edit shell —
134
+ * EC-616) and the one it saves, each written where its field lives — a `localized: true`
135
+ * field into `at`'s bucket, any other into the shared bag (`setAssetMetaField`, the
136
+ * editors' one rule) — as the per-key, per-locale patch against the record as stored. A
137
+ * key this editor did not change is never written. Without a base every key of the saved
138
+ * bag is set and none is removed.
139
+ */
140
+ export function assetRailMetaPatch(collection, record, at, doc, base) {
141
+ const before = base ? documentToAssetMeta(base) : {};
142
+ const after = documentToAssetMeta(doc);
143
+ const opened = assetMetaDraft(record);
144
+ let edited = opened;
145
+ for (const name of new Set([...Object.keys(before), ...Object.keys(after)])) {
146
+ if (semanticValueEqual(before[name], after[name]))
147
+ continue;
148
+ const field = collection.fields.find((f) => f.name === name) ?? { name };
149
+ edited = setAssetMetaField(edited, field, at, after[name]);
150
+ }
151
+ return assetMetaDraftPatch(opened, edited);
108
152
  }
109
153
  /** `create` when `capabilities.create === false` — never reached from the UI
110
154
  * (list/detail never call it); an explicit throw rather than a silent no-op. */
@@ -159,17 +203,25 @@ export function assetReferenceTarget(ref) {
159
203
  export function useAssetsCollectionViewSource(projectId, schema) {
160
204
  const store = useAssets();
161
205
  const registry = useMemo(() => createCollectionRegistry([schema]), [schema]);
162
- const documents = useCallback(() => store.assets.map((record) => assetToDocument(schema, record)), [store.assets, schema]);
206
+ // EC-632: the rail reads (and a save writes) a localized field in the studio's locale.
207
+ const at = useAssetReadLocale();
208
+ const documents = useCallback(() => store.assets.map((record) => assetToDocument(schema, record, at)), [store.assets, schema, at]);
163
209
  const get = useCallback((ref) => {
164
210
  const record = store.assets.find((a) => a.id === ref.id);
165
- return record ? assetToDocument(schema, record) : undefined;
166
- }, [store.assets, schema]);
211
+ return record ? assetToDocument(schema, record, at) : undefined;
212
+ }, [store.assets, schema, at]);
167
213
  const titleOf = useCallback((ref) => {
168
214
  const record = store.assets.find((a) => a.id === ref.id);
169
215
  return record?.title ?? record?.filename ?? ref.id;
170
216
  }, [store.assets]);
171
- const validate = useCallback((doc) => validateDocument(registry, doc, { localeConfig: ASSETS_LOCALE_CONFIG }), [registry]);
172
- const save = useCallback((doc) => store.updateMetadata(doc.id, { meta: documentToAssetMetaPatch(doc) }), [store]);
217
+ const localeConfig = useMemo(() => railLocaleConfig(at), [at]);
218
+ const validate = useCallback((doc) => validateDocument(registry, doc, { localeConfig }), [registry, localeConfig]);
219
+ const save = useCallback(async (doc, base) => {
220
+ const record = store.assets.find((a) => a.id === doc.id);
221
+ if (!record)
222
+ return { ok: false, message: `No asset “${doc.id}” is loaded.` };
223
+ return store.updateMetadata(doc.id, assetRailMetaPatch(schema, record, at, doc, base));
224
+ }, [store, schema, at]);
173
225
  // The unconfirmed delete (the store's usage gate still applies — a referenced
174
226
  // asset comes back `blocked`, surfaced via `lastError`). The UI's delete paths
175
227
  // never land here: row/bulk deletes confirm through the reference dialog and
@@ -184,7 +236,7 @@ export function useAssetsCollectionViewSource(projectId, schema) {
184
236
  projectId,
185
237
  schema,
186
238
  capabilities: ASSET_COLLECTION_CAPABILITIES,
187
- localeConfig: ASSETS_LOCALE_CONFIG,
239
+ localeConfig,
188
240
  documents,
189
241
  get,
190
242
  titleOf,