@elytracms/studio 0.0.41 → 0.0.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/builder/canvas.js +16 -2
- package/dist/components/builder/canvas.js.map +1 -1
- package/dist/components/cms/detail-editor.js +34 -25
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/details-pane.d.ts +11 -21
- package/dist/components/cms/details-pane.js +14 -31
- package/dist/components/cms/details-pane.js.map +1 -1
- package/dist/components/cms/document-url-path.d.ts +39 -0
- package/dist/components/cms/document-url-path.js +60 -0
- package/dist/components/cms/document-url-path.js.map +1 -0
- package/dist/components/cms/field-control.js +18 -12
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/inspector/inspector-sidebar.js +20 -3
- package/dist/components/inspector/inspector-sidebar.js.map +1 -1
- package/dist/lib/i18n/messages.en.js +6 -5
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,32 +1,24 @@
|
|
|
1
|
-
import type { CmsDocument, CollectionDef, FieldDef
|
|
1
|
+
import type { CmsDocument, CollectionDef, FieldDef } 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
|
-
}
|
|
10
3
|
/**
|
|
11
|
-
* EC-284 + EC-285
|
|
12
|
-
*
|
|
4
|
+
* EC-284 + EC-285 — document-level metadata pane (the right inspector). Up to two
|
|
5
|
+
* optional regions:
|
|
13
6
|
*
|
|
14
|
-
* 1. **
|
|
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
|
|
7
|
+
* 1. **Slot** (EC-284): a stable, named handle the site uses to find this document
|
|
19
8
|
* (the main menu, the footer menu…) instead of which exact entry it is — shown
|
|
20
9
|
* only when the collection declares alias slots.
|
|
21
|
-
*
|
|
10
|
+
* 2. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,
|
|
22
11
|
* author, publish date…) — document-level properties an editor sets *about* the
|
|
23
12
|
* document rather than as body content. Rendered via the detail editor's own
|
|
24
13
|
* field-row renderer, so they behave identically to a content field.
|
|
25
14
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
15
|
+
* Both are identity/metadata, not content, so they live here — off the content tabs.
|
|
16
|
+
* The composed URL is NOT here: 0.0.42 (round 3) moved it up to the detail-editor
|
|
17
|
+
* HEADER, under the record title, where a URL reads as a first-class property (a lone
|
|
18
|
+
* URL row on an otherwise-empty pane looked odd). The empty state shows only when
|
|
19
|
+
* there is nothing at all to display.
|
|
28
20
|
*/
|
|
29
|
-
export declare function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange,
|
|
21
|
+
export declare function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange, }: {
|
|
30
22
|
collection: CollectionDef;
|
|
31
23
|
doc: CmsDocument;
|
|
32
24
|
/** The other documents in this collection (to show which slots are taken). */
|
|
@@ -37,6 +29,4 @@ export declare function DetailsPane({ collection, doc, siblings, readOnly, field
|
|
|
37
29
|
/** Render one field row — shared with the content groups so behaviour matches. */
|
|
38
30
|
renderField: (field: FieldDef) => ReactNode;
|
|
39
31
|
onAliasChange: (alias: string | undefined) => void;
|
|
40
|
-
/** Routing context for the composed URL row (0.0.39); omitted for non-routable rails. */
|
|
41
|
-
urlContext?: DetailsUrlContext;
|
|
42
32
|
}): import("react").JSX.Element;
|
|
@@ -1,50 +1,33 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { declaredAliases
|
|
3
|
-
import {
|
|
4
|
-
import { Row, Select, Separator, Stack, Text } from '@elytracms/studio/ui';
|
|
5
|
-
import { cn } from '@elytracms/studio/lib/utils';
|
|
2
|
+
import { declaredAliases } from '@elytracms/core/cms-core';
|
|
3
|
+
import { Select, Separator, Stack, Text } from '@elytracms/studio/ui';
|
|
6
4
|
import { translate } from '@elytracms/studio/lib/i18n';
|
|
7
5
|
/**
|
|
8
|
-
* EC-284 + EC-285
|
|
9
|
-
*
|
|
6
|
+
* EC-284 + EC-285 — document-level metadata pane (the right inspector). Up to two
|
|
7
|
+
* optional regions:
|
|
10
8
|
*
|
|
11
|
-
* 1. **
|
|
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
|
|
9
|
+
* 1. **Slot** (EC-284): a stable, named handle the site uses to find this document
|
|
16
10
|
* (the main menu, the footer menu…) instead of which exact entry it is — shown
|
|
17
11
|
* only when the collection declares alias slots.
|
|
18
|
-
*
|
|
12
|
+
* 2. **Sidebar fields** (EC-285): the document's `sidebar`-group fields (slug,
|
|
19
13
|
* author, publish date…) — document-level properties an editor sets *about* the
|
|
20
14
|
* document rather than as body content. Rendered via the detail editor's own
|
|
21
15
|
* field-row renderer, so they behave identically to a content field.
|
|
22
16
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
17
|
+
* Both are identity/metadata, not content, so they live here — off the content tabs.
|
|
18
|
+
* The composed URL is NOT here: 0.0.42 (round 3) moved it up to the detail-editor
|
|
19
|
+
* HEADER, under the record title, where a URL reads as a first-class property (a lone
|
|
20
|
+
* URL row on an otherwise-empty pane looked odd). The empty state shows only when
|
|
21
|
+
* there is nothing at all to display.
|
|
25
22
|
*/
|
|
26
|
-
export function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange,
|
|
23
|
+
export function DetailsPane({ collection, doc, siblings, readOnly, fields, renderField, onAliasChange, }) {
|
|
27
24
|
const slots = declaredAliases(collection);
|
|
28
25
|
const hasSlots = slots.length > 0;
|
|
29
26
|
const hasFields = fields.length > 0;
|
|
30
|
-
|
|
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) => {
|
|
27
|
+
return (_jsxs(Stack, { gap: "4", className: "p-3", children: [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) => {
|
|
32
28
|
const takenByOther = siblings.some((s) => s.id !== doc.id && s.alias === slot);
|
|
33
29
|
const blocked = takenByOther && doc.alias !== slot;
|
|
34
30
|
return (_jsxs("option", { value: slot, disabled: blocked, children: [slot, blocked ? translate('cms.details.slotInUse') : ''] }, slot));
|
|
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, !
|
|
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}`)))) })] }));
|
|
31
|
+
})] }), _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] }));
|
|
49
32
|
}
|
|
50
33
|
//# sourceMappingURL=details-pane.js.map
|
|
@@ -1 +1 @@
|
|
|
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"]}
|
|
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;;;;;;;;;;;;;;;;;GAiBG;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). Up to 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 tabs.\n * The composed URL is NOT here: 0.0.42 (round 3) moved it up to the detail-editor\n * HEADER, under the record title, where a URL reads as a first-class property (a lone\n * URL row on an otherwise-empty pane looked odd). The empty state shows only when\n * 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}: {\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"]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CmsDocument, CollectionDef, LocaleConfig, RouteRecord, UrlSegment } from '@elytracms/core/cms-core';
|
|
2
|
+
/**
|
|
3
|
+
* One classified URL segment. An `ancestor` segment (with a `projectId` to build the
|
|
4
|
+
* link) navigates to that document's editor; a `static`/`current` segment — or an
|
|
5
|
+
* ancestor with no `projectId` (fixtures/no persistence) — is plain text, the current
|
|
6
|
+
* one highlighted. The single source of segment semantics for every surface.
|
|
7
|
+
*/
|
|
8
|
+
export declare function DocumentUrlSegment({ segment, projectId, collectionId, }: {
|
|
9
|
+
segment: UrlSegment;
|
|
10
|
+
projectId: string | undefined;
|
|
11
|
+
collectionId: string;
|
|
12
|
+
}): import("react").JSX.Element;
|
|
13
|
+
/**
|
|
14
|
+
* The detail-editor header's composed-URL row (0.0.42): a compact single line under
|
|
15
|
+
* the record title. The ancestor/static prefix ellipsizes gracefully on overflow (a
|
|
16
|
+
* real CSS ellipsis; the full path is the row tooltip) so the row NEVER wraps or
|
|
17
|
+
* pushes the header actions around; the leaf segment + open-live icon never shrink.
|
|
18
|
+
*
|
|
19
|
+
* `live` carries the published live-page affordance (header only), mirroring the
|
|
20
|
+
* retired "View live" button's gating exactly: `href` present → the leaf + external
|
|
21
|
+
* icon open the live page in a new tab; absent → the leaf is plain highlighted text
|
|
22
|
+
* and the icon is a muted, non-interactive hint carrying `title` (publish / set
|
|
23
|
+
* frontendUrl / loading). Omit `live` entirely on non-live surfaces.
|
|
24
|
+
*/
|
|
25
|
+
export declare function DocumentUrlPath({ projectId, collection, doc, siblings, routes, localeConfig, locale, live, className, }: {
|
|
26
|
+
projectId: string;
|
|
27
|
+
collection: CollectionDef;
|
|
28
|
+
doc: CmsDocument;
|
|
29
|
+
siblings: readonly CmsDocument[];
|
|
30
|
+
routes: readonly RouteRecord[];
|
|
31
|
+
localeConfig: LocaleConfig;
|
|
32
|
+
locale: string;
|
|
33
|
+
/** Published live-page affordance (header). Omitted → the leaf is plain text. */
|
|
34
|
+
live?: {
|
|
35
|
+
href?: string;
|
|
36
|
+
title: string;
|
|
37
|
+
};
|
|
38
|
+
className?: string;
|
|
39
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ExternalLink } from 'lucide-react';
|
|
3
|
+
import { resolveUrlSegments } from '@elytracms/core/cms-core';
|
|
4
|
+
import { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router';
|
|
5
|
+
import { Row } from '@elytracms/studio/ui';
|
|
6
|
+
import { cn } from '@elytracms/studio/lib/utils';
|
|
7
|
+
import { translate } from '@elytracms/studio/lib/i18n';
|
|
8
|
+
/**
|
|
9
|
+
* 0.0.42 — the shared composed-URL segment surface (EC-315 "hybrid split", round 3).
|
|
10
|
+
*
|
|
11
|
+
* A document's URL is a first-class property, classified segment by segment:
|
|
12
|
+
*
|
|
13
|
+
* - `ancestor` — a hierarchy-parent DOCUMENT's slug: a link to that document's
|
|
14
|
+
* editor (autosave keeps the current edit safe on navigate);
|
|
15
|
+
* - `static` — a mount prefix or route-pattern literal (`blog` in `/blog/:slug`);
|
|
16
|
+
* - `current` — this document's own slug segment (highlighted).
|
|
17
|
+
*
|
|
18
|
+
* ONE renderer feeds every surface — the detail-editor header path row
|
|
19
|
+
* ({@link DocumentUrlPath}) and the slug field's compact parent prefix (which reuses
|
|
20
|
+
* {@link DocumentUrlSegment}) — so a segment reads and behaves identically wherever
|
|
21
|
+
* it shows. Draft-derived (updates as you type); the published-gated live link on the
|
|
22
|
+
* header row is the sole open-live action, preserving the draft/live distinction.
|
|
23
|
+
*/
|
|
24
|
+
/** The `/` separator between segments (dimmed so the segments read as the content). */
|
|
25
|
+
function Separator() {
|
|
26
|
+
return (_jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: "/" }));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* One classified URL segment. An `ancestor` segment (with a `projectId` to build the
|
|
30
|
+
* link) navigates to that document's editor; a `static`/`current` segment — or an
|
|
31
|
+
* ancestor with no `projectId` (fixtures/no persistence) — is plain text, the current
|
|
32
|
+
* one highlighted. The single source of segment semantics for every surface.
|
|
33
|
+
*/
|
|
34
|
+
export function DocumentUrlSegment({ segment, projectId, collectionId, }) {
|
|
35
|
+
if (segment.kind === 'ancestor' && segment.documentId !== undefined && projectId !== undefined) {
|
|
36
|
+
return (_jsx(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId, _splat: studioPath.cmsDocument(collectionId, segment.documentId) }, title: translate('cms.relation.openTitle', { title: segment.text }), "data-testid": "doc-url-ancestor", className: "rounded px-0.5 text-primary hover:bg-primary/10 hover:underline", children: segment.text }));
|
|
37
|
+
}
|
|
38
|
+
return (_jsx("span", { className: cn('px-0.5', segment.kind === 'current' && 'font-medium text-foreground'), title: segment.kind === 'current' ? translate('cms.details.urlCurrent') : undefined, children: segment.text }));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The detail-editor header's composed-URL row (0.0.42): a compact single line under
|
|
42
|
+
* the record title. The ancestor/static prefix ellipsizes gracefully on overflow (a
|
|
43
|
+
* real CSS ellipsis; the full path is the row tooltip) so the row NEVER wraps or
|
|
44
|
+
* pushes the header actions around; the leaf segment + open-live icon never shrink.
|
|
45
|
+
*
|
|
46
|
+
* `live` carries the published live-page affordance (header only), mirroring the
|
|
47
|
+
* retired "View live" button's gating exactly: `href` present → the leaf + external
|
|
48
|
+
* icon open the live page in a new tab; absent → the leaf is plain highlighted text
|
|
49
|
+
* and the icon is a muted, non-interactive hint carrying `title` (publish / set
|
|
50
|
+
* frontendUrl / loading). Omit `live` entirely on non-live surfaces.
|
|
51
|
+
*/
|
|
52
|
+
export function DocumentUrlPath({ projectId, collection, doc, siblings, routes, localeConfig, locale, live, className, }) {
|
|
53
|
+
const segments = resolveUrlSegments({ routes: [...routes], localeConfig }, collection, doc, { siblings, locale });
|
|
54
|
+
const fullPath = segments.length === 0 ? '/' : `/${segments.map((s) => s.text).join('/')}`;
|
|
55
|
+
// The leaf is this page; everything before it is the (truncatable) context prefix.
|
|
56
|
+
const prefix = segments.slice(0, -1);
|
|
57
|
+
const leaf = segments[segments.length - 1];
|
|
58
|
+
return (_jsxs(Row, { gap: "none", align: "center", className: cn('min-w-0 flex-nowrap font-mono text-xs text-muted-foreground', className), title: fullPath, "data-testid": "header-url", children: [_jsx("span", { className: "block min-w-0 shrink overflow-hidden text-ellipsis whitespace-nowrap", children: prefix.map((seg, i) => (_jsxs("span", { children: [_jsx(Separator, {}), _jsx(DocumentUrlSegment, { segment: seg, projectId: projectId, collectionId: collection.id })] }, `${seg.text}-${i}`))) }), _jsxs("span", { className: "inline-flex shrink-0 items-center", children: [_jsx(Separator, {}), live?.href ? (_jsxs("a", { href: live.href, target: "_blank", rel: "noreferrer", title: live.title, "data-testid": "header-live-link", className: "inline-flex items-center gap-0.5 rounded px-0.5 font-medium text-foreground hover:bg-primary/10 hover:text-primary hover:underline", children: [leaf?.text ?? '', _jsx(ExternalLink, { className: "size-3", "aria-hidden": true })] })) : (_jsxs("span", { className: "inline-flex items-center gap-0.5 px-0.5", children: [leaf !== undefined ? (_jsx("span", { className: "font-medium text-foreground", title: translate('cms.details.urlCurrent'), children: leaf.text })) : null, live ? (_jsx("span", { title: live.title, "aria-label": live.title, "data-testid": "header-live-link-disabled", className: "inline-flex text-muted-foreground/40", children: _jsx(ExternalLink, { className: "size-3", "aria-hidden": true }) })) : null] }))] })] }));
|
|
59
|
+
}
|
|
60
|
+
//# sourceMappingURL=document-url-path.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"document-url-path.js","sourceRoot":"","sources":["../../../src/components/cms/document-url-path.tsx"],"names":[],"mappings":"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","sourcesContent":["import { ExternalLink } from 'lucide-react'\nimport { resolveUrlSegments } from '@elytracms/core/cms-core'\nimport type {\n CmsDocument,\n CollectionDef,\n LocaleConfig,\n RouteRecord,\n UrlSegment,\n} from '@elytracms/core/cms-core'\nimport { Link, STUDIO_PROJECT_ROUTE, studioPath } from '@elytracms/studio/router'\nimport { Row } from '@elytracms/studio/ui'\nimport { cn } from '@elytracms/studio/lib/utils'\nimport { translate } from '@elytracms/studio/lib/i18n'\n\n/**\n * 0.0.42 — the shared composed-URL segment surface (EC-315 \"hybrid split\", round 3).\n *\n * A document's URL is a first-class property, classified segment by segment:\n *\n * - `ancestor` — a hierarchy-parent DOCUMENT's slug: a link to that document's\n * editor (autosave keeps the current edit safe on navigate);\n * - `static` — a mount prefix or route-pattern literal (`blog` in `/blog/:slug`);\n * - `current` — this document's own slug segment (highlighted).\n *\n * ONE renderer feeds every surface — the detail-editor header path row\n * ({@link DocumentUrlPath}) and the slug field's compact parent prefix (which reuses\n * {@link DocumentUrlSegment}) — so a segment reads and behaves identically wherever\n * it shows. Draft-derived (updates as you type); the published-gated live link on the\n * header row is the sole open-live action, preserving the draft/live distinction.\n */\n\n/** The `/` separator between segments (dimmed so the segments read as the content). */\nfunction Separator() {\n return (\n <span aria-hidden className=\"text-muted-foreground/50\">\n /\n </span>\n )\n}\n\n/**\n * One classified URL segment. An `ancestor` segment (with a `projectId` to build the\n * link) navigates to that document's editor; a `static`/`current` segment — or an\n * ancestor with no `projectId` (fixtures/no persistence) — is plain text, the current\n * one highlighted. The single source of segment semantics for every surface.\n */\nexport function DocumentUrlSegment({\n segment,\n projectId,\n collectionId,\n}: {\n segment: UrlSegment\n projectId: string | undefined\n collectionId: string\n}) {\n if (segment.kind === 'ancestor' && segment.documentId !== undefined && projectId !== undefined) {\n return (\n <Link\n to={STUDIO_PROJECT_ROUTE}\n params={{ projectId, _splat: studioPath.cmsDocument(collectionId, segment.documentId) }}\n title={translate('cms.relation.openTitle', { title: segment.text })}\n data-testid=\"doc-url-ancestor\"\n className=\"rounded px-0.5 text-primary hover:bg-primary/10 hover:underline\"\n >\n {segment.text}\n </Link>\n )\n }\n return (\n <span\n className={cn('px-0.5', segment.kind === 'current' && 'font-medium text-foreground')}\n title={segment.kind === 'current' ? translate('cms.details.urlCurrent') : undefined}\n >\n {segment.text}\n </span>\n )\n}\n\n/**\n * The detail-editor header's composed-URL row (0.0.42): a compact single line under\n * the record title. The ancestor/static prefix ellipsizes gracefully on overflow (a\n * real CSS ellipsis; the full path is the row tooltip) so the row NEVER wraps or\n * pushes the header actions around; the leaf segment + open-live icon never shrink.\n *\n * `live` carries the published live-page affordance (header only), mirroring the\n * retired \"View live\" button's gating exactly: `href` present → the leaf + external\n * icon open the live page in a new tab; absent → the leaf is plain highlighted text\n * and the icon is a muted, non-interactive hint carrying `title` (publish / set\n * frontendUrl / loading). Omit `live` entirely on non-live surfaces.\n */\nexport function DocumentUrlPath({\n projectId,\n collection,\n doc,\n siblings,\n routes,\n localeConfig,\n locale,\n live,\n className,\n}: {\n projectId: string\n collection: CollectionDef\n doc: CmsDocument\n siblings: readonly CmsDocument[]\n routes: readonly RouteRecord[]\n localeConfig: LocaleConfig\n locale: string\n /** Published live-page affordance (header). Omitted → the leaf is plain text. */\n live?: { href?: string; title: string }\n className?: string\n}) {\n const segments = resolveUrlSegments(\n { routes: [...routes], localeConfig },\n collection,\n doc,\n { siblings, locale },\n )\n const fullPath = segments.length === 0 ? '/' : `/${segments.map((s) => s.text).join('/')}`\n // The leaf is this page; everything before it is the (truncatable) context prefix.\n const prefix = segments.slice(0, -1)\n const leaf = segments[segments.length - 1]\n\n return (\n <Row\n gap=\"none\"\n align=\"center\"\n className={cn('min-w-0 flex-nowrap font-mono text-xs text-muted-foreground', className)}\n title={fullPath}\n data-testid=\"header-url\"\n >\n {/* Prefix — locale/static/ancestor segments before the leaf. A block with inline\n children so `text-overflow: ellipsis` clips the ANCESTORS (not the leaf) when\n the path is too long; `min-w-0` lets it shrink inside the flex row. */}\n <span className=\"block min-w-0 shrink overflow-hidden text-ellipsis whitespace-nowrap\">\n {prefix.map((seg, i) => (\n <span key={`${seg.text}-${i}`}>\n <Separator />\n <DocumentUrlSegment segment={seg} projectId={projectId} collectionId={collection.id} />\n </span>\n ))}\n </span>\n {/* Leaf + live affordance — never shrinks, so the current page and the open-live\n icon stay visible even when the ancestors ellipsize. */}\n <span className=\"inline-flex shrink-0 items-center\">\n <Separator />\n {live?.href ? (\n <a\n href={live.href}\n target=\"_blank\"\n rel=\"noreferrer\"\n title={live.title}\n data-testid=\"header-live-link\"\n className=\"inline-flex items-center gap-0.5 rounded px-0.5 font-medium text-foreground hover:bg-primary/10 hover:text-primary hover:underline\"\n >\n {leaf?.text ?? ''}\n <ExternalLink className=\"size-3\" aria-hidden />\n </a>\n ) : (\n <span className=\"inline-flex items-center gap-0.5 px-0.5\">\n {leaf !== undefined ? (\n <span className=\"font-medium text-foreground\" title={translate('cms.details.urlCurrent')}>\n {leaf.text}\n </span>\n ) : null}\n {live ? (\n <span\n title={live.title}\n aria-label={live.title}\n data-testid=\"header-live-link-disabled\"\n className=\"inline-flex text-muted-foreground/40\"\n >\n <ExternalLink className=\"size-3\" aria-hidden />\n </span>\n ) : null}\n </span>\n )}\n </span>\n </Row>\n )\n}\n"]}
|
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
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, applySlugify, assetAcceptAttr, assetMatchesAccept, assetNode, assetSubfieldsOf, describeAccept, descendantIds, findSlugCollision, isFieldHidden, parentRelationField, readAssetId, resolveSlugPath, slugSourceValue, } from '@elytracms/core/cms-core';
|
|
5
|
+
import { BUILTIN_FIELD_CONTROLS, applySlugify, assetAcceptAttr, assetMatchesAccept, assetNode, assetSubfieldsOf, describeAccept, descendantIds, findSlugCollision, isFieldHidden, parentRelationField, readAssetId, resolveSlugPath, resolveUrlSegments, slugSourceValue, } from '@elytracms/core/cms-core';
|
|
6
6
|
import { resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
|
|
7
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';
|
|
8
8
|
import { cn } from '@elytracms/studio/lib/utils';
|
|
@@ -21,6 +21,7 @@ import { toRefArray } from './cell';
|
|
|
21
21
|
import { RelationPicker } from './relation-picker';
|
|
22
22
|
import { AssetPicker } from './asset-picker';
|
|
23
23
|
import { RichTextEditor } from './rich-text';
|
|
24
|
+
import { DocumentUrlSegment } from './document-url-path';
|
|
24
25
|
import { FieldRendererBoundary, FieldRendererIssue, useFieldRenderers, } from './field-renderer';
|
|
25
26
|
/**
|
|
26
27
|
* The document-editor field control. Resolves a project's custom renderer by the
|
|
@@ -182,6 +183,14 @@ function SlugField({ field, value, onChange, readOnly, id, }) {
|
|
|
182
183
|
const parts = draft && routable
|
|
183
184
|
? resolveSlugPath({ routes, localeConfig }, source.schema, draft, { siblings, locale })
|
|
184
185
|
: undefined;
|
|
186
|
+
// The classified context segments (0.0.42, round 3) — the same `resolveUrlSegments`
|
|
187
|
+
// the header URL row uses, so the slug prefix and the header path attribute each
|
|
188
|
+
// segment identically. Everything before this document's own `current` slug is the
|
|
189
|
+
// context prefix; its LAST segment is the immediate parent shown in the compact pill.
|
|
190
|
+
const contextSegments = draft && routable
|
|
191
|
+
? resolveUrlSegments({ routes, localeConfig }, source.schema, draft, { siblings, locale }).filter((s) => s.kind !== 'current')
|
|
192
|
+
: [];
|
|
193
|
+
const lastContext = contextSegments[contextSegments.length - 1];
|
|
185
194
|
// Default (sync) uniqueness — the first colliding sibling's title, or null.
|
|
186
195
|
const defaultCollisionTitle = useMemo(() => {
|
|
187
196
|
if (slugConfig?.unique || !draft)
|
|
@@ -247,17 +256,14 @@ function SlugField({ field, value, onChange, readOnly, id, }) {
|
|
|
247
256
|
}
|
|
248
257
|
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') }));
|
|
249
258
|
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;
|
|
250
|
-
// The compact, ellipsized read-only prefix (0.0.39 "hybrid split"):
|
|
251
|
-
// context segment (`…/produkte/`)
|
|
252
|
-
// sidebar
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
:
|
|
257
|
-
|
|
258
|
-
const compactPrefix = lastSegment ? `${prefixSegments.length > 1 ? '…/' : '/'}${lastSegment}/` : '/';
|
|
259
|
-
const prefixTitle = parts?.path ?? (prefixSegments.length > 0 ? `/${prefixSegments.join('/')}/` : '/');
|
|
260
|
-
return (_jsxs(Stack, { gap: "2", children: [parts ? (_jsxs(Row, { gap: "1", align: "center", "data-testid": "slug-composed-path", children: [_jsx("span", { className: "shrink-0 rounded bg-muted/40 px-1.5 py-1 font-mono text-xs text-muted-foreground ring-1 ring-inset ring-border", title: prefixTitle, "data-testid": "slug-prefix", children: compactPrefix }), input, regenerateButton] })) : (_jsxs(Row, { gap: "1", align: "center", children: [input, regenerateButton] })), 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] }));
|
|
259
|
+
// The compact, ellipsized read-only prefix (0.0.39 "hybrid split", round-3 clickable):
|
|
260
|
+
// only the LAST context segment (`…/produkte/`) so a deep path can't overflow the
|
|
261
|
+
// narrow Details sidebar — but that shown segment is now CLICKABLE with the same
|
|
262
|
+
// navigation semantics as the header URL row (an ancestor DOCUMENT links to its
|
|
263
|
+
// editor; a static route literal stays plain). The full composed path is the tooltip.
|
|
264
|
+
const prefixTitle = parts?.path ??
|
|
265
|
+
(contextSegments.length > 0 ? `/${contextSegments.map((s) => s.text).join('/')}/` : '/');
|
|
266
|
+
return (_jsxs(Stack, { gap: "2", children: [parts ? (_jsxs(Row, { gap: "1", align: "center", "data-testid": "slug-composed-path", children: [_jsx("span", { className: "inline-flex min-w-0 shrink-0 items-center rounded bg-muted/40 px-1.5 py-1 font-mono text-xs text-muted-foreground ring-1 ring-inset ring-border", title: prefixTitle, "data-testid": "slug-prefix", children: contextSegments.length === 0 ? (_jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: "/" })) : (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: contextSegments.length > 1 ? '…/' : '/' }), lastContext ? (_jsx(DocumentUrlSegment, { segment: lastContext, projectId: editor?.projectId, collectionId: source.collectionId })) : null, _jsx("span", { "aria-hidden": true, className: "text-muted-foreground/50", children: "/" })] })) }), input, regenerateButton] })) : (_jsxs(Row, { gap: "1", align: "center", children: [input, regenerateButton] })), 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] }));
|
|
261
267
|
}
|
|
262
268
|
function asRecord(value) {
|
|
263
269
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|