@elytracms/studio 0.0.37 → 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.
- package/convex/cms.ts +8 -2
- package/dist/components/app-views/default-modules.d.ts +60 -5
- package/dist/components/app-views/default-modules.js +53 -15
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +2 -0
- package/dist/components/app-views/module.js +8 -0
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/assets/asset-detail.d.ts +24 -0
- package/dist/components/assets/asset-detail.js +163 -0
- package/dist/components/assets/asset-detail.js.map +1 -0
- package/dist/components/assets/asset-grid.d.ts +28 -0
- package/dist/components/assets/asset-grid.js +68 -0
- package/dist/components/assets/asset-grid.js.map +1 -0
- package/dist/components/assets/asset-metadata.d.ts +19 -0
- package/dist/components/assets/asset-metadata.js +44 -0
- package/dist/components/assets/asset-metadata.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +54 -0
- package/dist/components/assets/assets-collection-view-source.js +186 -0
- package/dist/components/assets/assets-collection-view-source.js.map +1 -0
- package/dist/components/assets/assets-module.d.ts +18 -0
- package/dist/components/assets/assets-module.js +94 -0
- package/dist/components/assets/assets-module.js.map +1 -0
- package/dist/components/assets/assets-store.js +2 -7
- package/dist/components/assets/assets-store.js.map +1 -1
- package/dist/components/assets/assets-view-preference.d.ts +15 -0
- package/dist/components/assets/assets-view-preference.js +22 -0
- package/dist/components/assets/assets-view-preference.js.map +1 -0
- package/dist/components/builder/builder-store.d.ts +12 -0
- package/dist/components/builder/builder-store.js +18 -16
- package/dist/components/builder/builder-store.js.map +1 -1
- package/dist/components/builder/clipboard-actions.js +5 -14
- package/dist/components/builder/clipboard-actions.js.map +1 -1
- package/dist/components/builder/composition-field-editor-lazy.d.ts +19 -0
- package/dist/components/builder/composition-field-editor-lazy.js +29 -0
- package/dist/components/builder/composition-field-editor-lazy.js.map +1 -0
- package/dist/components/builder/composition-field-editor.js +1 -4
- package/dist/components/builder/composition-field-editor.js.map +1 -1
- package/dist/components/builder/insert-context.d.ts +8 -0
- package/dist/components/builder/insert-context.js +1 -3
- package/dist/components/builder/insert-context.js.map +1 -1
- package/dist/components/builder/page-tree.js +1 -3
- package/dist/components/builder/page-tree.js.map +1 -1
- package/dist/components/builder/property-inspector.js +7 -134
- package/dist/components/builder/property-inspector.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +15 -4
- package/dist/components/cms/cms-store.js +42 -24
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.d.ts +8 -1
- package/dist/components/cms/collection-table.js +62 -10
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/column-width.d.ts +25 -0
- package/dist/components/cms/column-width.js +47 -0
- package/dist/components/cms/column-width.js.map +1 -0
- package/dist/components/cms/detail-editor.js +101 -68
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/discard-draft.d.ts +32 -0
- package/dist/components/cms/discard-draft.js +13 -0
- package/dist/components/cms/discard-draft.js.map +1 -0
- package/dist/components/cms/document-staging.d.ts +7 -0
- package/dist/components/cms/document-staging.js +44 -0
- package/dist/components/cms/document-staging.js.map +1 -0
- package/dist/components/cms/field-connection.d.ts +22 -0
- package/dist/components/cms/field-connection.js +37 -0
- package/dist/components/cms/field-connection.js.map +1 -0
- package/dist/components/cms/field-control.js +158 -34
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/prop-fields.d.ts +43 -0
- package/dist/components/cms/prop-fields.js +130 -0
- package/dist/components/cms/prop-fields.js.map +1 -0
- package/dist/components/cms/rich-text/component-embed.d.ts +17 -3
- package/dist/components/cms/rich-text/component-embed.js +29 -7
- package/dist/components/cms/rich-text/component-embed.js.map +1 -1
- package/dist/components/cms/rich-text/editor.d.ts +3 -0
- package/dist/components/cms/rich-text/editor.js +21 -3
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/embed-picker.d.ts +17 -0
- package/dist/components/cms/rich-text/embed-picker.js +34 -0
- package/dist/components/cms/rich-text/embed-picker.js.map +1 -0
- package/dist/components/cms/rich-text/embed-props.d.ts +49 -0
- package/dist/components/cms/rich-text/embed-props.js +106 -0
- package/dist/components/cms/rich-text/embed-props.js.map +1 -0
- package/dist/components/cms/rich-text/extensions.d.ts +7 -1
- package/dist/components/cms/rich-text/extensions.js +6 -1
- package/dist/components/cms/rich-text/extensions.js.map +1 -1
- package/dist/components/cms/rich-text/index.d.ts +6 -1
- package/dist/components/cms/rich-text/index.js +6 -1
- package/dist/components/cms/rich-text/index.js.map +1 -1
- package/dist/components/cms/rich-text/insert.d.ts +21 -0
- package/dist/components/cms/rich-text/insert.js +55 -0
- package/dist/components/cms/rich-text/insert.js.map +1 -0
- package/dist/components/cms/rich-text/lazy.d.ts +19 -0
- package/dist/components/cms/rich-text/lazy.js +26 -0
- package/dist/components/cms/rich-text/lazy.js.map +1 -0
- package/dist/components/cms/rich-text/slash-command.d.ts +27 -0
- package/dist/components/cms/rich-text/slash-command.js +158 -0
- package/dist/components/cms/rich-text/slash-command.js.map +1 -0
- package/dist/components/cms/rich-text/toolbar.d.ts +5 -6
- package/dist/components/cms/rich-text/toolbar.js +3 -5
- package/dist/components/cms/rich-text/toolbar.js.map +1 -1
- package/dist/components/cms/row-actions.d.ts +28 -0
- package/dist/components/cms/row-actions.js +20 -0
- package/dist/components/cms/row-actions.js.map +1 -0
- package/dist/components/cms/use-document-versions.d.ts +8 -1
- package/dist/components/cms/use-document-versions.js +31 -4
- package/dist/components/cms/use-document-versions.js.map +1 -1
- package/dist/components/cms/version-compare-lazy.d.ts +1 -0
- package/dist/components/cms/version-compare-lazy.js +23 -0
- package/dist/components/cms/version-compare-lazy.js.map +1 -0
- package/dist/components/cms/version-compare-text.d.ts +46 -0
- package/dist/components/cms/version-compare-text.js +78 -0
- package/dist/components/cms/version-compare-text.js.map +1 -1
- package/dist/components/cms/version-compare.d.ts +9 -5
- package/dist/components/cms/version-compare.js +184 -59
- package/dist/components/cms/version-compare.js.map +1 -1
- package/dist/components/cms/versions-pane.js +10 -4
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/components/component-detail.d.ts +5 -3
- package/dist/components/components/component-detail.js +41 -8
- package/dist/components/components/component-detail.js.map +1 -1
- package/dist/components/components/component-preview.d.ts +61 -0
- package/dist/components/components/component-preview.js +103 -0
- package/dist/components/components/component-preview.js.map +1 -0
- package/dist/components/components/components-index.d.ts +5 -3
- package/dist/components/components/components-index.js +19 -24
- package/dist/components/components/components-index.js.map +1 -1
- package/dist/components/components/registry-browser.d.ts +7 -0
- package/dist/components/components/registry-browser.js +20 -36
- package/dist/components/components/registry-browser.js.map +1 -1
- package/dist/components/components/taxonomy.d.ts +51 -0
- package/dist/components/components/taxonomy.js +73 -0
- package/dist/components/components/taxonomy.js.map +1 -0
- package/dist/components/components/transfer-dialog.d.ts +20 -0
- package/dist/components/components/transfer-dialog.js +64 -0
- package/dist/components/components/transfer-dialog.js.map +1 -0
- package/dist/components/components/transfer.d.ts +43 -0
- package/dist/components/components/transfer.js +31 -0
- package/dist/components/components/transfer.js.map +1 -0
- package/dist/components/project-list.js +3 -4
- package/dist/components/project-list.js.map +1 -1
- package/dist/components/project-switcher.js +3 -2
- package/dist/components/project-switcher.js.map +1 -1
- package/dist/components/rail-store-status.d.ts +21 -0
- package/dist/components/rail-store-status.js +21 -0
- package/dist/components/rail-store-status.js.map +1 -0
- package/dist/components/redirects/redirects-module.js +4 -8
- package/dist/components/redirects/redirects-module.js.map +1 -1
- package/dist/components/redirects/redirects-store.js +2 -7
- package/dist/components/redirects/redirects-store.js.map +1 -1
- package/dist/components/sign-in-needed-badge.d.ts +6 -0
- package/dist/components/sign-in-needed-badge.js +13 -0
- package/dist/components/sign-in-needed-badge.js.map +1 -0
- package/dist/components/studio-project-screen.js +1 -4
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/components/studio-shell.js +1 -4
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/components/users/users-module.js +5 -7
- package/dist/components/users/users-module.js.map +1 -1
- package/dist/components/users/users-store.js +2 -7
- package/dist/components/users/users-store.js.map +1 -1
- package/dist/lib/auth/index.js +1 -1
- package/dist/lib/auth/index.js.map +1 -1
- package/dist/lib/clipboard/paste-target.js +1 -3
- package/dist/lib/clipboard/paste-target.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +0 -2
- package/dist/lib/cms-views/view-model.js +0 -7
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/diff-view-preference.d.ts +7 -0
- package/dist/lib/diff-view-preference.js +21 -0
- package/dist/lib/diff-view-preference.js.map +1 -0
- package/dist/lib/i18n/messages.en.js +81 -30
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/local-preference.d.ts +11 -0
- package/dist/lib/local-preference.js +26 -0
- package/dist/lib/local-preference.js.map +1 -0
- package/dist/lib/locale/fallback.d.ts +45 -0
- package/dist/lib/locale/fallback.js +85 -0
- package/dist/lib/locale/fallback.js.map +1 -1
- package/dist/lib/persona/labels.d.ts +6 -0
- package/dist/lib/persona/labels.js +8 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/lib/query/index.d.ts +1 -1
- package/dist/lib/query/index.js +1 -1
- package/dist/lib/query/index.js.map +1 -1
- package/dist/lib/query/operations-query.d.ts +14 -2
- package/dist/lib/query/operations-query.js +11 -6
- package/dist/lib/query/operations-query.js.map +1 -1
- package/dist/lib/rail.d.ts +0 -6
- package/dist/lib/rail.js +6 -25
- package/dist/lib/rail.js.map +1 -1
- package/dist/lib/theme.d.ts +0 -6
- package/dist/lib/theme.js +10 -13
- package/dist/lib/theme.js.map +1 -1
- package/dist/router/match.d.ts +1 -0
- package/dist/router/match.js +4 -0
- package/dist/router/match.js.map +1 -1
- package/dist/ui/button.d.ts +1 -1
- package/package.json +22 -24
- package/src/styles/studio.css +17 -0
|
@@ -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
|
|
642
|
-
const collection = source.collections.find((c) => c.id ===
|
|
643
|
-
return
|
|
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
|