@elytracms/studio 0.0.38 → 0.0.40
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 +75 -11
- 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 +143 -79
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/details-pane.d.ts +21 -8
- package/dist/components/cms/details-pane.js +31 -11
- package/dist/components/cms/details-pane.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 +167 -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 +154 -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/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/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/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/link-control.js +3 -1
- package/dist/components/cms/rich-text/link-control.js.map +1 -1
- package/dist/components/cms/rich-text/toolbar-state.d.ts +45 -0
- package/dist/components/cms/rich-text/toolbar-state.js +66 -0
- package/dist/components/cms/rich-text/toolbar-state.js.map +1 -0
- package/dist/components/cms/rich-text/toolbar.js +7 -15
- 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 +217 -64
- 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 +97 -0
- package/dist/components/components/component-preview.js +202 -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 +94 -33
- 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 -25
|
@@ -1,21 +1,32 @@
|
|
|
1
|
-
import type { CmsDocument, CollectionDef, FieldDef } from '@elytracms/core/cms-core';
|
|
1
|
+
import type { CmsDocument, CollectionDef, FieldDef, LocaleConfig, RouteRecord } from '@elytracms/core/cms-core';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
+
/** 0.0.39 — routing context the Details pane needs to compose the document URL row. */
|
|
4
|
+
export interface DetailsUrlContext {
|
|
5
|
+
projectId: string;
|
|
6
|
+
routes: readonly RouteRecord[];
|
|
7
|
+
localeConfig: LocaleConfig;
|
|
8
|
+
locale: string;
|
|
9
|
+
}
|
|
3
10
|
/**
|
|
4
|
-
* EC-284 + EC-285 — document-level metadata pane (the right
|
|
5
|
-
* optional regions:
|
|
11
|
+
* EC-284 + EC-285 (+ 0.0.39 URL row) — document-level metadata pane (the right
|
|
12
|
+
* inspector). Up to three optional regions:
|
|
6
13
|
*
|
|
7
|
-
* 1. **
|
|
14
|
+
* 1. **URL** (0.0.39): the full composed path as its own row — the EC-315 "hybrid
|
|
15
|
+
* split" moves the composed URL out of the cramped slug field and up to the
|
|
16
|
+
* document, where a URL is a first-class property. Ancestor-DOCUMENT segments
|
|
17
|
+
* link to their editors; static route segments are plain text.
|
|
18
|
+
* 2. **Slot** (EC-284): a stable, named handle the site uses to find this document
|
|
8
19
|
* (the main menu, the footer menu…) instead of which exact entry it is — shown
|
|
9
20
|
* only when the collection declares alias slots.
|
|
10
|
-
*
|
|
21
|
+
* 3. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,
|
|
11
22
|
* author, publish date…) — document-level properties an editor sets *about* the
|
|
12
23
|
* document rather than as body content. Rendered via the detail editor's own
|
|
13
24
|
* field-row renderer, so they behave identically to a content field.
|
|
14
25
|
*
|
|
15
|
-
*
|
|
16
|
-
* tabs. The empty state shows only when there is
|
|
26
|
+
* All three are identity/metadata, not content, so they live here — off the content
|
|
27
|
+
* tabs. The empty state shows only when there is nothing at all to display.
|
|
17
28
|
*/
|
|
18
|
-
export declare function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange, }: {
|
|
29
|
+
export declare function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange, urlContext, }: {
|
|
19
30
|
collection: CollectionDef;
|
|
20
31
|
doc: CmsDocument;
|
|
21
32
|
/** The other documents in this collection (to show which slots are taken). */
|
|
@@ -26,4 +37,6 @@ export declare function DetailsPane({ collection, doc, siblings, readOnly, field
|
|
|
26
37
|
/** Render one field row — shared with the content groups so behaviour matches. */
|
|
27
38
|
renderField: (field: FieldDef) => ReactNode;
|
|
28
39
|
onAliasChange: (alias: string | undefined) => void;
|
|
40
|
+
/** Routing context for the composed URL row (0.0.39); omitted for non-routable rails. */
|
|
41
|
+
urlContext?: DetailsUrlContext;
|
|
29
42
|
}): import("react").JSX.Element;
|
|
@@ -1,30 +1,50 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { declaredAliases } from '@elytracms/core/cms-core';
|
|
3
|
-
import {
|
|
2
|
+
import { declaredAliases, resolveUrlSegments } from '@elytracms/core/cms-core';
|
|
3
|
+
import { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router';
|
|
4
|
+
import { Row, Select, Separator, Stack, Text } from '@elytracms/studio/ui';
|
|
5
|
+
import { cn } from '@elytracms/studio/lib/utils';
|
|
4
6
|
import { translate } from '@elytracms/studio/lib/i18n';
|
|
5
7
|
/**
|
|
6
|
-
* EC-284 + EC-285 — document-level metadata pane (the right
|
|
7
|
-
* optional regions:
|
|
8
|
+
* EC-284 + EC-285 (+ 0.0.39 URL row) — document-level metadata pane (the right
|
|
9
|
+
* inspector). Up to three optional regions:
|
|
8
10
|
*
|
|
9
|
-
* 1. **
|
|
11
|
+
* 1. **URL** (0.0.39): the full composed path as its own row — the EC-315 "hybrid
|
|
12
|
+
* split" moves the composed URL out of the cramped slug field and up to the
|
|
13
|
+
* document, where a URL is a first-class property. Ancestor-DOCUMENT segments
|
|
14
|
+
* link to their editors; static route segments are plain text.
|
|
15
|
+
* 2. **Slot** (EC-284): a stable, named handle the site uses to find this document
|
|
10
16
|
* (the main menu, the footer menu…) instead of which exact entry it is — shown
|
|
11
17
|
* only when the collection declares alias slots.
|
|
12
|
-
*
|
|
18
|
+
* 3. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,
|
|
13
19
|
* author, publish date…) — document-level properties an editor sets *about* the
|
|
14
20
|
* document rather than as body content. Rendered via the detail editor's own
|
|
15
21
|
* field-row renderer, so they behave identically to a content field.
|
|
16
22
|
*
|
|
17
|
-
*
|
|
18
|
-
* tabs. The empty state shows only when there is
|
|
23
|
+
* All three are identity/metadata, not content, so they live here — off the content
|
|
24
|
+
* tabs. The empty state shows only when there is nothing at all to display.
|
|
19
25
|
*/
|
|
20
|
-
export function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange, }) {
|
|
26
|
+
export function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange, urlContext, }) {
|
|
21
27
|
const slots = declaredAliases(collection);
|
|
22
28
|
const hasSlots = slots.length > 0;
|
|
23
29
|
const hasFields = fields.length > 0;
|
|
24
|
-
|
|
30
|
+
const routable = urlContext !== undefined && urlContext.routes.some((r) => r.document?.collection === collection.id);
|
|
31
|
+
return (_jsxs(Stack, { gap: "4", className: "p-3", children: [routable && urlContext ? (_jsx(DocumentUrlRow, { collection: collection, doc: doc, siblings: siblings, context: urlContext })) : null, routable && (hasSlots || hasFields) ? _jsx(Separator, {}) : null, hasSlots ? (_jsxs(Stack, { gap: "1", children: [_jsx(Text, { size: "xs", weight: "semibold", children: translate('cms.details.slot') }), _jsxs(Select, { value: doc.alias ?? '', disabled: readOnly, "aria-label": translate('cms.details.slot'), "data-testid": "alias-select", onChange: (e) => onAliasChange(e.target.value === '' ? undefined : e.target.value), children: [_jsx("option", { value: "", children: translate('cms.details.noSlot') }), slots.map((slot) => {
|
|
25
32
|
const takenByOther = siblings.some((s) => s.id !== doc.id && s.alias === slot);
|
|
26
33
|
const blocked = takenByOther && doc.alias !== slot;
|
|
27
34
|
return (_jsxs("option", { value: slot, disabled: blocked, children: [slot, blocked ? translate('cms.details.slotInUse') : ''] }, slot));
|
|
28
|
-
})] }), _jsx(Text, { size: "xs", tone: "muted", children: translate('cms.details.slotHint') })] })) : null, hasSlots && hasFields ? _jsx(Separator, {}) : null, hasFields ? _jsx(Stack, { gap: "4", children: fields.map(renderField) }) : null, !hasSlots && !hasFields ? (_jsx(Text, { size: "xs", tone: "muted", children: translate('cms.details.empty') })) : null] }));
|
|
35
|
+
})] }), _jsx(Text, { size: "xs", tone: "muted", children: translate('cms.details.slotHint') })] })) : null, hasSlots && hasFields ? _jsx(Separator, {}) : null, hasFields ? _jsx(Stack, { gap: "4", children: fields.map(renderField) }) : null, !routable && !hasSlots && !hasFields ? (_jsx(Text, { size: "xs", tone: "muted", children: translate('cms.details.empty') })) : null] }));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The document-level URL row (0.0.39 — EC-315 "hybrid split"). The full composed path,
|
|
39
|
+
* classified segment by segment: an ancestor-DOCUMENT segment is a link to that
|
|
40
|
+
* document's editor (autosave keeps the current edit safe on navigate); a static
|
|
41
|
+
* route segment (mount prefix, or `blog` in `/blog/:slug`) is plain text; this
|
|
42
|
+
* document's own slug segment is highlighted as the current page. Draft-derived (it
|
|
43
|
+
* updates as you type) — the header's published-gated "View live" stays the sole
|
|
44
|
+
* open-live action, so the draft/live distinction is preserved here too.
|
|
45
|
+
*/
|
|
46
|
+
function DocumentUrlRow({ collection, doc, siblings, context, }) {
|
|
47
|
+
const segments = resolveUrlSegments({ routes: [...context.routes], localeConfig: context.localeConfig }, collection, doc, { siblings, locale: context.locale });
|
|
48
|
+
return (_jsxs(Stack, { gap: "1", "data-testid": "details-url", children: [_jsx(Text, { size: "xs", weight: "semibold", children: translate('cms.details.url') }), _jsx(Row, { gap: "none", align: "center", wrap: true, className: "font-mono text-xs text-muted-foreground", children: segments.length === 0 ? (_jsx("span", { "aria-hidden": true, children: "/" })) : (segments.map((seg, i) => (_jsxs("span", { className: "inline-flex items-center", children: [_jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: "/" }), seg.kind === 'ancestor' && seg.documentId ? (_jsx(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId: context.projectId, _splat: studioPath.cmsDocument(collection.id, seg.documentId) }, title: translate('cms.relation.openTitle', { title: seg.text }), "data-testid": "details-url-ancestor", className: "rounded px-0.5 text-primary hover:bg-primary/10 hover:underline", children: seg.text })) : (_jsx("span", { className: cn('px-0.5', seg.kind === 'current' && 'font-medium text-foreground'), title: seg.kind === 'current' ? translate('cms.details.urlCurrent') : undefined, children: seg.text }))] }, `${seg.text}-${i}`)))) })] }));
|
|
29
49
|
}
|
|
30
50
|
//# sourceMappingURL=details-pane.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"details-pane.js","sourceRoot":"","sources":["../../../src/components/cms/details-pane.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAA;AAG1D,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,KAAK,EAAE,IAAI,EAAE,MAAM,sBAAsB,CAAA;AACrE,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAA;AAEtD;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,WAAW,CAAC,EAC1B,UAAU,EACV,GAAG,EACH,QAAQ,EACR,QAAQ,EACR,MAAM,EACN,WAAW,EACX,aAAa,GAYd;IACC,MAAM,KAAK,GAAG,eAAe,CAAC,UAAU,CAAC,CAAA;IACzC,MAAM,QAAQ,GAAG,KAAK,CAAC,MAAM,GAAG,CAAC,CAAA;IACjC,MAAM,SAAS,GAAG,MAAM,CAAC,MAAM,GAAG,CAAC,CAAA;IAEnC,OAAO,CACL,MAAC,KAAK,IAAC,GAAG,EAAC,GAAG,EAAC,SAAS,EAAC,KAAK,aAC3B,QAAQ,CAAC,CAAC,CAAC,CACV,MAAC,KAAK,IAAC,GAAG,EAAC,GAAG,aACZ,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,MAAM,EAAC,UAAU,YAC9B,SAAS,CAAC,kBAAkB,CAAC,GACzB,EACP,MAAC,MAAM,IACL,KAAK,EAAE,GAAG,CAAC,KAAK,IAAI,EAAE,EACtB,QAAQ,EAAE,QAAQ,gBACN,SAAS,CAAC,kBAAkB,CAAC,iBAC7B,cAAc,EAC1B,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,aAElF,iBAAQ,KAAK,EAAC,EAAE,YAAE,SAAS,CAAC,oBAAoB,CAAC,GAAU,EAC1D,KAAK,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;gCAClB,MAAM,YAAY,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,KAAK,GAAG,CAAC,EAAE,IAAI,CAAC,CAAC,KAAK,KAAK,IAAI,CAAC,CAAA;gCAC9E,MAAM,OAAO,GAAG,YAAY,IAAI,GAAG,CAAC,KAAK,KAAK,IAAI,CAAA;gCAClD,OAAO,CACL,kBAAmB,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,OAAO,aAC9C,IAAI,EACJ,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC,uBAAuB,CAAC,CAAC,CAAC,CAAC,EAAE,KAFvC,IAAI,CAGR,CACV,CAAA;4BACH,CAAC,CAAC,IACK,EACT,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,sBAAsB,CAAC,GAC7B,IACD,CACT,CAAC,CAAC,CAAC,IAAI,EAEP,QAAQ,IAAI,SAAS,CAAC,CAAC,CAAC,KAAC,SAAS,KAAG,CAAC,CAAC,CAAC,IAAI,EAE5C,SAAS,CAAC,CAAC,CAAC,KAAC,KAAK,IAAC,GAAG,EAAC,GAAG,YAAE,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC,GAAS,CAAC,CAAC,CAAC,IAAI,EAEnE,CAAC,QAAQ,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CACzB,KAAC,IAAI,IAAC,IAAI,EAAC,IAAI,EAAC,IAAI,EAAC,OAAO,YACzB,SAAS,CAAC,mBAAmB,CAAC,GAC1B,CACR,CAAC,CAAC,CAAC,IAAI,IACF,CACT,CAAA;AACH,CAAC","sourcesContent":["import { declaredAliases } from '@elytracms/core/cms-core'\nimport type { CmsDocument, CollectionDef, FieldDef } from '@elytracms/core/cms-core'\nimport type { ReactNode } from 'react'\nimport { Select, Separator, Stack, Text } from '@elytracms/studio/ui'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * EC-284 + EC-285 — document-level metadata pane (the right inspector). Two\n * optional regions:\n *\n * 1. **Slot** (EC-284): a stable, named handle the site uses to find this document\n * (the main menu, the footer menu…) instead of which exact entry it is — shown\n * only when the collection declares alias slots.\n * 2. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,\n * author, publish date…) — document-level properties an editor sets *about* the\n * document rather than as body content. Rendered via the detail editor's own\n * field-row renderer, so they behave identically to a content field.\n *\n * Both are identity/metadata, not content, so they live here — off the content\n * tabs. The empty state shows only when there is neither a slot nor a sidebar field.\n */\nexport function DetailsPane({\n collection,\n doc,\n siblings,\n readOnly,\n fields,\n renderField,\n onAliasChange,\n}: {\n collection: CollectionDef\n doc: CmsDocument\n /** The other documents in this collection (to show which slots are taken). */\n siblings: readonly CmsDocument[]\n readOnly?: boolean\n /** The collection's `sidebar`-group fields, in order (EC-285). */\n fields: readonly FieldDef[]\n /** Render one field row — shared with the content groups so behaviour matches. */\n renderField: (field: FieldDef) => ReactNode\n onAliasChange: (alias: string | undefined) => void\n}) {\n const slots = declaredAliases(collection)\n const hasSlots = slots.length > 0\n const hasFields = fields.length > 0\n\n return (\n <Stack gap=\"4\" className=\"p-3\">\n {hasSlots ? (\n <Stack gap=\"1\">\n <Text size=\"xs\" weight=\"semibold\">\n {translate('cms.details.slot')}\n </Text>\n <Select\n value={doc.alias ?? ''}\n disabled={readOnly}\n aria-label={translate('cms.details.slot')}\n data-testid=\"alias-select\"\n onChange={(e) => onAliasChange(e.target.value === '' ? undefined : e.target.value)}\n >\n <option value=\"\">{translate('cms.details.noSlot')}</option>\n {slots.map((slot) => {\n const takenByOther = siblings.some((s) => s.id !== doc.id && s.alias === slot)\n const blocked = takenByOther && doc.alias !== slot\n return (\n <option key={slot} value={slot} disabled={blocked}>\n {slot}\n {blocked ? translate('cms.details.slotInUse') : ''}\n </option>\n )\n })}\n </Select>\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.details.slotHint')}\n </Text>\n </Stack>\n ) : null}\n\n {hasSlots && hasFields ? <Separator /> : null}\n\n {hasFields ? <Stack gap=\"4\">{fields.map(renderField)}</Stack> : null}\n\n {!hasSlots && !hasFields ? (\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.details.empty')}\n </Text>\n ) : null}\n </Stack>\n )\n}\n"]}
|
|
1
|
+
{"version":3,"file":"details-pane.js","sourceRoot":"","sources":["../../../src/components/cms/details-pane.tsx"],"names":[],"mappings":"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","sourcesContent":["import { declaredAliases, resolveUrlSegments } from '@elytracms/core/cms-core'\nimport type {\n CmsDocument,\n CollectionDef,\n FieldDef,\n LocaleConfig,\n RouteRecord,\n} from '@elytracms/core/cms-core'\nimport type { ReactNode } from 'react'\nimport { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router'\nimport { Row, Select, Separator, Stack, Text } from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/** 0.0.39 — routing context the Details pane needs to compose the document URL row. */\nexport interface DetailsUrlContext {\n projectId: string\n routes: readonly RouteRecord[]\n localeConfig: LocaleConfig\n locale: string\n}\n\n/**\n * EC-284 + EC-285 (+ 0.0.39 URL row) — document-level metadata pane (the right\n * inspector). Up to three optional regions:\n *\n * 1. **URL** (0.0.39): the full composed path as its own row — the EC-315 \"hybrid\n * split\" moves the composed URL out of the cramped slug field and up to the\n * document, where a URL is a first-class property. Ancestor-DOCUMENT segments\n * link to their editors; static route segments are plain text.\n * 2. **Slot** (EC-284): a stable, named handle the site uses to find this document\n * (the main menu, the footer menu…) instead of which exact entry it is — shown\n * only when the collection declares alias slots.\n * 3. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,\n * author, publish date…) — document-level properties an editor sets *about* the\n * document rather than as body content. Rendered via the detail editor's own\n * field-row renderer, so they behave identically to a content field.\n *\n * All three are identity/metadata, not content, so they live here — off the content\n * tabs. The empty state shows only when there is nothing at all to display.\n */\nexport function DetailsPane({\n collection,\n doc,\n siblings,\n readOnly,\n fields,\n renderField,\n onAliasChange,\n urlContext,\n}: {\n collection: CollectionDef\n doc: CmsDocument\n /** The other documents in this collection (to show which slots are taken). */\n siblings: readonly CmsDocument[]\n readOnly?: boolean\n /** The collection's `sidebar`-group fields, in order (EC-285). */\n fields: readonly FieldDef[]\n /** Render one field row — shared with the content groups so behaviour matches. */\n renderField: (field: FieldDef) => ReactNode\n onAliasChange: (alias: string | undefined) => void\n /** Routing context for the composed URL row (0.0.39); omitted for non-routable rails. */\n urlContext?: DetailsUrlContext\n}) {\n const slots = declaredAliases(collection)\n const hasSlots = slots.length > 0\n const hasFields = fields.length > 0\n const routable =\n urlContext !== undefined && urlContext.routes.some((r) => r.document?.collection === collection.id)\n\n return (\n <Stack gap=\"4\" className=\"p-3\">\n {routable && urlContext ? (\n <DocumentUrlRow collection={collection} doc={doc} siblings={siblings} context={urlContext} />\n ) : null}\n\n {routable && (hasSlots || hasFields) ? <Separator /> : null}\n\n {hasSlots ? (\n <Stack gap=\"1\">\n <Text size=\"xs\" weight=\"semibold\">\n {translate('cms.details.slot')}\n </Text>\n <Select\n value={doc.alias ?? ''}\n disabled={readOnly}\n aria-label={translate('cms.details.slot')}\n data-testid=\"alias-select\"\n onChange={(e) => onAliasChange(e.target.value === '' ? undefined : e.target.value)}\n >\n <option value=\"\">{translate('cms.details.noSlot')}</option>\n {slots.map((slot) => {\n const takenByOther = siblings.some((s) => s.id !== doc.id && s.alias === slot)\n const blocked = takenByOther && doc.alias !== slot\n return (\n <option key={slot} value={slot} disabled={blocked}>\n {slot}\n {blocked ? translate('cms.details.slotInUse') : ''}\n </option>\n )\n })}\n </Select>\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.details.slotHint')}\n </Text>\n </Stack>\n ) : null}\n\n {hasSlots && hasFields ? <Separator /> : null}\n\n {hasFields ? <Stack gap=\"4\">{fields.map(renderField)}</Stack> : null}\n\n {!routable && !hasSlots && !hasFields ? (\n <Text size=\"xs\" tone=\"muted\">\n {translate('cms.details.empty')}\n </Text>\n ) : null}\n </Stack>\n )\n}\n\n/**\n * The document-level URL row (0.0.39 — EC-315 \"hybrid split\"). The full composed path,\n * classified segment by segment: an ancestor-DOCUMENT segment is a link to that\n * document's editor (autosave keeps the current edit safe on navigate); a static\n * route segment (mount prefix, or `blog` in `/blog/:slug`) is plain text; this\n * document's own slug segment is highlighted as the current page. Draft-derived (it\n * updates as you type) — the header's published-gated \"View live\" stays the sole\n * open-live action, so the draft/live distinction is preserved here too.\n */\nfunction DocumentUrlRow({\n collection,\n doc,\n siblings,\n context,\n}: {\n collection: CollectionDef\n doc: CmsDocument\n siblings: readonly CmsDocument[]\n context: DetailsUrlContext\n}) {\n const segments = resolveUrlSegments(\n { routes: [...context.routes], localeConfig: context.localeConfig },\n collection,\n doc,\n { siblings, locale: context.locale },\n )\n return (\n <Stack gap=\"1\" data-testid=\"details-url\">\n <Text size=\"xs\" weight=\"semibold\">\n {translate('cms.details.url')}\n </Text>\n <Row gap=\"none\" align=\"center\" wrap className=\"font-mono text-xs text-muted-foreground\">\n {segments.length === 0 ? (\n <span aria-hidden>/</span>\n ) : (\n segments.map((seg, i) => (\n <span key={`${seg.text}-${i}`} className=\"inline-flex items-center\">\n <span aria-hidden className=\"text-muted-foreground/50\">\n /\n </span>\n {seg.kind === 'ancestor' && seg.documentId ? (\n <Link\n to={STUDIO_PROJECT_ROUTE}\n params={{ projectId: context.projectId, _splat: studioPath.cmsDocument(collection.id, seg.documentId) }}\n title={translate('cms.relation.openTitle', { title: seg.text })}\n data-testid=\"details-url-ancestor\"\n className=\"rounded px-0.5 text-primary hover:bg-primary/10 hover:underline\"\n >\n {seg.text}\n </Link>\n ) : (\n <span\n className={cn('px-0.5', seg.kind === 'current' && 'font-medium text-foreground')}\n title={seg.kind === 'current' ? translate('cms.details.urlCurrent') : undefined}\n >\n {seg.text}\n </span>\n )}\n </span>\n ))\n )}\n </Row>\n </Stack>\n )\n}\n"]}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CmsDocument, CollectionDef } from '@elytracms/core/cms-core';
|
|
2
|
+
/**
|
|
3
|
+
* EC-316 — the "Discard changes" header action's enablement, as one pure
|
|
4
|
+
* decision. Discarding reverts the draft to exactly the published content (via
|
|
5
|
+
* the EC-298 restore machinery), so it is offered iff there is a published
|
|
6
|
+
* version to revert TO and the draft actually differs from it:
|
|
7
|
+
*
|
|
8
|
+
* - `hidden` — the rail can't discard at all (no publish/versions
|
|
9
|
+
* capability) or the document isn't published: there is no
|
|
10
|
+
* published content to revert to, so no menu item.
|
|
11
|
+
* - `view-read-only`— viewing the Published snapshot (EC-226): the draft isn't
|
|
12
|
+
* the active view, so the item is disabled with a hint.
|
|
13
|
+
* - `loading` — the version history hasn't resolved yet; disabled.
|
|
14
|
+
* - `up-to-date` — the draft SEMANTICALLY equals the published snapshot
|
|
15
|
+
* (`documentsSemanticEqual`, the SAME comparator the
|
|
16
|
+
* Versions pane's publish gate uses, EC-298 — so the two
|
|
17
|
+
* surfaces never contradict); nothing to discard, disabled.
|
|
18
|
+
* - `ready` — a published version exists and the draft differs: enabled.
|
|
19
|
+
*/
|
|
20
|
+
export type DiscardDraftStatus = 'hidden' | 'view-read-only' | 'loading' | 'up-to-date' | 'ready';
|
|
21
|
+
export declare function discardDraftStatus(input: {
|
|
22
|
+
/** The rail can publish AND has version history (both required to discard). */
|
|
23
|
+
canDiscard: boolean;
|
|
24
|
+
/** The document IS published (version pointer set). */
|
|
25
|
+
published: boolean;
|
|
26
|
+
/** The editor is viewing the read-only Published snapshot (EC-226). */
|
|
27
|
+
viewReadOnly: boolean;
|
|
28
|
+
/** The pinned published version's snapshot; `undefined` while loading / none pinned. */
|
|
29
|
+
publishedSnapshot: CmsDocument | undefined;
|
|
30
|
+
collection: CollectionDef;
|
|
31
|
+
draft: CmsDocument;
|
|
32
|
+
}): DiscardDraftStatus;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { documentsSemanticEqual } from '@elytracms/core/content';
|
|
2
|
+
export function discardDraftStatus(input) {
|
|
3
|
+
if (!input.canDiscard || !input.published)
|
|
4
|
+
return 'hidden';
|
|
5
|
+
if (input.viewReadOnly)
|
|
6
|
+
return 'view-read-only';
|
|
7
|
+
if (input.publishedSnapshot === undefined)
|
|
8
|
+
return 'loading';
|
|
9
|
+
return documentsSemanticEqual(input.collection, input.publishedSnapshot, input.draft)
|
|
10
|
+
? 'up-to-date'
|
|
11
|
+
: 'ready';
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=discard-draft.js.map
|
|
@@ -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"]}
|