@elytracms/studio 0.0.38 → 0.0.39

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 (179) hide show
  1. package/convex/cms.ts +8 -2
  2. package/dist/components/app-views/default-modules.d.ts +60 -5
  3. package/dist/components/app-views/default-modules.js +53 -15
  4. package/dist/components/app-views/default-modules.js.map +1 -1
  5. package/dist/components/app-views/module.d.ts +2 -0
  6. package/dist/components/app-views/module.js +8 -0
  7. package/dist/components/app-views/module.js.map +1 -1
  8. package/dist/components/assets/asset-detail.d.ts +24 -0
  9. package/dist/components/assets/asset-detail.js +163 -0
  10. package/dist/components/assets/asset-detail.js.map +1 -0
  11. package/dist/components/assets/asset-grid.d.ts +28 -0
  12. package/dist/components/assets/asset-grid.js +68 -0
  13. package/dist/components/assets/asset-grid.js.map +1 -0
  14. package/dist/components/assets/asset-metadata.d.ts +19 -0
  15. package/dist/components/assets/asset-metadata.js +44 -0
  16. package/dist/components/assets/asset-metadata.js.map +1 -1
  17. package/dist/components/assets/assets-collection-view-source.d.ts +54 -0
  18. package/dist/components/assets/assets-collection-view-source.js +186 -0
  19. package/dist/components/assets/assets-collection-view-source.js.map +1 -0
  20. package/dist/components/assets/assets-module.d.ts +18 -0
  21. package/dist/components/assets/assets-module.js +94 -0
  22. package/dist/components/assets/assets-module.js.map +1 -0
  23. package/dist/components/assets/assets-store.js +2 -7
  24. package/dist/components/assets/assets-store.js.map +1 -1
  25. package/dist/components/assets/assets-view-preference.d.ts +15 -0
  26. package/dist/components/assets/assets-view-preference.js +22 -0
  27. package/dist/components/assets/assets-view-preference.js.map +1 -0
  28. package/dist/components/builder/builder-store.d.ts +12 -0
  29. package/dist/components/builder/builder-store.js +18 -16
  30. package/dist/components/builder/builder-store.js.map +1 -1
  31. package/dist/components/builder/clipboard-actions.js +5 -14
  32. package/dist/components/builder/clipboard-actions.js.map +1 -1
  33. package/dist/components/builder/composition-field-editor-lazy.d.ts +19 -0
  34. package/dist/components/builder/composition-field-editor-lazy.js +29 -0
  35. package/dist/components/builder/composition-field-editor-lazy.js.map +1 -0
  36. package/dist/components/builder/composition-field-editor.js +1 -4
  37. package/dist/components/builder/composition-field-editor.js.map +1 -1
  38. package/dist/components/builder/insert-context.d.ts +8 -0
  39. package/dist/components/builder/insert-context.js +1 -3
  40. package/dist/components/builder/insert-context.js.map +1 -1
  41. package/dist/components/builder/page-tree.js +1 -3
  42. package/dist/components/builder/page-tree.js.map +1 -1
  43. package/dist/components/builder/property-inspector.js +7 -134
  44. package/dist/components/builder/property-inspector.js.map +1 -1
  45. package/dist/components/cms/cms-store.d.ts +15 -4
  46. package/dist/components/cms/cms-store.js +42 -24
  47. package/dist/components/cms/cms-store.js.map +1 -1
  48. package/dist/components/cms/collection-table.d.ts +8 -1
  49. package/dist/components/cms/collection-table.js +62 -10
  50. package/dist/components/cms/collection-table.js.map +1 -1
  51. package/dist/components/cms/column-width.d.ts +25 -0
  52. package/dist/components/cms/column-width.js +47 -0
  53. package/dist/components/cms/column-width.js.map +1 -0
  54. package/dist/components/cms/detail-editor.js +101 -68
  55. package/dist/components/cms/detail-editor.js.map +1 -1
  56. package/dist/components/cms/discard-draft.d.ts +32 -0
  57. package/dist/components/cms/discard-draft.js +13 -0
  58. package/dist/components/cms/discard-draft.js.map +1 -0
  59. package/dist/components/cms/document-staging.d.ts +7 -0
  60. package/dist/components/cms/document-staging.js +44 -0
  61. package/dist/components/cms/document-staging.js.map +1 -0
  62. package/dist/components/cms/field-connection.d.ts +22 -0
  63. package/dist/components/cms/field-connection.js +37 -0
  64. package/dist/components/cms/field-connection.js.map +1 -0
  65. package/dist/components/cms/field-control.js +158 -34
  66. package/dist/components/cms/field-control.js.map +1 -1
  67. package/dist/components/cms/prop-fields.d.ts +43 -0
  68. package/dist/components/cms/prop-fields.js +130 -0
  69. package/dist/components/cms/prop-fields.js.map +1 -0
  70. package/dist/components/cms/rich-text/component-embed.d.ts +17 -3
  71. package/dist/components/cms/rich-text/component-embed.js +29 -7
  72. package/dist/components/cms/rich-text/component-embed.js.map +1 -1
  73. package/dist/components/cms/rich-text/embed-props.d.ts +49 -0
  74. package/dist/components/cms/rich-text/embed-props.js +106 -0
  75. package/dist/components/cms/rich-text/embed-props.js.map +1 -0
  76. package/dist/components/cms/rich-text/index.d.ts +6 -1
  77. package/dist/components/cms/rich-text/index.js +6 -1
  78. package/dist/components/cms/rich-text/index.js.map +1 -1
  79. package/dist/components/cms/rich-text/lazy.d.ts +19 -0
  80. package/dist/components/cms/rich-text/lazy.js +26 -0
  81. package/dist/components/cms/rich-text/lazy.js.map +1 -0
  82. package/dist/components/cms/row-actions.d.ts +28 -0
  83. package/dist/components/cms/row-actions.js +20 -0
  84. package/dist/components/cms/row-actions.js.map +1 -0
  85. package/dist/components/cms/use-document-versions.d.ts +8 -1
  86. package/dist/components/cms/use-document-versions.js +31 -4
  87. package/dist/components/cms/use-document-versions.js.map +1 -1
  88. package/dist/components/cms/version-compare-lazy.d.ts +1 -0
  89. package/dist/components/cms/version-compare-lazy.js +23 -0
  90. package/dist/components/cms/version-compare-lazy.js.map +1 -0
  91. package/dist/components/cms/version-compare-text.d.ts +46 -0
  92. package/dist/components/cms/version-compare-text.js +78 -0
  93. package/dist/components/cms/version-compare-text.js.map +1 -1
  94. package/dist/components/cms/version-compare.d.ts +9 -5
  95. package/dist/components/cms/version-compare.js +184 -59
  96. package/dist/components/cms/version-compare.js.map +1 -1
  97. package/dist/components/cms/versions-pane.js +10 -4
  98. package/dist/components/cms/versions-pane.js.map +1 -1
  99. package/dist/components/components/component-detail.d.ts +5 -3
  100. package/dist/components/components/component-detail.js +41 -8
  101. package/dist/components/components/component-detail.js.map +1 -1
  102. package/dist/components/components/component-preview.d.ts +61 -0
  103. package/dist/components/components/component-preview.js +103 -0
  104. package/dist/components/components/component-preview.js.map +1 -0
  105. package/dist/components/components/components-index.d.ts +5 -3
  106. package/dist/components/components/components-index.js +19 -24
  107. package/dist/components/components/components-index.js.map +1 -1
  108. package/dist/components/components/registry-browser.d.ts +7 -0
  109. package/dist/components/components/registry-browser.js +20 -36
  110. package/dist/components/components/registry-browser.js.map +1 -1
  111. package/dist/components/components/taxonomy.d.ts +51 -0
  112. package/dist/components/components/taxonomy.js +73 -0
  113. package/dist/components/components/taxonomy.js.map +1 -0
  114. package/dist/components/components/transfer-dialog.d.ts +20 -0
  115. package/dist/components/components/transfer-dialog.js +64 -0
  116. package/dist/components/components/transfer-dialog.js.map +1 -0
  117. package/dist/components/components/transfer.d.ts +43 -0
  118. package/dist/components/components/transfer.js +31 -0
  119. package/dist/components/components/transfer.js.map +1 -0
  120. package/dist/components/project-list.js +3 -4
  121. package/dist/components/project-list.js.map +1 -1
  122. package/dist/components/project-switcher.js +3 -2
  123. package/dist/components/project-switcher.js.map +1 -1
  124. package/dist/components/rail-store-status.d.ts +21 -0
  125. package/dist/components/rail-store-status.js +21 -0
  126. package/dist/components/rail-store-status.js.map +1 -0
  127. package/dist/components/redirects/redirects-module.js +4 -8
  128. package/dist/components/redirects/redirects-module.js.map +1 -1
  129. package/dist/components/redirects/redirects-store.js +2 -7
  130. package/dist/components/redirects/redirects-store.js.map +1 -1
  131. package/dist/components/sign-in-needed-badge.d.ts +6 -0
  132. package/dist/components/sign-in-needed-badge.js +13 -0
  133. package/dist/components/sign-in-needed-badge.js.map +1 -0
  134. package/dist/components/studio-project-screen.js +1 -4
  135. package/dist/components/studio-project-screen.js.map +1 -1
  136. package/dist/components/studio-shell.js +1 -4
  137. package/dist/components/studio-shell.js.map +1 -1
  138. package/dist/components/users/users-module.js +5 -7
  139. package/dist/components/users/users-module.js.map +1 -1
  140. package/dist/components/users/users-store.js +2 -7
  141. package/dist/components/users/users-store.js.map +1 -1
  142. package/dist/lib/auth/index.js +1 -1
  143. package/dist/lib/auth/index.js.map +1 -1
  144. package/dist/lib/clipboard/paste-target.js +1 -3
  145. package/dist/lib/clipboard/paste-target.js.map +1 -1
  146. package/dist/lib/cms-views/view-model.d.ts +0 -2
  147. package/dist/lib/cms-views/view-model.js +0 -7
  148. package/dist/lib/cms-views/view-model.js.map +1 -1
  149. package/dist/lib/diff-view-preference.d.ts +7 -0
  150. package/dist/lib/diff-view-preference.js +21 -0
  151. package/dist/lib/diff-view-preference.js.map +1 -0
  152. package/dist/lib/i18n/messages.en.js +79 -30
  153. package/dist/lib/i18n/messages.en.js.map +1 -1
  154. package/dist/lib/local-preference.d.ts +11 -0
  155. package/dist/lib/local-preference.js +26 -0
  156. package/dist/lib/local-preference.js.map +1 -0
  157. package/dist/lib/locale/fallback.d.ts +45 -0
  158. package/dist/lib/locale/fallback.js +85 -0
  159. package/dist/lib/locale/fallback.js.map +1 -1
  160. package/dist/lib/persona/labels.d.ts +6 -0
  161. package/dist/lib/persona/labels.js +8 -0
  162. package/dist/lib/persona/labels.js.map +1 -1
  163. package/dist/lib/query/index.d.ts +1 -1
  164. package/dist/lib/query/index.js +1 -1
  165. package/dist/lib/query/index.js.map +1 -1
  166. package/dist/lib/query/operations-query.d.ts +14 -2
  167. package/dist/lib/query/operations-query.js +11 -6
  168. package/dist/lib/query/operations-query.js.map +1 -1
  169. package/dist/lib/rail.d.ts +0 -6
  170. package/dist/lib/rail.js +6 -25
  171. package/dist/lib/rail.js.map +1 -1
  172. package/dist/lib/theme.d.ts +0 -6
  173. package/dist/lib/theme.js +10 -13
  174. package/dist/lib/theme.js.map +1 -1
  175. package/dist/router/match.d.ts +1 -0
  176. package/dist/router/match.js +4 -0
  177. package/dist/router/match.js.map +1 -1
  178. package/dist/ui/button.d.ts +1 -1
  179. package/package.json +22 -25
@@ -0,0 +1 @@
1
+ {"version":3,"file":"discard-draft.js","sourceRoot":"","sources":["../../../src/components/cms/discard-draft.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAA;AAuBhE,MAAM,UAAU,kBAAkB,CAAC,KAWlC;IACC,IAAI,CAAC,KAAK,CAAC,UAAU,IAAI,CAAC,KAAK,CAAC,SAAS;QAAE,OAAO,QAAQ,CAAA;IAC1D,IAAI,KAAK,CAAC,YAAY;QAAE,OAAO,gBAAgB,CAAA;IAC/C,IAAI,KAAK,CAAC,iBAAiB,KAAK,SAAS;QAAE,OAAO,SAAS,CAAA;IAC3D,OAAO,sBAAsB,CAAC,KAAK,CAAC,UAAU,EAAE,KAAK,CAAC,iBAAiB,EAAE,KAAK,CAAC,KAAK,CAAC;QACnF,CAAC,CAAC,YAAY;QACd,CAAC,CAAC,OAAO,CAAA;AACb,CAAC","sourcesContent":["import { documentsSemanticEqual } from '@elytracms/core/content'\nimport type { CmsDocument, CollectionDef } from '@elytracms/core/cms-core'\n\n/**\n * EC-316 — the \"Discard changes\" header action's enablement, as one pure\n * decision. Discarding reverts the draft to exactly the published content (via\n * the EC-298 restore machinery), so it is offered iff there is a published\n * version to revert TO and the draft actually differs from it:\n *\n * - `hidden` — the rail can't discard at all (no publish/versions\n * capability) or the document isn't published: there is no\n * published content to revert to, so no menu item.\n * - `view-read-only`— viewing the Published snapshot (EC-226): the draft isn't\n * the active view, so the item is disabled with a hint.\n * - `loading` — the version history hasn't resolved yet; disabled.\n * - `up-to-date` — the draft SEMANTICALLY equals the published snapshot\n * (`documentsSemanticEqual`, the SAME comparator the\n * Versions pane's publish gate uses, EC-298 — so the two\n * surfaces never contradict); nothing to discard, disabled.\n * - `ready` — a published version exists and the draft differs: enabled.\n */\nexport type DiscardDraftStatus = 'hidden' | 'view-read-only' | 'loading' | 'up-to-date' | 'ready'\n\nexport function discardDraftStatus(input: {\n /** The rail can publish AND has version history (both required to discard). */\n canDiscard: boolean\n /** The document IS published (version pointer set). */\n published: boolean\n /** The editor is viewing the read-only Published snapshot (EC-226). */\n viewReadOnly: boolean\n /** The pinned published version's snapshot; `undefined` while loading / none pinned. */\n publishedSnapshot: CmsDocument | undefined\n collection: CollectionDef\n draft: CmsDocument\n}): DiscardDraftStatus {\n if (!input.canDiscard || !input.published) return 'hidden'\n if (input.viewReadOnly) return 'view-read-only'\n if (input.publishedSnapshot === undefined) return 'loading'\n return documentsSemanticEqual(input.collection, input.publishedSnapshot, input.draft)\n ? 'up-to-date'\n : 'ready'\n}\n"]}
@@ -0,0 +1,7 @@
1
+ import type { CmsDocument } from '@elytracms/core/cms-core';
2
+ /** Stage a document for the next `CmsProvider` mount of `projectId` (playground only). */
3
+ export declare function stageDocument(projectId: string, doc: CmsDocument): void;
4
+ /** The documents staged for `projectId` (a copy; non-destructive). */
5
+ export declare function stagedDocuments(projectId: string | undefined): CmsDocument[];
6
+ /** Test seam: drop all staged documents. */
7
+ export declare function clearStagedDocuments(): void;
@@ -0,0 +1,44 @@
1
+ import { isPlaygroundMode } from '@elytracms/studio/lib/persistence';
2
+ /**
3
+ * EC-320 — a tiny cross-mount handoff for a freshly created document.
4
+ *
5
+ * The "Use in document…" transfer creates a draft in the Components view, then
6
+ * navigates to that document's CMS editor — a FRESH `CmsProvider` mount. In
7
+ * **Convex** mode the new provider re-hydrates from persistence (the transfer's
8
+ * `saveDocument` already upserted it), so nothing more is needed. In the
9
+ * **playground**, though, `CmsSessionGate` renders synchronously with `null`
10
+ * (fixtures) and never reads back the seeded adapter, so a doc created in one
11
+ * mount is invisible to the next.
12
+ *
13
+ * This module bridges exactly that gap: the transfer STAGES the built document,
14
+ * and the next `CmsProvider` for the same project merges any staged docs into its
15
+ * initial set (staged wins by id). It is deliberately **playground-only** — under
16
+ * Convex, hydration is the source of truth and staging would risk shadowing a
17
+ * newer persisted revision. Reads are non-destructive (StrictMode-safe: a double
18
+ * mount sees the same doc both times); the buffer is session-scoped dev state.
19
+ */
20
+ const staged = new Map();
21
+ function docKey(doc) {
22
+ return `${doc.collection}/${doc.id}`;
23
+ }
24
+ /** Stage a document for the next `CmsProvider` mount of `projectId` (playground only). */
25
+ export function stageDocument(projectId, doc) {
26
+ if (!isPlaygroundMode())
27
+ return;
28
+ const list = staged.get(projectId) ?? [];
29
+ // Replace any prior staging of the same doc key so a re-transfer wins.
30
+ const next = list.filter((d) => docKey(d) !== docKey(doc));
31
+ next.push(doc);
32
+ staged.set(projectId, next);
33
+ }
34
+ /** The documents staged for `projectId` (a copy; non-destructive). */
35
+ export function stagedDocuments(projectId) {
36
+ if (projectId === undefined)
37
+ return [];
38
+ return (staged.get(projectId) ?? []).map((d) => structuredClone(d));
39
+ }
40
+ /** Test seam: drop all staged documents. */
41
+ export function clearStagedDocuments() {
42
+ staged.clear();
43
+ }
44
+ //# sourceMappingURL=document-staging.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"document-staging.js","sourceRoot":"","sources":["../../../src/components/cms/document-staging.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,gBAAgB,EAAE,MAAM,mCAAmC,CAAA;AAEpE;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,GAAG,IAAI,GAAG,EAAyB,CAAA;AAE/C,SAAS,MAAM,CAAC,GAAgB;IAC9B,OAAO,GAAG,GAAG,CAAC,UAAU,IAAI,GAAG,CAAC,EAAE,EAAE,CAAA;AACtC,CAAC;AAED,0FAA0F;AAC1F,MAAM,UAAU,aAAa,CAAC,SAAiB,EAAE,GAAgB;IAC/D,IAAI,CAAC,gBAAgB,EAAE;QAAE,OAAM;IAC/B,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAA;IACxC,uEAAuE;IACvE,MAAM,IAAI,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,MAAM,CAAC,CAAC,CAAC,KAAK,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;IAC1D,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAA;IACd,MAAM,CAAC,GAAG,CAAC,SAAS,EAAE,IAAI,CAAC,CAAA;AAC7B,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,eAAe,CAAC,SAA6B;IAC3D,IAAI,SAAS,KAAK,SAAS;QAAE,OAAO,EAAE,CAAA;IACtC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,CAAA;AACrE,CAAC;AAED,4CAA4C;AAC5C,MAAM,UAAU,oBAAoB;IAClC,MAAM,CAAC,KAAK,EAAE,CAAA;AAChB,CAAC","sourcesContent":["import type { CmsDocument } from '@elytracms/core/cms-core'\nimport { isPlaygroundMode } from '@elytracms/studio/lib/persistence'\n\n/**\n * EC-320 — a tiny cross-mount handoff for a freshly created document.\n *\n * The \"Use in document…\" transfer creates a draft in the Components view, then\n * navigates to that document's CMS editor — a FRESH `CmsProvider` mount. In\n * **Convex** mode the new provider re-hydrates from persistence (the transfer's\n * `saveDocument` already upserted it), so nothing more is needed. In the\n * **playground**, though, `CmsSessionGate` renders synchronously with `null`\n * (fixtures) and never reads back the seeded adapter, so a doc created in one\n * mount is invisible to the next.\n *\n * This module bridges exactly that gap: the transfer STAGES the built document,\n * and the next `CmsProvider` for the same project merges any staged docs into its\n * initial set (staged wins by id). It is deliberately **playground-only** — under\n * Convex, hydration is the source of truth and staging would risk shadowing a\n * newer persisted revision. Reads are non-destructive (StrictMode-safe: a double\n * mount sees the same doc both times); the buffer is session-scoped dev state.\n */\n\nconst staged = new Map<string, CmsDocument[]>()\n\nfunction docKey(doc: CmsDocument): string {\n return `${doc.collection}/${doc.id}`\n}\n\n/** Stage a document for the next `CmsProvider` mount of `projectId` (playground only). */\nexport function stageDocument(projectId: string, doc: CmsDocument): void {\n if (!isPlaygroundMode()) return\n const list = staged.get(projectId) ?? []\n // Replace any prior staging of the same doc key so a re-transfer wins.\n const next = list.filter((d) => docKey(d) !== docKey(doc))\n next.push(doc)\n staged.set(projectId, next)\n}\n\n/** The documents staged for `projectId` (a copy; non-destructive). */\nexport function stagedDocuments(projectId: string | undefined): CmsDocument[] {\n if (projectId === undefined) return []\n return (staged.get(projectId) ?? []).map((d) => structuredClone(d))\n}\n\n/** Test seam: drop all staged documents. */\nexport function clearStagedDocuments(): void {\n staged.clear()\n}\n"]}
@@ -0,0 +1,22 @@
1
+ import type { Locale } from '@elytracms/core/cms-core';
2
+ import type { FieldConnection } from '@elytracms/studio/lib/locale';
3
+ /**
4
+ * EC-318 — the per-field localization CONNECTION affordance, replacing EC-176's
5
+ * passive "showing EN" text. On a localized field in a non-base locale it shows,
6
+ * quietly, whether the field FOLLOWS the base locale (connected) or OWNS a
7
+ * translation (diverged), with the matching action:
8
+ * • connected → a "Follows EN" chip + "Fill from EN" (seed a translatable copy);
9
+ * • diverged → a "Translated" chip + "Reset to EN" (confirmed — discards it).
10
+ * Typing directly into a connected field also diverges it (the caller's write path).
11
+ *
12
+ * Connection state is DERIVED (a per-locale key present ⇒ diverged), never stored.
13
+ * The caller renders this ONLY in an editable, non-base-locale view — so it is
14
+ * absent on the base locale, on non-localized fields, and in read-only/Published.
15
+ */
16
+ export declare function FieldConnectionControl({ connection, fieldName, locale, onFillFromBase, onResetToBase, }: {
17
+ connection: FieldConnection;
18
+ fieldName: string;
19
+ locale: Locale;
20
+ onFillFromBase: () => void;
21
+ onResetToBase: () => void;
22
+ }): import("react").JSX.Element;
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Languages, Link2, RotateCcw } from 'lucide-react';
3
+ import { useState } from 'react';
4
+ import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Row, } from '@elytracms/studio/ui';
5
+ import { translate } from '@elytracms/studio/lib/i18n';
6
+ import { localeChipLabel } from '@elytracms/studio/lib/locale';
7
+ /**
8
+ * EC-318 — the per-field localization CONNECTION affordance, replacing EC-176's
9
+ * passive "showing EN" text. On a localized field in a non-base locale it shows,
10
+ * quietly, whether the field FOLLOWS the base locale (connected) or OWNS a
11
+ * translation (diverged), with the matching action:
12
+ * • connected → a "Follows EN" chip + "Fill from EN" (seed a translatable copy);
13
+ * • diverged → a "Translated" chip + "Reset to EN" (confirmed — discards it).
14
+ * Typing directly into a connected field also diverges it (the caller's write path).
15
+ *
16
+ * Connection state is DERIVED (a per-locale key present ⇒ diverged), never stored.
17
+ * The caller renders this ONLY in an editable, non-base-locale view — so it is
18
+ * absent on the base locale, on non-localized fields, and in read-only/Published.
19
+ */
20
+ export function FieldConnectionControl({ connection, fieldName, locale, onFillFromBase, onResetToBase, }) {
21
+ const [confirmOpen, setConfirmOpen] = useState(false);
22
+ const base = localeChipLabel(connection.baseLocale);
23
+ const active = localeChipLabel(locale);
24
+ if (connection.connected) {
25
+ return (_jsxs(Row, { gap: "1", children: [_jsxs(Badge, { variant: "outline", "data-testid": `field-connected-${fieldName}`, title: translate('cms.editor.localeFollowsHint', { base }), className: "border-dashed px-1.5 py-0 text-[0.65rem] font-normal", children: [_jsx(Link2, { className: "size-3", "aria-hidden": true }), translate('cms.editor.localeFollowsBase', { base })] }), connection.canFillFromBase ? (_jsx(ConnectionAction, { testId: `field-fill-from-base-${fieldName}`, onClick: onFillFromBase, children: translate('cms.editor.localeFillFromBase', { base }) })) : null] }));
26
+ }
27
+ return (_jsxs(Row, { gap: "1", children: [_jsxs(Badge, { variant: "outline", "data-testid": `field-diverged-${fieldName}`, title: translate('cms.editor.localeTranslatedHint', { base, locale: active }), className: "px-1.5 py-0 text-[0.65rem] font-normal", children: [_jsx(Languages, { className: "size-3", "aria-hidden": true }), translate('cms.editor.localeTranslated')] }), _jsx(ConnectionAction, { testId: `field-reset-to-base-${fieldName}`, onClick: () => setConfirmOpen(true), children: translate('cms.editor.localeResetToBase', { base }) }), _jsx(Dialog, { open: confirmOpen, onOpenChange: (open) => { if (!open)
28
+ setConfirmOpen(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.localeResetTitle', { base }) }), _jsx(DialogDescription, { children: translate('cms.editor.localeResetDescription', { base, locale: active }) })] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setConfirmOpen(false), children: translate('common.cancel') }), _jsxs(Button, { variant: "destructive", size: "sm", "data-testid": `field-reset-to-base-confirm-${fieldName}`, onClick: () => {
29
+ setConfirmOpen(false);
30
+ onResetToBase();
31
+ }, children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.localeResetConfirm', { base })] })] })] }) })] }));
32
+ }
33
+ /** A quiet inline text action for the connection affordance (fill / reset). */
34
+ function ConnectionAction({ testId, onClick, children, }) {
35
+ return (_jsx("button", { type: "button", "data-testid": testId, onClick: onClick, className: "rounded text-[0.7rem] font-medium text-primary underline-offset-2 hover:underline", children: children }));
36
+ }
37
+ //# sourceMappingURL=field-connection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"field-connection.js","sourceRoot":"","sources":["../../../src/components/cms/field-connection.tsx"],"names":[],"mappings":"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","sourcesContent":["import { Languages, Link2, RotateCcw } from 'lucide-react'\nimport { useState } from 'react'\nimport type { ReactNode } from 'react'\nimport type { Locale } from '@elytracms/core/cms-core'\nimport {\n Badge,\n Button,\n Dialog,\n DialogContent,\n DialogDescription,\n DialogFooter,\n DialogHeader,\n DialogTitle,\n Row,\n} from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\nimport { localeChipLabel } from '@elytracms/studio/lib/locale'\nimport type { FieldConnection } from '@elytracms/studio/lib/locale'\n\n/**\n * EC-318 — the per-field localization CONNECTION affordance, replacing EC-176's\n * passive \"showing EN\" text. On a localized field in a non-base locale it shows,\n * quietly, whether the field FOLLOWS the base locale (connected) or OWNS a\n * translation (diverged), with the matching action:\n * • connected → a \"Follows EN\" chip + \"Fill from EN\" (seed a translatable copy);\n * • diverged → a \"Translated\" chip + \"Reset to EN\" (confirmed — discards it).\n * Typing directly into a connected field also diverges it (the caller's write path).\n *\n * Connection state is DERIVED (a per-locale key present ⇒ diverged), never stored.\n * The caller renders this ONLY in an editable, non-base-locale view — so it is\n * absent on the base locale, on non-localized fields, and in read-only/Published.\n */\nexport function FieldConnectionControl({\n connection,\n fieldName,\n locale,\n onFillFromBase,\n onResetToBase,\n}: {\n connection: FieldConnection\n fieldName: string\n locale: Locale\n onFillFromBase: () => void\n onResetToBase: () => void\n}) {\n const [confirmOpen, setConfirmOpen] = useState(false)\n const base = localeChipLabel(connection.baseLocale)\n const active = localeChipLabel(locale)\n\n if (connection.connected) {\n return (\n <Row gap=\"1\">\n <Badge\n variant=\"outline\"\n data-testid={`field-connected-${fieldName}`}\n title={translate('cms.editor.localeFollowsHint', { base })}\n className=\"border-dashed px-1.5 py-0 text-[0.65rem] font-normal\"\n >\n <Link2 className=\"size-3\" aria-hidden />\n {translate('cms.editor.localeFollowsBase', { base })}\n </Badge>\n {connection.canFillFromBase ? (\n <ConnectionAction\n testId={`field-fill-from-base-${fieldName}`}\n onClick={onFillFromBase}\n >\n {translate('cms.editor.localeFillFromBase', { base })}\n </ConnectionAction>\n ) : null}\n </Row>\n )\n }\n\n return (\n <Row gap=\"1\">\n <Badge\n variant=\"outline\"\n data-testid={`field-diverged-${fieldName}`}\n title={translate('cms.editor.localeTranslatedHint', { base, locale: active })}\n className=\"px-1.5 py-0 text-[0.65rem] font-normal\"\n >\n <Languages className=\"size-3\" aria-hidden />\n {translate('cms.editor.localeTranslated')}\n </Badge>\n <ConnectionAction\n testId={`field-reset-to-base-${fieldName}`}\n onClick={() => setConfirmOpen(true)}\n >\n {translate('cms.editor.localeResetToBase', { base })}\n </ConnectionAction>\n <Dialog open={confirmOpen} onOpenChange={(open) => { if (!open) setConfirmOpen(false) }}>\n <DialogContent>\n <DialogHeader>\n <DialogTitle>{translate('cms.editor.localeResetTitle', { base })}</DialogTitle>\n <DialogDescription>\n {translate('cms.editor.localeResetDescription', { base, locale: active })}\n </DialogDescription>\n </DialogHeader>\n <DialogFooter className=\"mt-4\">\n <Button variant=\"ghost\" size=\"sm\" onClick={() => setConfirmOpen(false)}>\n {translate('common.cancel')}\n </Button>\n <Button\n variant=\"destructive\"\n size=\"sm\"\n data-testid={`field-reset-to-base-confirm-${fieldName}`}\n onClick={() => {\n setConfirmOpen(false)\n onResetToBase()\n }}\n >\n <RotateCcw /> {translate('cms.editor.localeResetConfirm', { base })}\n </Button>\n </DialogFooter>\n </DialogContent>\n </Dialog>\n </Row>\n )\n}\n\n/** A quiet inline text action for the connection affordance (fill / reset). */\nfunction ConnectionAction({\n testId,\n onClick,\n children,\n}: {\n testId: string\n onClick: () => void\n children: ReactNode\n}) {\n return (\n <button\n type=\"button\"\n data-testid={testId}\n onClick={onClick}\n className=\"rounded text-[0.7rem] font-medium text-primary underline-offset-2 hover:underline\"\n >\n {children}\n </button>\n )\n}\n"]}
@@ -1,15 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { AlertTriangle, ChevronDown, ChevronUp, GripVertical, ImagePlus, Link2, MoreHorizontal, Pencil, Plus, RotateCcw, SlidersHorizontal, Trash2, Upload, X } from 'lucide-react';
2
+ import { AlertTriangle, ChevronDown, ChevronUp, GripVertical, ImagePlus, Link2, MoreHorizontal, Pencil, Plus, RotateCcw, RotateCw, SlidersHorizontal, Trash2, Upload, X } from 'lucide-react';
3
3
  import { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router';
5
- import { BUILTIN_FIELD_CONTROLS, assetAcceptAttr, assetMatchesAccept, assetNode, assetSubfieldsOf, describeAccept, descendantIds, isFieldHidden, parentRelationField, readAssetId, } from '@elytracms/core/cms-core';
5
+ import { BUILTIN_FIELD_CONTROLS, applySlugify, assetAcceptAttr, assetMatchesAccept, assetNode, assetSubfieldsOf, describeAccept, descendantIds, findSlugCollision, isFieldHidden, parentRelationField, readAssetId, resolveSlugPath, slugSourceValue, } from '@elytracms/core/cms-core';
6
+ import { resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
6
7
  import { Button, Card, Code, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Field, Input, Row, RowItem, Select, Spacer, Stack, Text, } from '@elytracms/studio/ui';
7
8
  import { cn } from '@elytracms/studio/lib/utils';
8
9
  import { localizedLabel, translate } from '@elytracms/studio/lib/i18n';
10
+ import { activeLocaleValues } from '@elytracms/studio/lib/locale';
9
11
  import { applyAssetSelection, assetIdsOf, singleAssetIdOf } from '../assets/asset-selection';
10
12
  import { useOptionalAssets } from '../assets/assets-store';
11
13
  import { AssetThumb, FocalCropPreviews, FocalPointPicker, useAssetRecord } from '../assets/asset-thumb';
12
14
  import { newlyAddedAssetIds } from '../assets/focal-point';
15
+ import { assetMetaFieldsOf, pruneMeta } from '../assets/asset-metadata';
13
16
  import { fieldLabel } from './cms-store';
14
17
  import { useCollectionView } from '../collection-view/collection-view-context';
15
18
  import { useFieldDensity } from './field-density';
@@ -50,7 +53,26 @@ export function FieldControl(props) {
50
53
  }
51
54
  return builtIn;
52
55
  }
56
+ /** The conventional slug field name (mirrors `url-for-document`'s `SLUG_FIELD`). */
57
+ const SLUG_FIELD_NAME = 'slug';
58
+ /** The built-in control name that opts any `text` field into the slug control (EC-315). */
59
+ const SLUG_CONTROL = 'slug';
60
+ /**
61
+ * EC-315: a `text` field is rendered by the smart slug control when it opts in via
62
+ * `form.control: 'slug'`, OR (the default auto-upgrade) when it is simply named
63
+ * `slug` — the long-standing convention `url-for-document`/hierarchy already key off.
64
+ */
65
+ function isSlugControl(field) {
66
+ return field.type === 'text' && (field.form?.control === SLUG_CONTROL || field.name === SLUG_FIELD_NAME);
67
+ }
53
68
  function BuiltInFieldControl({ field, value, onChange, readOnly, id }) {
69
+ // EC-315: the smart slug control (composed path, auto-slug, per-locale segments)
70
+ // subsumes the old inline `slug` input + PathPreview. It degrades to a plain input
71
+ // when the collection isn't routable (name-based auto-upgrade only) or no document
72
+ // editor context is present (e.g. a table-cell picker).
73
+ if (isSlugControl(field)) {
74
+ return _jsx(SlugField, { field: field, value: value, onChange: onChange, readOnly: readOnly, id: id });
75
+ }
54
76
  switch (field.type) {
55
77
  case 'boolean':
56
78
  return (_jsxs("label", { className: "flex h-8 items-center gap-2 text-sm", children: [_jsx("input", { id: id, type: "checkbox", checked: value === true, disabled: readOnly, onChange: (e) => onChange(e.target.checked), className: "size-4 accent-primary disabled:opacity-50" }), _jsx(Text, { size: "sm", tone: "muted", children: translate(value === true ? 'common.true' : 'common.false') })] }));
@@ -99,6 +121,135 @@ export function SegmentedOptions({ field, value, onChange, readOnly, id, }) {
99
121
  return (_jsx(Button, { type: "button", variant: active ? 'default' : 'outline', size: "sm", role: "radio", "aria-checked": active, title: localizedLabel(opt.description) || undefined, disabled: readOnly, onClick: () => onChange(opt.value), children: localizedLabel(opt.label, opt.value) }, opt.value));
100
122
  }) }));
101
123
  }
124
+ /**
125
+ * EC-315 — the smart slug / URL-segment control. A `text` field named `slug` (or any
126
+ * `text` field with `form.control: 'slug'`) renders here instead of a bare input:
127
+ *
128
+ * - **Composed path**: the route-derived URL, decomposed by {@link resolveSlugPath}
129
+ * into read-only parent/context segments + the editable slug segment inline + the
130
+ * locale prefix (non-default locales). Reuses the same `urlForDocument` the live
131
+ * site uses, so the preview matches what visitors get. Draft-derived (updates as
132
+ * you type); the live "View live" button in the header stays the published open.
133
+ * - **Auto-slug**: while the slug is empty or was last produced by auto-derivation,
134
+ * it live-derives from the source field (default: the collection's `titleField`).
135
+ * A manual edit stops the auto-fill; the ↻ button always re-derives.
136
+ * - **Per-locale**: reads/writes through the shared `value`/`onChange` seam, so a
137
+ * `localized` slug edits the active locale's segment with no bespoke locale code.
138
+ * - **Uniqueness**: an inline warning when the candidate collides with a sibling in
139
+ * the same scope (same parent when hierarchical, same locale). A custom
140
+ * `slug.unique` hook replaces the default sibling scan.
141
+ *
142
+ * Degrades to a plain input when there is no document editor context (a table-cell
143
+ * picker) or when a name-based auto-upgrade lands on a non-routable collection.
144
+ */
145
+ function SlugField({ field, value, onChange, readOnly, id, }) {
146
+ const source = useCollectionView();
147
+ const editor = useOptionalDocumentEditor();
148
+ const slugConfig = field.slug;
149
+ const explicit = field.form?.control === SLUG_CONTROL;
150
+ const current = typeof value === 'string' ? value : '';
151
+ const locale = source.activeLocale;
152
+ const localeConfig = source.localeConfig;
153
+ const routes = useMemo(() => resolveDeploymentRoutes(), []);
154
+ const routable = useMemo(() => routes.some((r) => r.document?.collection === source.collectionId), [routes, source.collectionId]);
155
+ const draft = editor?.draft;
156
+ // The active-locale value bag the auto-slug source derives from (title, etc.).
157
+ const values = useMemo(() => (draft ? activeLocaleValues(draft, locale) : {}), [draft, locale]);
158
+ const derived = applySlugify(slugConfig, slugSourceValue(slugConfig, source.schema, values));
159
+ // Track whether the slug was manually set — auto-derive stays active while the
160
+ // slug is empty or was last produced by auto-derivation (never overwrites a manual
161
+ // slug). Seeded true when the loaded document already carries a slug.
162
+ const [manuallyEdited, setManuallyEdited] = useState(() => current.trim() !== '');
163
+ const onChangeRef = useRef(onChange);
164
+ onChangeRef.current = onChange;
165
+ useEffect(() => {
166
+ if (readOnly || manuallyEdited)
167
+ return;
168
+ if (derived && derived !== current)
169
+ onChangeRef.current(derived);
170
+ }, [derived, manuallyEdited, readOnly, current]);
171
+ // The composed path parts (routable collections with a document editor only).
172
+ const siblings = draft ? source.documents() : [];
173
+ // Draft-derived (routes + locale only, no `frontendUrl`) — the composed path is a
174
+ // read-only preview that updates as you type, mirroring the detail editor's draft
175
+ // URL. The header's published-gated "View live" stays the sole open-live action,
176
+ // so the draft/live distinction is preserved (an unpublished slug never links out).
177
+ const parts = useMemo(() => {
178
+ if (!draft || !routable)
179
+ return undefined;
180
+ return resolveSlugPath({ routes, localeConfig }, source.schema, draft, { siblings, locale });
181
+ }, [draft, routable, routes, localeConfig, source.schema, locale]);
182
+ // Default (sync) uniqueness — the first colliding sibling's title, or null.
183
+ const defaultCollisionTitle = useMemo(() => {
184
+ if (slugConfig?.unique || !draft)
185
+ return null;
186
+ const trimmed = current.trim();
187
+ if (trimmed === '')
188
+ return null;
189
+ const hit = findSlugCollision(trimmed, draft, source.documents(), {
190
+ collection: source.schema,
191
+ locale,
192
+ localeConfig,
193
+ });
194
+ return hit ? source.titleOf({ collection: source.collectionId, id: hit.id }) : null;
195
+ }, [current, draft, slugConfig, locale, localeConfig, source]);
196
+ // Custom (possibly async) uniqueness hook — race-guarded.
197
+ const [asyncTaken, setAsyncTaken] = useState(false);
198
+ useEffect(() => {
199
+ const unique = slugConfig?.unique;
200
+ if (!unique || !draft) {
201
+ setAsyncTaken(false);
202
+ return;
203
+ }
204
+ const trimmed = current.trim();
205
+ if (trimmed === '') {
206
+ setAsyncTaken(false);
207
+ return;
208
+ }
209
+ let cancelled = false;
210
+ Promise.resolve(unique(trimmed, { document: draft, locale, siblings: source.documents() }))
211
+ .then((ok) => {
212
+ if (!cancelled)
213
+ setAsyncTaken(ok === false);
214
+ })
215
+ .catch(() => {
216
+ if (!cancelled)
217
+ setAsyncTaken(false);
218
+ });
219
+ return () => {
220
+ cancelled = true;
221
+ };
222
+ }, [current, slugConfig, draft, locale, source]);
223
+ const collisionMessage = slugConfig?.unique
224
+ ? asyncTaken
225
+ ? translate('cms.field.slug.takenGeneric')
226
+ : undefined
227
+ : defaultCollisionTitle
228
+ ? translate('cms.field.slug.taken', { title: defaultCollisionTitle })
229
+ : undefined;
230
+ function handleInput(next) {
231
+ onChange(next || undefined);
232
+ // Clearing re-enables auto-derive; any non-empty entry is a manual override.
233
+ setManuallyEdited(next.trim() !== '');
234
+ }
235
+ function regenerate() {
236
+ setManuallyEdited(false);
237
+ const next = applySlugify(slugConfig, slugSourceValue(slugConfig, source.schema, values));
238
+ onChange(next || undefined);
239
+ }
240
+ // No document editor (a picker), or a name-based auto-upgrade on a non-routable
241
+ // collection: keep the historical plain input, no slug chrome (no regression).
242
+ if (!editor || (!explicit && !routable)) {
243
+ return (_jsx(Input, { id: id, disabled: readOnly, value: current, onChange: (e) => onChange(e.target.value || undefined) }));
244
+ }
245
+ const input = (_jsx(Input, { id: id, disabled: readOnly, value: current, placeholder: derived || undefined, onChange: (e) => handleInput(e.target.value), className: cn(parts ? 'h-7 min-w-[10ch] flex-1 bg-background font-mono text-xs' : 'flex-1') }));
246
+ const regenerateButton = !readOnly ? (_jsx(Button, { type: "button", variant: "ghost", size: "icon-sm", onClick: regenerate, "aria-label": translate('cms.field.slug.regenerate'), title: translate('cms.field.slug.regenerate'), children: _jsx(RotateCw, {}) })) : null;
247
+ return (_jsxs(Stack, { gap: "2", children: [parts ? (_jsxs(Row, { gap: "1", align: "center", wrap: true, className: "rounded-md border border-input bg-muted/30 px-2 py-1 font-mono text-xs text-muted-foreground", "data-testid": "slug-composed-path", children: [parts.localePrefix ? _jsx(SlugSegment, { children: parts.localePrefix }) : null, _jsx("span", { "aria-hidden": true, children: "/" }), parts.parentSegments.map((seg, i) => (_jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx(SlugSegment, { children: seg }), _jsx("span", { "aria-hidden": true, children: "/" })] }, `${seg}-${i}`))), input, regenerateButton] })) : (_jsxs(Row, { gap: "1", align: "center", children: [input, regenerateButton] })), routable ? (_jsxs(Row, { gap: "1", align: "center", className: "text-xs text-muted-foreground", "data-testid": "slug-path-preview", children: [_jsx(Link2, { className: "size-3 shrink-0", "aria-hidden": true }), parts?.path ? (_jsx(Text, { as: "span", size: "xs", tone: "muted", className: "min-w-0 truncate", title: parts.path, children: translate('cms.editor.servedAt', { path: parts.path }) })) : (_jsx(Text, { as: "span", size: "xs", tone: "muted", children: translate('cms.editor.slugUrlHint') }))] })) : null, collisionMessage ? (_jsxs(Row, { gap: "1", align: "center", "data-testid": "slug-collision", children: [_jsx(AlertTriangle, { className: "size-3.5 shrink-0 text-warning", "aria-hidden": true }), _jsx(Text, { size: "xs", tone: "muted", children: collisionMessage })] })) : null] }));
248
+ }
249
+ /** A single read-only path segment, subtly boxed to read as fixed context (EC-315). */
250
+ function SlugSegment({ children }) {
251
+ return (_jsx("span", { className: "rounded bg-background px-1.5 py-0.5 text-foreground/70 ring-1 ring-inset ring-border", children: children }));
252
+ }
102
253
  function asRecord(value) {
103
254
  return value !== null && typeof value === 'object' && !Array.isArray(value)
104
255
  ? value
@@ -522,10 +673,7 @@ function AssetField({ field, value, onChange, readOnly, }) {
522
673
  // is the belt to the picker's stopPropagation braces: the field cannot react at all.
523
674
  const dragProps = canUpload && !open ? { onDragEnter, onDragOver, onDragLeave, onDrop } : {};
524
675
  const selectButton = !readOnly ? (_jsxs(Button, { variant: "outline", size: "sm", onClick: () => setOpen(true), children: [_jsx(ImagePlus, {}), " ", translate('common.select')] })) : null;
525
- return (_jsxs(Stack, { gap: "2", className: cn('relative rounded-lg transition-shadow', dragging && 'ring-2 ring-primary ring-offset-2 ring-offset-background'), ...dragProps, children: [many && hasSub ? (
526
- // Gallery with per-use sub-fields (EC-301): a card per placement — thumbnail +
527
- // its own sub-field form — reusing the object/repeater item rendering.
528
- _jsxs(Stack, { gap: "2", children: [entries.length > 0 ? (_jsx(Stack, { gap: "2", children: entries.map((entry, i) => {
676
+ return (_jsxs(Stack, { gap: "2", className: cn('relative rounded-lg transition-shadow', dragging && 'ring-2 ring-primary ring-offset-2 ring-offset-background'), ...dragProps, children: [many && hasSub ? (_jsxs(Stack, { gap: "2", children: [entries.length > 0 ? (_jsx(Stack, { gap: "2", children: entries.map((entry, i) => {
529
677
  const aid = readAssetId(entry);
530
678
  if (!aid)
531
679
  return null;
@@ -534,12 +682,7 @@ function AssetField({ field, value, onChange, readOnly, }) {
534
682
  nextEntries[i] = assetNode(aid, next);
535
683
  onChange(nextEntries);
536
684
  }, onRemove: readOnly ? undefined : () => onChange(entries.filter((_, j) => j !== i)) }, `${aid}-${i}`));
537
- }) })) : (_jsx(EmptyAssetState, { compact: compact, canUpload: canUpload, onSelect: () => setOpen(true) })), _jsx(Row, { gap: "2", wrap: true, align: "center", children: selectButton })] })) : many ? (
538
- // Gallery: a grid of focal-aware previews + the single Select entry.
539
- _jsxs(Stack, { gap: "2", children: [ids.length > 0 ? (_jsx("div", { className: cn('grid gap-2', compact ? 'grid-cols-2' : 'grid-cols-3 sm:grid-cols-4'), children: ids.map((aid, i) => (_jsx(AssetChip, { assetId: aid, collectionId: assetCollectionId, onRemove: readOnly ? undefined : () => onChange(ids.filter((x) => x !== aid)) }, `${aid}-${i}`))) })) : (_jsx(EmptyAssetState, { compact: compact, canUpload: canUpload, onSelect: () => setOpen(true) })), _jsx(Row, { gap: "2", wrap: true, align: "center", children: selectButton })] })) : single && hasSub ? (
540
- // With per-use sub-fields the asset + its fields read as ONE grouped unit
541
- // (like an object), so it's clear they belong together (EC-301 feedback).
542
- _jsx(Card, { className: "border-l-2 border-l-border p-3", children: _jsxs(Stack, { gap: "3", children: [_jsx(AssetSinglePreview, { assetId: single, compact: compact, readOnly: readOnly, hasLibrary: assets !== null, fallbackTitle: source.titleOf({ collection: assetCollectionId, id: single }), selectButton: selectButton, onEdit: () => setEditing(true), onRemove: () => onChange(applyAssetSelection(value, null, field.multiple)) }), _jsx("div", { className: "border-t border-border/60 pt-3", children: _jsx(ObjectItemForm, { fields: subFields, value: assetSubfieldsOf(value), readOnly: readOnly, idPrefix: "asset-sub", onChange: (next) => onChange(assetNode(single, next)) }) })] }) })) : single ? (_jsx(AssetSinglePreview, { assetId: single, compact: compact, readOnly: readOnly, hasLibrary: assets !== null, fallbackTitle: source.titleOf({ collection: assetCollectionId, id: single }), selectButton: selectButton, onEdit: () => setEditing(true), onRemove: () => onChange(applyAssetSelection(value, null, field.multiple)) })) : (_jsxs(Stack, { gap: "2", children: [_jsx(EmptyAssetState, { compact: compact, canUpload: canUpload, onSelect: () => setOpen(true) }), _jsx(Row, { gap: "2", wrap: true, align: "center", children: selectButton })] })), !many && single && assets ? (_jsx(AssetOptionsDialog, { assetId: single, readOnly: readOnly, open: editing, onOpenChange: setEditing })) : null, _jsx(AssetPicker, { value: single, open: open, onOpenChange: setOpen, accept: assetAcceptAttr(field.accept), onChange: applyPicked }), rejected.length > 0 ? (_jsxs(Row, { gap: "2", align: "center", children: [_jsx(AlertTriangle, { className: "size-4 shrink-0 text-warning" }), _jsx(Text, { size: "xs", tone: "muted", children: rejected.length === 1
685
+ }) })) : (_jsx(EmptyAssetState, { compact: compact, canUpload: canUpload, onSelect: () => setOpen(true) })), _jsx(Row, { gap: "2", wrap: true, align: "center", children: selectButton })] })) : many ? (_jsxs(Stack, { gap: "2", children: [ids.length > 0 ? (_jsx("div", { className: cn('grid gap-2', compact ? 'grid-cols-2' : 'grid-cols-3 sm:grid-cols-4'), children: ids.map((aid, i) => (_jsx(AssetChip, { assetId: aid, collectionId: assetCollectionId, onRemove: readOnly ? undefined : () => onChange(ids.filter((x) => x !== aid)) }, `${aid}-${i}`))) })) : (_jsx(EmptyAssetState, { compact: compact, canUpload: canUpload, onSelect: () => setOpen(true) })), _jsx(Row, { gap: "2", wrap: true, align: "center", children: selectButton })] })) : single && hasSub ? (_jsx(Card, { className: "border-l-2 border-l-border p-3", children: _jsxs(Stack, { gap: "3", children: [_jsx(AssetSinglePreview, { assetId: single, compact: compact, readOnly: readOnly, hasLibrary: assets !== null, fallbackTitle: source.titleOf({ collection: assetCollectionId, id: single }), selectButton: selectButton, onEdit: () => setEditing(true), onRemove: () => onChange(applyAssetSelection(value, null, field.multiple)) }), _jsx("div", { className: "border-t border-border/60 pt-3", children: _jsx(ObjectItemForm, { fields: subFields, value: assetSubfieldsOf(value), readOnly: readOnly, idPrefix: "asset-sub", onChange: (next) => onChange(assetNode(single, next)) }) })] }) })) : single ? (_jsx(AssetSinglePreview, { assetId: single, compact: compact, readOnly: readOnly, hasLibrary: assets !== null, fallbackTitle: source.titleOf({ collection: assetCollectionId, id: single }), selectButton: selectButton, onEdit: () => setEditing(true), onRemove: () => onChange(applyAssetSelection(value, null, field.multiple)) })) : (_jsxs(Stack, { gap: "2", children: [_jsx(EmptyAssetState, { compact: compact, canUpload: canUpload, onSelect: () => setOpen(true) }), _jsx(Row, { gap: "2", wrap: true, align: "center", children: selectButton })] })), !many && single && assets ? (_jsx(AssetOptionsDialog, { assetId: single, readOnly: readOnly, open: editing, onOpenChange: setEditing })) : null, _jsx(AssetPicker, { value: single, open: open, onOpenChange: setOpen, accept: assetAcceptAttr(field.accept), onChange: applyPicked }), rejected.length > 0 ? (_jsxs(Row, { gap: "2", align: "center", children: [_jsx(AlertTriangle, { className: "size-4 shrink-0 text-warning" }), _jsx(Text, { size: "xs", tone: "muted", children: rejected.length === 1
543
686
  ? translate('cms.asset.rejected.one', {
544
687
  name: rejected[0],
545
688
  types: describeAccept(field.accept),
@@ -605,25 +748,6 @@ function AssetGalleryItem({ assetId, collectionId, fields, value, onChangeSubs,
605
748
  const title = source.titleOf({ collection: collectionId, id: assetId });
606
749
  return (_jsxs(Card, { className: "border-l-2 border-l-border p-0", children: [_jsxs(Row, { gap: "2", align: "center", className: "border-b border-border/60 bg-muted/30 px-2 py-1.5", children: [_jsx(AssetThumb, { record: record, alt: title, className: "size-10 shrink-0" }), _jsx(Text, { size: "sm", className: "min-w-0 flex-1 truncate font-medium", title: title, children: title }), onRemove ? (_jsx(Button, { variant: "ghost", size: "sm", "aria-label": translate('common.remove'), onClick: onRemove, children: _jsx(Trash2, { className: "size-3.5" }) })) : null] }), _jsx("div", { className: "px-3 pb-3", children: _jsx(ObjectItemForm, { fields: fields, value: value, onChange: onChangeSubs, readOnly: readOnly, idPrefix: idPrefix }) })] }));
607
750
  }
608
- /**
609
- * System columns the upload pipeline owns — never editable metadata, so they're
610
- * excluded from the asset collection's schema-driven options form (EC-301). The
611
- * filename + dimensions already show in the field's info column.
612
- */
613
- const SYSTEM_ASSET_FIELDS = new Set([
614
- 'filename',
615
- 'url',
616
- 'contentType',
617
- 'byteSize',
618
- 'width',
619
- 'height',
620
- 'storageKey',
621
- 'title',
622
- ]);
623
- /** Drop `undefined` values so the persisted meta bag stays JSON/Convex-clean. */
624
- function pruneMeta(meta) {
625
- return Object.fromEntries(Object.entries(meta).filter(([, v]) => v !== undefined));
626
- }
627
751
  /**
628
752
  * The image options dialog (EC-301): the focal point (engine-special visual
629
753
  * control) + the asset's **schema-driven** metadata, rendered from the
@@ -638,9 +762,9 @@ function AssetOptionsDialog({ assetId, readOnly, open, onOpenChange, }) {
638
762
  const assets = useOptionalAssets();
639
763
  const record = useAssetRecord(assetId);
640
764
  const metaFields = useMemo(() => {
641
- const assetId = source.assetCollectionId ?? 'asset';
642
- const collection = source.collections.find((c) => c.id === assetId);
643
- return (collection?.fields ?? []).filter((f) => !SYSTEM_ASSET_FIELDS.has(f.name) && !f.form?.readOnly);
765
+ const assetCollectionId = source.assetCollectionId ?? 'asset';
766
+ const collection = source.collections.find((c) => c.id === assetCollectionId);
767
+ return collection ? assetMetaFieldsOf(collection) : [];
644
768
  }, [source]);
645
769
  // Local edit buffers, seeded once per open from the record (so an external edit
646
770
  // still wins between opens). Metadata is non-localized in v1 (the asset