@elytracms/studio 0.0.26 → 0.0.28
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/assets.ts +19 -10
- package/convex/authority.ts +60 -0
- package/convex/cliTokens.ts +4 -4
- package/convex/cms.ts +12 -12
- package/convex/content.ts +10 -3
- package/convex/delivery.ts +22 -4
- package/convex/guard.ts +28 -11
- package/convex/members.ts +40 -7
- package/convex/redirects.ts +67 -0
- package/convex/references.ts +11 -11
- package/convex/schema.ts +30 -1
- package/convex/sync.ts +17 -10
- package/convex/users.ts +63 -0
- package/dist/components/app-views/default-modules.d.ts +33 -14
- package/dist/components/app-views/default-modules.js +59 -20
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +32 -0
- package/dist/components/app-views/module.js +16 -0
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/app-views/sidebar.d.ts +15 -1
- package/dist/components/app-views/sidebar.js +10 -0
- package/dist/components/app-views/sidebar.js.map +1 -1
- package/dist/components/cms/asset-picker.js +4 -4
- package/dist/components/cms/asset-picker.js.map +1 -1
- package/dist/components/cms/cell.js +16 -13
- package/dist/components/cms/cell.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +11 -26
- package/dist/components/cms/cms-store.js +25 -160
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/collection-table.d.ts +10 -0
- package/dist/components/cms/collection-table.js +72 -72
- package/dist/components/cms/collection-table.js.map +1 -1
- package/dist/components/cms/detail-editor.d.ts +14 -0
- package/dist/components/cms/detail-editor.js +224 -72
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-editor-context.d.ts +26 -0
- package/dist/components/cms/document-editor-context.js.map +1 -1
- package/dist/components/cms/editable-cell.js +12 -7
- package/dist/components/cms/editable-cell.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +2 -0
- package/dist/components/cms/field-control.js +40 -30
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/filter-builder.d.ts +18 -0
- package/dist/components/cms/filter-builder.js +47 -6
- package/dist/components/cms/filter-builder.js.map +1 -1
- package/dist/components/cms/import-export.js +5 -3
- package/dist/components/cms/import-export.js.map +1 -1
- package/dist/components/cms/reference-confirm.js +8 -9
- package/dist/components/cms/reference-confirm.js.map +1 -1
- package/dist/components/cms/relation-picker.js +13 -10
- package/dist/components/cms/relation-picker.js.map +1 -1
- package/dist/components/cms/use-cell-edit.js +13 -7
- package/dist/components/cms/use-cell-edit.js.map +1 -1
- package/dist/components/cms/use-document-versions.d.ts +16 -0
- package/dist/components/cms/use-document-versions.js +31 -0
- package/dist/components/cms/use-document-versions.js.map +1 -0
- package/dist/components/cms/version-compare-text.d.ts +30 -0
- package/dist/components/cms/version-compare-text.js +113 -0
- package/dist/components/cms/version-compare-text.js.map +1 -0
- package/dist/components/cms/version-compare.d.ts +12 -0
- package/dist/components/cms/version-compare.js +271 -0
- package/dist/components/cms/version-compare.js.map +1 -0
- package/dist/components/cms/versions-pane.d.ts +3 -0
- package/dist/components/cms/versions-pane.js +47 -38
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/cms/view-bar.d.ts +21 -6
- package/dist/components/cms/view-bar.js +42 -57
- package/dist/components/cms/view-bar.js.map +1 -1
- package/dist/components/collection-view/cms-collection-view-source.d.ts +15 -0
- package/dist/components/collection-view/cms-collection-view-source.js +158 -0
- package/dist/components/collection-view/cms-collection-view-source.js.map +1 -0
- package/dist/components/collection-view/collection-view-context.d.ts +10 -0
- package/dist/components/collection-view/collection-view-context.js +26 -0
- package/dist/components/collection-view/collection-view-context.js.map +1 -0
- package/dist/components/current-user-menu.js +8 -1
- package/dist/components/current-user-menu.js.map +1 -1
- package/dist/components/redirects/redirects-collection-view-source.d.ts +18 -0
- package/dist/components/redirects/redirects-collection-view-source.js +114 -0
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -0
- package/dist/components/redirects/redirects-module.d.ts +18 -0
- package/dist/components/redirects/redirects-module.js +43 -0
- package/dist/components/redirects/redirects-module.js.map +1 -0
- package/dist/components/redirects/redirects-schema.d.ts +10 -0
- package/dist/components/redirects/redirects-schema.js +42 -0
- package/dist/components/redirects/redirects-schema.js.map +1 -0
- package/dist/components/redirects/redirects-store.d.ts +46 -0
- package/dist/components/redirects/redirects-store.js +124 -0
- package/dist/components/redirects/redirects-store.js.map +1 -0
- package/dist/components/references/references-panel.js +2 -2
- package/dist/components/references/references-panel.js.map +1 -1
- package/dist/components/section-nav.js +14 -5
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/users/user-search.d.ts +8 -0
- package/dist/components/users/user-search.js +13 -0
- package/dist/components/users/user-search.js.map +1 -0
- package/dist/components/users/users-collection-view-source.d.ts +28 -0
- package/dist/components/users/users-collection-view-source.js +157 -0
- package/dist/components/users/users-collection-view-source.js.map +1 -0
- package/dist/components/users/users-module.d.ts +23 -0
- package/dist/components/users/users-module.js +61 -0
- package/dist/components/users/users-module.js.map +1 -0
- package/dist/components/users/users-schema.d.ts +19 -0
- package/dist/components/users/users-schema.js +38 -0
- package/dist/components/users/users-schema.js.map +1 -0
- package/dist/components/users/users-store.d.ts +40 -0
- package/dist/components/users/users-store.js +87 -0
- package/dist/components/users/users-store.js.map +1 -0
- package/dist/lib/cms-views/index.d.ts +4 -5
- package/dist/lib/cms-views/index.js +4 -5
- package/dist/lib/cms-views/index.js.map +1 -1
- package/dist/lib/cms-views/view-model.d.ts +48 -1
- package/dist/lib/cms-views/view-model.js +77 -1
- package/dist/lib/cms-views/view-model.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +251 -0
- package/dist/lib/collection-view/collection-view-source.js +10 -0
- package/dist/lib/collection-view/collection-view-source.js.map +1 -0
- package/dist/lib/operations/session-hydration.d.ts +7 -0
- package/dist/lib/operations/session-hydration.js +7 -1
- package/dist/lib/operations/session-hydration.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.d.ts +5 -1
- package/dist/lib/persistence/convex-adapter.js +148 -1
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +1 -8
- package/dist/lib/workspace/index.js +3 -9
- package/dist/lib/workspace/index.js.map +1 -1
- package/dist/router/match.d.ts +4 -0
- package/dist/router/match.js +8 -0
- package/dist/router/match.js.map +1 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +2 -0
- package/dist/ui/index.js.map +1 -1
- package/dist/ui/popover.d.ts +13 -0
- package/dist/ui/popover.js +17 -0
- package/dist/ui/popover.js.map +1 -0
- package/dist/ui/row-item.d.ts +16 -0
- package/dist/ui/row-item.js +18 -0
- package/dist/ui/row-item.js.map +1 -0
- package/package.json +7 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Link, STUDIO_PROJECT_ROUTE, studioPath, useNavigate } from '@elytracms/studio/router';
|
|
3
|
-
import { CircleAlert, CircleCheck, ExternalLink, Eye, EyeOff, History, Languages, Loader2, Lock, MoreHorizontal, Tag, Trash2, } from 'lucide-react';
|
|
3
|
+
import { Check, CircleAlert, CircleCheck, ExternalLink, Eye, EyeOff, GitCompareArrows, History, Languages, Link2, Loader2, Lock, MoreHorizontal, Tag, Trash2, X, } from 'lucide-react';
|
|
4
4
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
5
5
|
import { FIELD_WIDTH_SPANS, SIDEBAR_FIELD_GROUP, clearHiddenValues, documentKey, groupOrder, groupSectionOf, hasAliases, hiddenGroupNames, isBlocksField, isFieldHidden, isFieldLocalized, isSingleton, resolveDocumentForLocale, resolveDocumentTitle, resolveField, sidebarFields, titleFieldOf, staticFieldHidden, urlForDocument, } from '@elytracms/core/cms-core';
|
|
6
6
|
import { blockRootsOf } from '@elytracms/core/project-graph';
|
|
@@ -13,33 +13,79 @@ import { parseDocumentFieldPath } from '@elytracms/studio/lib/references';
|
|
|
13
13
|
import { FallbackChip, fallbackTitle } from './cell';
|
|
14
14
|
import { FieldControl } from './field-control';
|
|
15
15
|
import { CompositionFieldEditor } from '../builder/composition-field-editor';
|
|
16
|
-
import { fieldLabel
|
|
16
|
+
import { fieldLabel } from './cms-store';
|
|
17
|
+
import { useCmsCollectionViewSource } from '../collection-view/cms-collection-view-source';
|
|
18
|
+
import { CollectionViewProvider, useCollectionView } from '../collection-view/collection-view-context';
|
|
19
|
+
import { resolveFieldAccess } from '@elytracms/studio/lib/collection-view/collection-view-source';
|
|
17
20
|
import { DocStateBadge } from './doc-state';
|
|
21
|
+
import { UnsavedChangesGuard } from '../guards';
|
|
18
22
|
import { ReferenceConfirmDialog } from './reference-confirm';
|
|
19
23
|
import { ReferencesPanel } from '../references/references-panel';
|
|
20
24
|
import { InspectorSidebar } from '../inspector/inspector-sidebar';
|
|
21
25
|
import { DocumentEditorProvider } from './document-editor-context';
|
|
22
26
|
import { VersionsPane } from './versions-pane';
|
|
27
|
+
import { VersionCompare } from './version-compare';
|
|
23
28
|
import { IssuesPane } from './issues-pane';
|
|
24
29
|
import { DetailsPane } from './details-pane';
|
|
25
30
|
import { useSetBreadcrumb } from '../breadcrumb-context';
|
|
26
31
|
import { resolveDeploymentFrontendUrl, resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
|
|
27
32
|
import { usePublishedDocuments } from '@elytracms/studio/lib/cms/published-documents';
|
|
33
|
+
/** The conventional slug field name (mirrors `url-for-document`'s `SLUG_FIELD`). */
|
|
34
|
+
const SLUG_FIELD = 'slug';
|
|
28
35
|
export function DetailEditor({ projectId, collectionId, documentId, focusPath, }) {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
36
|
+
// AD-19: the edit shell is a portable surface over a CollectionViewSource, just
|
|
37
|
+
// like the list. `DetailEditor` is the CMS entry point — it builds the CMS source
|
|
38
|
+
// and hands it to the portable {@link PortableDetailEditor} so `EditorBody` (and
|
|
39
|
+
// the shared field controls / pickers) render over the source, never `useCms()`.
|
|
40
|
+
const source = useCmsCollectionViewSource(collectionId);
|
|
41
|
+
if (!source) {
|
|
42
|
+
return (_jsx(DocumentNotFound, { projectId: projectId, listPath: studioPath.cmsCollection(collectionId), label: collectionId }));
|
|
43
|
+
}
|
|
44
|
+
return (_jsx(PortableDetailEditor, { source: source, projectId: projectId, documentId: documentId, focusPath: focusPath }));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The portable edit shell (EC-240 / AD-19): resolve the record by id from ANY
|
|
48
|
+
* {@link CollectionViewSource}, then render the shared {@link EditorBody} over it.
|
|
49
|
+
* The CMS entry point ({@link DetailEditor}) and the users rail both mount this —
|
|
50
|
+
* routing (the "back to list" target, the not-found link) is rail-owned via
|
|
51
|
+
* `source.listPath`, so the shell never hard-codes the CMS route.
|
|
52
|
+
*/
|
|
53
|
+
export function PortableDetailEditor({ source, projectId, documentId, focusPath, }) {
|
|
54
|
+
const doc = source.get({ collection: source.collectionId, id: documentId });
|
|
32
55
|
// EC-274: singleton opening is owned by `SingletonGate` (it resolves the lone
|
|
33
56
|
// document and materializes it). The detail editor just renders the document it
|
|
34
57
|
// is given by id — singletons need no special case here.
|
|
35
|
-
if (!
|
|
36
|
-
return (_jsx(
|
|
58
|
+
if (!doc) {
|
|
59
|
+
return (_jsx(DocumentNotFound, { projectId: projectId, listPath: source.listPath, label: source.schema.form?.label ?? source.collectionId }));
|
|
37
60
|
}
|
|
38
|
-
return (_jsx(EditorBody, { projectId: projectId, collection:
|
|
61
|
+
return (_jsx(CollectionViewProvider, { source: source, children: _jsx(EditorBody, { projectId: projectId, collection: source.schema, doc: doc, focusPath: focusPath }, `${source.collectionId}/${documentId}`) }));
|
|
62
|
+
}
|
|
63
|
+
/** The record-not-found state — a heading + a rail-owned "back to list" link. */
|
|
64
|
+
function DocumentNotFound({ projectId, listPath, label, }) {
|
|
65
|
+
return (_jsx("div", { className: "grid h-full place-items-center p-8", children: _jsxs(Stack, { gap: "3", align: "center", className: "text-center", children: [_jsx(Heading, { level: 2, size: "h2", children: "Not found" }), listPath !== undefined ? (_jsxs(Link, { to: STUDIO_PROJECT_ROUTE, params: { projectId, _splat: listPath }, className: "text-sm text-primary hover:underline", children: ["Back to ", label] })) : null] }) }));
|
|
39
66
|
}
|
|
40
67
|
function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
41
|
-
const
|
|
68
|
+
const source = useCollectionView();
|
|
42
69
|
const navigate = useNavigate();
|
|
70
|
+
// AD-19: capability flags gate whole shell regions AND their method groups, so
|
|
71
|
+
// the SAME edit shell serves a non-publishing / non-versioned / single-locale
|
|
72
|
+
// rail (the users rail, EC-240) without a bespoke editor. A false flag hides its
|
|
73
|
+
// region entirely — no dead controls, no reads fired on that region's behalf.
|
|
74
|
+
const canPublish = source.capabilities.publish;
|
|
75
|
+
const canVersions = source.capabilities.versions;
|
|
76
|
+
const canLocales = source.capabilities.locales;
|
|
77
|
+
// References is the CMS content-graph surface (the reference index tracks the
|
|
78
|
+
// publishable document rail). A non-publishing rail (users, future redirects) is
|
|
79
|
+
// not part of that graph, so its References tab would be structurally empty —
|
|
80
|
+
// gate it off. A dedicated `references` capability can split this later if a
|
|
81
|
+
// non-publishing-but-referenceable rail appears (YAGNI today).
|
|
82
|
+
const showReferences = canPublish;
|
|
83
|
+
// Principal-based field access (EC-240, AD-18): `edit|read|hidden`, resolved
|
|
84
|
+
// per field for the current principal. Default-open — a source with no policy
|
|
85
|
+
// derives it from `isFieldReadOnly`. Access depends on the record's identity, not
|
|
86
|
+
// its edited values (content-conditional hiding is the separate `isHidden` axis),
|
|
87
|
+
// so it keys off the stable `doc`, never the live draft.
|
|
88
|
+
const accessOf = useCallback((field) => resolveFieldAccess(source, field, doc), [source, doc]);
|
|
43
89
|
// EC-217: a singleton collection has no list — there is nowhere to go "back"
|
|
44
90
|
// to, and its one document is fixed (never deleted). The header drops the
|
|
45
91
|
// back-crumb/id line and the Delete action accordingly.
|
|
@@ -47,7 +93,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
47
93
|
const [draft, setDraft] = useState(() => structuredClone(doc));
|
|
48
94
|
// The studio's ambient active locale (EC-176): the form displays and edits
|
|
49
95
|
// exactly this locale — switched via the shared LocaleSwitcher, never per field.
|
|
50
|
-
const locale =
|
|
96
|
+
const locale = source.activeLocale;
|
|
51
97
|
// Bumped when a version restore replaces the draft out-of-band, to remount the
|
|
52
98
|
// (uncontrolled) canvas editor from the restored value (EC-224).
|
|
53
99
|
const [restoreNonce, setRestoreNonce] = useState(0);
|
|
@@ -59,7 +105,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
59
105
|
// EC-226: the draft/published VIEW perspective. Draft = the editable working
|
|
60
106
|
// draft; Published = the pinned published snapshot, rendered read-only. Only
|
|
61
107
|
// meaningful when the document is published.
|
|
62
|
-
const published =
|
|
108
|
+
const published = source.publishing?.isPublished(ref) ?? false;
|
|
63
109
|
const [perspective, setPerspective] = useState('draft');
|
|
64
110
|
const [publishedSnapshot, setPublishedSnapshot] = useState({ status: 'loading' });
|
|
65
111
|
// Snap back to Draft if the document is unpublished while viewing Published.
|
|
@@ -70,10 +116,12 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
70
116
|
// Load the pinned published snapshot when viewing Published (fresh each switch,
|
|
71
117
|
// so it reflects the current pin after a re-publish). `loadDocumentVersions` is
|
|
72
118
|
// a stable callback, so this only re-runs on a perspective/ref change.
|
|
73
|
-
const loadVersions =
|
|
119
|
+
const loadVersions = source.versioning?.load;
|
|
74
120
|
useEffect(() => {
|
|
75
121
|
if (perspective !== 'published')
|
|
76
122
|
return;
|
|
123
|
+
if (!loadVersions)
|
|
124
|
+
return;
|
|
77
125
|
let cancelled = false;
|
|
78
126
|
setPublishedSnapshot({ status: 'loading' });
|
|
79
127
|
void (async () => {
|
|
@@ -96,38 +144,48 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
96
144
|
const viewDoc = viewReadOnly && publishedSnapshot.status === 'ready' ? publishedSnapshot.doc : draft;
|
|
97
145
|
const publishedPending = viewReadOnly && publishedSnapshot.status !== 'ready';
|
|
98
146
|
const dirty = JSON.stringify(draft) !== JSON.stringify(doc);
|
|
99
|
-
const schemaIssues = useMemo(() =>
|
|
147
|
+
const schemaIssues = useMemo(() => source.validate(draft), [source, draft]);
|
|
100
148
|
// EC-300/AD-16: composition embeds (hard references) whose target isn't published
|
|
101
149
|
// would render empty on the live site. Surface them as errors in the Issues panel,
|
|
102
150
|
// but they DON'T gate the Publish button (schema errors do) — publishing is
|
|
103
151
|
// confirm-through, so the editor can publish the page and the reusable together
|
|
104
152
|
// knowingly. The publish guard below catches them.
|
|
105
|
-
const referenceIssues = useMemo(() => unpublishedEmbedIssues(collection, draft,
|
|
153
|
+
const referenceIssues = useMemo(() => unpublishedEmbedIssues(collection, draft, source), [collection, draft, source]);
|
|
106
154
|
const issues = useMemo(() => [...schemaIssues, ...referenceIssues], [schemaIssues, referenceIssues]);
|
|
107
155
|
const errorCount = schemaIssues.filter((i) => i.severity === 'error').length;
|
|
108
156
|
const hasErrors = errorCount > 0;
|
|
109
157
|
const hasUnpublishedRefs = referenceIssues.length > 0;
|
|
110
|
-
// ---
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
158
|
+
// --- Save mode (EC-225 autosave · EC-240 explicit) -------------------------
|
|
159
|
+
// A publishing rail AUTOSAVES: the draft persists 1s after the last edit and
|
|
160
|
+
// publish is the deliberate commit. A non-publishing rail (users, EC-240) has no
|
|
161
|
+
// publish step, so silent apply is the wrong default for authority data — it
|
|
162
|
+
// SAVES EXPLICITLY instead: edits stay in the draft until the Save button, and a
|
|
163
|
+
// navigation guard protects unsaved changes. (Role stays in `members`, applied
|
|
164
|
+
// on Save and enforced immediately — never behind a publish pointer.)
|
|
165
|
+
// Refs let the unmount flush read the latest values without re-subscribing.
|
|
166
|
+
const explicitSave = !canPublish;
|
|
114
167
|
const draftRef = useRef(draft);
|
|
115
168
|
draftRef.current = draft;
|
|
116
169
|
const dirtyRef = useRef(dirty);
|
|
117
170
|
dirtyRef.current = dirty;
|
|
118
|
-
const
|
|
119
|
-
|
|
171
|
+
const sourceRef = useRef(source);
|
|
172
|
+
sourceRef.current = source;
|
|
173
|
+
const explicitSaveRef = useRef(explicitSave);
|
|
174
|
+
explicitSaveRef.current = explicitSave;
|
|
120
175
|
useEffect(() => {
|
|
176
|
+
if (explicitSave)
|
|
177
|
+
return; // explicit rails persist only via the Save button
|
|
121
178
|
if (!dirty)
|
|
122
179
|
return;
|
|
123
|
-
const timer = window.setTimeout(() =>
|
|
180
|
+
const timer = window.setTimeout(() => sourceRef.current.save(draftRef.current), 1000);
|
|
124
181
|
return () => window.clearTimeout(timer);
|
|
125
|
-
}, [dirty, draft]);
|
|
126
|
-
// Flush a still-pending edit on unmount (navigating away inside the
|
|
127
|
-
// window) so nothing is lost.
|
|
182
|
+
}, [dirty, draft, explicitSave]);
|
|
183
|
+
// Flush a still-pending AUTOSAVE edit on unmount (navigating away inside the
|
|
184
|
+
// debounce window) so nothing is lost. Explicit rails never flush silently —
|
|
185
|
+
// their `UnsavedChangesGuard` catches the navigation instead. Unmount-only.
|
|
128
186
|
useEffect(() => () => {
|
|
129
|
-
if (dirtyRef.current)
|
|
130
|
-
|
|
187
|
+
if (!explicitSaveRef.current && dirtyRef.current)
|
|
188
|
+
sourceRef.current.save(draftRef.current);
|
|
131
189
|
}, []);
|
|
132
190
|
const errorsByField = useMemo(() => {
|
|
133
191
|
const map = new Map();
|
|
@@ -145,7 +203,9 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
145
203
|
// (and loads) only when selected, and unmounts on leave, so normal editing
|
|
146
204
|
// never triggers reference fetching. Appended after the collection's content tabs.
|
|
147
205
|
const REFERENCES_TAB = 'references';
|
|
148
|
-
|
|
206
|
+
// EC-298: the version-compare view is a sticky framework tab (kept open while
|
|
207
|
+
// you tab to the real fields; closed only by its ✕).
|
|
208
|
+
const COMPARE_TAB = 'compare';
|
|
149
209
|
const [activeTab, setActiveTab] = useState(tabs[0]);
|
|
150
210
|
// EC-285: the reserved `sidebar` group is document-level metadata (slug, author…)
|
|
151
211
|
// — it renders in the Details inspector pane, not as a content tab, so it is
|
|
@@ -168,6 +228,33 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
168
228
|
// `sidebar`-group field can open the Details pane (where it renders). Undefined
|
|
169
229
|
// = uncontrolled (the sidebar defaults to its first pane).
|
|
170
230
|
const [inspectorPaneId, setInspectorPaneId] = useState();
|
|
231
|
+
// EC-298: the open version comparison. The Compare tab is STICKY — opening it
|
|
232
|
+
// sets the selection and focuses the tab; tabbing to the real fields keeps it
|
|
233
|
+
// open (the selection persists) so an editor can flip between diff and fields;
|
|
234
|
+
// only its ✕ closes it. `activeTab === COMPARE_TAB` is when the diff is visible.
|
|
235
|
+
const [compare, setCompare] = useState(null);
|
|
236
|
+
const startCompare = useCallback((base) => {
|
|
237
|
+
setCompare({ base, target: 'draft' });
|
|
238
|
+
setActiveTab(COMPARE_TAB);
|
|
239
|
+
setInspectorPaneId('versions');
|
|
240
|
+
}, []);
|
|
241
|
+
const setCompareBase = useCallback((base) => {
|
|
242
|
+
setCompare((c) => (c ? { ...c, base } : { base, target: 'draft' }));
|
|
243
|
+
}, []);
|
|
244
|
+
const setCompareTarget = useCallback((target) => {
|
|
245
|
+
setCompare((c) => (c ? { ...c, target } : c));
|
|
246
|
+
}, []);
|
|
247
|
+
const exitCompare = useCallback(() => {
|
|
248
|
+
setCompare(null);
|
|
249
|
+
setActiveTab((prev) => (prev === COMPARE_TAB ? tabs[0] : prev));
|
|
250
|
+
}, [tabs]);
|
|
251
|
+
// A content tab (schema tab or References). Selecting one leaves the Compare
|
|
252
|
+
// view VISIBLE-wise but keeps the comparison open (sticky) — the diff is one
|
|
253
|
+
// click away on its still-present tab chip.
|
|
254
|
+
const renderContentTab = (tab) => {
|
|
255
|
+
const active = tab === activeTab;
|
|
256
|
+
return (_jsx("button", { type: "button", "aria-pressed": active, onClick: () => setActiveTab(tab), className: cn('rounded-md px-2.5 py-1 text-xs font-medium capitalize transition-colors', active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted'), children: tab }, tab));
|
|
257
|
+
};
|
|
171
258
|
// Focus a field by name: switch to its tab (and locale, when given), scroll the
|
|
172
259
|
// control into view, and ring-highlight it. Shared by the reference deep-link
|
|
173
260
|
// (`focusPath`, EC-172) and the Issues pane's jump-to-field (EC-283).
|
|
@@ -185,7 +272,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
185
272
|
const fieldTabs = collection.form?.tabs ?? ['main'];
|
|
186
273
|
setActiveTab(field.form?.tab ?? fieldTabs[0]);
|
|
187
274
|
}
|
|
188
|
-
if (localeHint !== undefined &&
|
|
275
|
+
if (localeHint !== undefined && source.localeConfig.locales.includes(localeHint)) {
|
|
189
276
|
setStudioLocale(localeHint);
|
|
190
277
|
}
|
|
191
278
|
setHighlightedField(field.name);
|
|
@@ -200,7 +287,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
200
287
|
}, 60);
|
|
201
288
|
const clearTimer = window.setTimeout(() => setHighlightedField(null), 3200);
|
|
202
289
|
focusTimersRef.current = [scrollTimer, clearTimer];
|
|
203
|
-
}, [collection,
|
|
290
|
+
}, [collection, source.localeConfig]);
|
|
204
291
|
useEffect(() => () => focusTimersRef.current.forEach((t) => window.clearTimeout(t)), []);
|
|
205
292
|
// Reference deep links (EC-172): `?field=<index path>` pinpoints the exact field.
|
|
206
293
|
useEffect(() => {
|
|
@@ -221,7 +308,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
221
308
|
const explicit = viewDoc.localized?.[locale]?.[field.name];
|
|
222
309
|
if (explicit !== undefined)
|
|
223
310
|
return explicit;
|
|
224
|
-
return resolveField(collection, viewDoc, field.name, locale,
|
|
311
|
+
return resolveField(collection, viewDoc, field.name, locale, source.localeConfig).value;
|
|
225
312
|
}
|
|
226
313
|
return viewDoc.values[field.name];
|
|
227
314
|
}
|
|
@@ -249,7 +336,9 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
249
336
|
// draft changes, so a field shows/hides live as the editor types.
|
|
250
337
|
const conditionValues = { ...draft.values, ...(draft.localized?.[locale] ?? {}) };
|
|
251
338
|
const isHidden = (field) => isFieldHidden(field, { values: conditionValues });
|
|
252
|
-
|
|
339
|
+
// Visible = not content-hidden AND not principal-hidden (EC-240 access === 'hidden').
|
|
340
|
+
const isFieldShown = (field) => !isHidden(field) && accessOf(field) !== 'hidden';
|
|
341
|
+
const visibleSidebarFields = sidebarFieldDefs.filter(isFieldShown);
|
|
253
342
|
// EC-306: presentational group sections hidden right now (static or predicate) —
|
|
254
343
|
// skipped entirely, like a hidden field. Recomputed live as the draft changes.
|
|
255
344
|
const hiddenGroups = hiddenGroupNames(collection, { values: conditionValues });
|
|
@@ -261,15 +350,20 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
261
350
|
function renderFieldRow(field) {
|
|
262
351
|
if (isHidden(field))
|
|
263
352
|
return null;
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
353
|
+
// EC-240: principal-based access. `hidden` drops the row entirely (this
|
|
354
|
+
// principal can't see the field); `read` renders it read-only. Distinct from
|
|
355
|
+
// the content-conditional `isHidden` above and the view-level read-only
|
|
356
|
+
// (Published perspective) — the control honors all three.
|
|
357
|
+
const access = accessOf(field);
|
|
358
|
+
if (access === 'hidden')
|
|
359
|
+
return null;
|
|
360
|
+
const fieldLevelReadOnly = access === 'read';
|
|
267
361
|
const fieldReadOnly = viewReadOnly || fieldLevelReadOnly;
|
|
268
362
|
const localized = isFieldLocalized(collection, field.name);
|
|
269
363
|
// Fallback visibility (EC-176): the control DISPLAYS the fallback value, and
|
|
270
364
|
// this marker says so on the exact field.
|
|
271
365
|
const fallback = localized
|
|
272
|
-
? displayedFieldFallback(collection, viewDoc, field.name, locale,
|
|
366
|
+
? displayedFieldFallback(collection, viewDoc, field.name, locale, source.localeConfig)
|
|
273
367
|
: null;
|
|
274
368
|
// The variant was created in this editing session (the saved document doesn't
|
|
275
369
|
// have it yet): editing a fallback-shown value is an explicit translation act.
|
|
@@ -278,10 +372,10 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
278
372
|
localized &&
|
|
279
373
|
hasLocaleVariant(draft, field.name, locale) &&
|
|
280
374
|
!hasLocaleVariant(doc, field.name, locale);
|
|
281
|
-
return (
|
|
375
|
+
return (_jsxs(Field, { className: cn('rounded-md transition-shadow', highlightedField === field.name && 'bg-primary/5 p-2 ring-2 ring-primary/70'), label: _jsxs(Row, { gap: "2", children: [_jsx("span", { children: fieldLabel(field) }), field.validation?.required ? _jsx("span", { className: "text-destructive", children: "*" }) : null, localized ? _jsx(Badge, { variant: "outline", children: locale }) : null, fallback ? (_jsxs(Badge, { variant: "warning", title: fallbackTitle(fallback), "data-testid": `field-fallback-${field.name}`, children: ["showing ", localeChipLabel(fallback.sourceLocale)] })) : null, newlyTranslated ? (_jsxs(Badge, { variant: "success", "data-testid": `field-translated-${field.name}`, children: [_jsx(Languages, { className: "size-3" }), " translated to ", localeChipLabel(locale)] })) : null, fieldLevelReadOnly ? (_jsxs(Badge, { variant: "outline", children: [_jsx(Lock, { className: "size-3" }), " read-only"] })) : null, _jsx(Spacer, {})] }), htmlFor: `f-${field.name}`, hint: fallback ? (_jsxs(_Fragment, { children: ["Showing the ", localeChipLabel(fallback.sourceLocale), " (fallback) value \u2014 edit to translate to ", localeChipLabel(locale), ". Clearing the ", localeChipLabel(locale), " text falls back again."] })) : (field.form?.description), error: fieldReadOnly ? undefined : errorsByField.get(field.name), children: [_jsx(FieldControl, { id: `f-${field.name}`, field: field, value: getValue(field), onChange: (v) => setValue(field, v), readOnly: fieldReadOnly }), field.name === SLUG_FIELD && routableCollection ? _jsx(PathPreview, { url: draftUrl }) : null] }, field.name));
|
|
282
376
|
}
|
|
283
377
|
function save() {
|
|
284
|
-
|
|
378
|
+
source.save(draft);
|
|
285
379
|
}
|
|
286
380
|
// EC-224: a version restore swaps the working draft for an older snapshot; the
|
|
287
381
|
// open editor adopts it so the form/canvas re-render from the restored content
|
|
@@ -326,7 +420,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
326
420
|
id: 'details',
|
|
327
421
|
label: 'Details',
|
|
328
422
|
icon: _jsx(Tag, { className: "size-3.5" }),
|
|
329
|
-
render: () => (_jsx(DetailsPane, { collection: collection, doc: draft, siblings:
|
|
423
|
+
render: () => (_jsx(DetailsPane, { collection: collection, doc: draft, siblings: source.documents(), readOnly: viewReadOnly, fields: visibleSidebarFields, renderField: renderFieldRow, onAliasChange: (alias) => setDraft((d) => {
|
|
330
424
|
const next = { ...d };
|
|
331
425
|
if (alias === undefined)
|
|
332
426
|
delete next.alias;
|
|
@@ -336,9 +430,13 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
336
430
|
}) })),
|
|
337
431
|
}
|
|
338
432
|
: null;
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
433
|
+
// Versions is capability-gated (AD-19): a non-versioned rail (users) never mounts
|
|
434
|
+
// the pane, so no version read fires on its records.
|
|
435
|
+
const trailingPanes = [
|
|
436
|
+
...(detailsPane ? [detailsPane] : []),
|
|
437
|
+
...(canVersions ? [versionsPane] : []),
|
|
438
|
+
issuesPane,
|
|
439
|
+
];
|
|
342
440
|
// EC-268: the Publish button is its own indicator — pending while the publish
|
|
343
441
|
// round-trips, a brief success, and an error toast if it rejects at transport.
|
|
344
442
|
const [publishState, setPublishState] = useState('idle');
|
|
@@ -367,16 +465,16 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
367
465
|
if (cleaned !== draft)
|
|
368
466
|
setDraft(cleaned);
|
|
369
467
|
if (cleaned !== draft || dirty)
|
|
370
|
-
|
|
468
|
+
source.save(cleaned);
|
|
371
469
|
// Publish snapshots the draft as a version and pins it (EC-224). The live
|
|
372
470
|
// row stays a continuous draft — published-ness is the version pointer
|
|
373
|
-
// (`
|
|
471
|
+
// (`source.publishing.isPublished`). Pre-gated by `disabled={hasErrors}`; the optimistic
|
|
374
472
|
// flip reconciles against the backend (EC-225), so a logical rejection the
|
|
375
473
|
// gate doesn't cover (schema divergence) self-corrects the badge. A hard
|
|
376
474
|
// failure (transport) rejects the flush — surface it loudly (EC-268).
|
|
377
475
|
setPublishState('pending');
|
|
378
476
|
try {
|
|
379
|
-
await
|
|
477
|
+
await source.publishing?.publish(ref);
|
|
380
478
|
setPublishState('success');
|
|
381
479
|
setTimeout(() => setPublishState('idle'), 1800);
|
|
382
480
|
}
|
|
@@ -388,26 +486,28 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
388
486
|
function handleConfirmedAction() {
|
|
389
487
|
if (confirmAction === 'unpublish') {
|
|
390
488
|
setConfirmAction(null);
|
|
391
|
-
void
|
|
489
|
+
void source.publishing?.unpublish(ref);
|
|
392
490
|
return;
|
|
393
491
|
}
|
|
394
492
|
if (confirmAction === 'delete') {
|
|
395
493
|
setConfirmAction(null);
|
|
396
|
-
|
|
494
|
+
source.remove(ref);
|
|
495
|
+
// Return to the rail's list (rail-owned `listPath`), or the project index if
|
|
496
|
+
// the rail has no list surface (AD-19 — never a hard-coded CMS route).
|
|
397
497
|
void navigate({
|
|
398
498
|
to: STUDIO_PROJECT_ROUTE,
|
|
399
|
-
params: { projectId, _splat: studioPath.
|
|
499
|
+
params: { projectId, _splat: source.listPath ?? studioPath.index() },
|
|
400
500
|
});
|
|
401
501
|
}
|
|
402
502
|
}
|
|
403
503
|
// While the published snapshot is pending, don't derive the title from the
|
|
404
504
|
// draft (it must not read as the published title) — fall back to the id.
|
|
405
|
-
const title = publishedPending ? '' : primaryTitle(viewDoc, collection, locale,
|
|
505
|
+
const title = publishedPending ? '' : primaryTitle(viewDoc, collection, locale, source.localeConfig);
|
|
406
506
|
// EC-242: the untranslated-title chip applies only to a field-backed title; a
|
|
407
507
|
// function title pattern has no single source field to mark as a fallback.
|
|
408
508
|
const titleFallback = publishedPending || typeof collection.titleField === 'function'
|
|
409
509
|
? null
|
|
410
|
-
: displayedFieldFallback(collection, viewDoc, titleFieldOf(collection)?.name ?? 'title', locale,
|
|
510
|
+
: displayedFieldFallback(collection, viewDoc, titleFieldOf(collection)?.name ?? 'title', locale, source.localeConfig);
|
|
411
511
|
// EC-214: the document is the active record — contribute the collection (a
|
|
412
512
|
// link back to its list) and the document title to the global header
|
|
413
513
|
// breadcrumb, replacing the editor's own back-link + collection/id line. A
|
|
@@ -415,18 +515,21 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
415
515
|
// title tracks the live draft so a rename updates the crumb immediately.
|
|
416
516
|
const breadcrumbItems = useMemo(() => {
|
|
417
517
|
const collectionLabel = collection.form?.label ?? collection.id;
|
|
418
|
-
|
|
518
|
+
// A singleton (no list) or a rail with no list surface contributes a single,
|
|
519
|
+
// non-linked crumb; otherwise the collection crumb links back to its list via
|
|
520
|
+
// the rail-owned `listPath` (never a hard-coded CMS route — AD-19).
|
|
521
|
+
if (singleton || source.listPath === undefined)
|
|
419
522
|
return [{ label: collectionLabel }];
|
|
420
|
-
const docTitle = primaryTitle(draft, collection, locale,
|
|
523
|
+
const docTitle = primaryTitle(draft, collection, locale, source.localeConfig) || doc.id;
|
|
421
524
|
return [
|
|
422
525
|
{
|
|
423
526
|
label: collectionLabel,
|
|
424
527
|
to: STUDIO_PROJECT_ROUTE,
|
|
425
|
-
params: { projectId, _splat:
|
|
528
|
+
params: { projectId, _splat: source.listPath },
|
|
426
529
|
},
|
|
427
530
|
{ label: docTitle },
|
|
428
531
|
];
|
|
429
|
-
}, [singleton, collection, draft, locale,
|
|
532
|
+
}, [singleton, collection, draft, locale, source.localeConfig, source.listPath, doc.id, projectId]);
|
|
430
533
|
useSetBreadcrumb(breadcrumbItems);
|
|
431
534
|
// EC-205/EC-216: the LIVE URL derives strictly from PUBLISHED data — the
|
|
432
535
|
// document's pinned published snapshot and its published ancestors — never the
|
|
@@ -434,7 +537,7 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
434
537
|
// pool is exactly what the live site serves, so a published doc resolves to the
|
|
435
538
|
// URL visitors actually get; `undefined` until the doc is published (and the
|
|
436
539
|
// pool has loaded). The collection being routable gates the disabled hint below.
|
|
437
|
-
const publishedDocs = usePublishedDocuments(
|
|
540
|
+
const publishedDocs = usePublishedDocuments(source.projectId, collection.id);
|
|
438
541
|
const routableCollection = useMemo(() => resolveDeploymentRoutes().some((route) => route.document?.collection === collection.id), [collection.id]);
|
|
439
542
|
const liveUrl = useMemo(() => {
|
|
440
543
|
const published = publishedDocs.byId.get(doc.id);
|
|
@@ -443,9 +546,24 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
443
546
|
return urlForDocument({
|
|
444
547
|
routes: resolveDeploymentRoutes(),
|
|
445
548
|
frontendUrl: resolveDeploymentFrontendUrl(),
|
|
446
|
-
localeConfig:
|
|
549
|
+
localeConfig: source.localeConfig,
|
|
447
550
|
}, collection, published, { siblings: [...publishedDocs.byId.values()], locale });
|
|
448
|
-
}, [publishedDocs, collection, doc.id, locale,
|
|
551
|
+
}, [publishedDocs, collection, doc.id, locale, source.localeConfig]);
|
|
552
|
+
// EC-246d: the DRAFT path — derived from the current edit (slug + parent chain),
|
|
553
|
+
// not the published pool — so the slug field shows the URL forming live, before
|
|
554
|
+
// the first publish. Read-only: the slug is the single editable source, the path
|
|
555
|
+
// is always derived (never a second stored field). `undefined` until a slug
|
|
556
|
+
// resolves a route (a fresh hierarchy page with no slug has no path yet).
|
|
557
|
+
const draftUrl = useMemo(() => urlForDocument({ routes: resolveDeploymentRoutes(), localeConfig: source.localeConfig }, collection, viewDoc, { siblings: source.documents(), locale }), [collection, viewDoc, locale, source]);
|
|
558
|
+
// EC-240: hide the tab bar when there is nothing to navigate — a single content
|
|
559
|
+
// tab with no References/Compare (the users rail). When framework tabs ARE
|
|
560
|
+
// present we KEEP the content tab(s) so there is always a way back to the form;
|
|
561
|
+
// only a genuinely single-tab surface drops them. And drop the whole lens row
|
|
562
|
+
// when it would be empty (no tabs to show, no locale/perspective controls), so a
|
|
563
|
+
// bare rail (users) shows no orphaned row.
|
|
564
|
+
const hasFrameworkTabs = showReferences || compare !== null;
|
|
565
|
+
const showContentTabs = tabs.length > 1 || hasFrameworkTabs;
|
|
566
|
+
const showLensRow = showContentTabs || canLocales || canPublish;
|
|
449
567
|
return (_jsx(DocumentEditorProvider, { value: {
|
|
450
568
|
projectId,
|
|
451
569
|
collection,
|
|
@@ -456,17 +574,37 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
456
574
|
viewReadOnly,
|
|
457
575
|
save,
|
|
458
576
|
replaceDraft,
|
|
459
|
-
|
|
577
|
+
compare,
|
|
578
|
+
compareActive: activeTab === COMPARE_TAB,
|
|
579
|
+
startCompare,
|
|
580
|
+
setCompareBase,
|
|
581
|
+
setCompareTarget,
|
|
582
|
+
exitCompare,
|
|
583
|
+
}, children: _jsxs("div", { className: cn('flex h-full min-h-0 flex-col', viewReadOnly && 'bg-info/[0.04]'), children: [_jsxs("div", { className: "shrink-0 border-b border-border px-4 py-3 sm:px-6", children: [_jsxs(Row, { gap: "3", children: [_jsxs(Row, { gap: "2", align: "center", className: "min-w-0", children: [_jsx(Heading, { level: 1, size: "h2", className: "truncate", children: singleton ? collection.form?.label ?? collection.id : title || doc.id }), singleton ? null : _jsx(FallbackChip, { fallback: titleFallback, testId: "doc-title-fallback" })] }), _jsx(Spacer, {}), _jsx(Text, { size: "xs", tone: "muted", className: "whitespace-nowrap", "data-testid": "autosave-status", children: source.lastError
|
|
584
|
+
? 'Save failed'
|
|
585
|
+
: explicitSave
|
|
586
|
+
? dirty
|
|
587
|
+
? 'Unsaved changes'
|
|
588
|
+
: 'Saved'
|
|
589
|
+
: dirty
|
|
590
|
+
? 'Saving…'
|
|
591
|
+
: 'Saved' }), canPublish ? _jsx(DocStateBadge, { published: published }) : null, routableCollection ? (liveUrl?.href ? (_jsx(Button, { asChild: true, variant: "outline", size: "sm", title: `Open ${liveUrl.path}`, children: _jsxs("a", { href: liveUrl.href, target: "_blank", rel: "noreferrer", "data-testid": "view-live", children: [_jsx(ExternalLink, {}), " View live"] }) })) : (_jsxs(Button, { variant: "outline", size: "sm", disabled: true, "data-testid": "view-live-disabled", title: publishedDocs.status === 'loading'
|
|
460
592
|
? 'Loading the live URL…'
|
|
461
|
-
: !
|
|
593
|
+
: !published
|
|
462
594
|
? 'Publish to view the live page'
|
|
463
|
-
: 'Set frontendUrl in elytra.config.ts to enable', children: [_jsx(ExternalLink, {}), " View live"] }))) : null, _jsx(Button, { size: "sm", onClick: handlePublish, disabled: hasErrors || viewReadOnly || publishState === 'pending', title: viewReadOnly
|
|
595
|
+
: 'Set frontendUrl in elytra.config.ts to enable', children: [_jsx(ExternalLink, {}), " View live"] }))) : null, canPublish ? (_jsx(Button, { size: "sm", onClick: handlePublish, disabled: hasErrors || viewReadOnly || publishState === 'pending', title: viewReadOnly
|
|
464
596
|
? 'Switch to Draft to publish'
|
|
465
597
|
: hasErrors
|
|
466
598
|
? `Fix ${errorCount} ${errorCount === 1 ? 'issue' : 'issues'} to publish`
|
|
467
|
-
: undefined, children: publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " Publishing\u2026"] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " Published"] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " Publish"] })) }),
|
|
599
|
+
: undefined, children: publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " Publishing\u2026"] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " Published"] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " Publish"] })) })) : null, explicitSave ? (_jsxs(Button, { size: "sm", onClick: save, disabled: !dirty, "data-testid": "doc-save", children: [_jsx(Check, {}), " Save"] })) : null, published || !singleton ? (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { asChild: true, children: _jsx(Button, { variant: "outline", size: "icon-sm", "aria-label": "More actions", children: _jsx(MoreHorizontal, {}) }) }), _jsxs(DropdownMenuContent, { align: "end", children: [published ? (_jsxs(DropdownMenuItem, { onClick: () => setConfirmAction('unpublish'), children: [_jsx(EyeOff, {}), " Unpublish"] })) : null, singleton ? null : (_jsxs(DropdownMenuItem, { "data-testid": "doc-delete", className: "text-destructive", onClick: () => setConfirmAction('delete'), children: [_jsx(Trash2, {}), " Delete"] }))] })] })) : null] }), showLensRow ? (_jsxs(Row, { gap: "3", className: "mt-3", wrap: true, children: [_jsxs(Row, { gap: "1", align: "center", children: [showContentTabs ? tabs.map((tab) => renderContentTab(tab)) : null, showReferences ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "mx-1 h-4 w-px bg-border", "aria-hidden": true }), renderContentTab(REFERENCES_TAB)] })) : null, compare ? (_jsxs("span", { className: cn('inline-flex items-center gap-1 rounded-md px-2.5 py-1 text-xs font-medium transition-colors', activeTab === COMPARE_TAB
|
|
600
|
+
? 'bg-accent text-accent-foreground'
|
|
601
|
+
: 'text-muted-foreground hover:bg-muted'), children: [_jsxs("button", { type: "button", onClick: () => setActiveTab(COMPARE_TAB), "aria-pressed": activeTab === COMPARE_TAB, className: "inline-flex items-center gap-1", children: [_jsx(GitCompareArrows, { className: "size-3", "aria-hidden": true }), " Compare"] }), _jsx("button", { type: "button", onClick: exitCompare, "aria-label": "Close compare", className: "ml-0.5 rounded p-0.5 hover:bg-background/40", children: _jsx(X, { className: "size-3" }) })] })) : null] }), _jsx(Spacer, {}), canLocales ? _jsx(LocaleSwitcher, {}) : null, canPublish ? (_jsxs(Row, { gap: "px", className: "rounded-md bg-muted/60 p-0.5", children: [_jsx(PerspectiveButton, { label: "Draft", active: perspective === 'draft', onClick: () => setPerspective('draft') }), _jsx(PerspectiveButton, { label: "Published", active: perspective === 'published', accent: "info", disabled: !published, title: published ? undefined : 'Publish this document to view its published version', onClick: () => setPerspective('published') })] })) : null] })) : null] }), viewReadOnly ? (_jsxs(Row, { gap: "2", className: "shrink-0 border-b border-info/30 bg-info/10 px-4 py-1.5 text-info sm:px-6", "data-testid": "published-view-banner", children: [_jsx(Lock, { className: "size-3.5 shrink-0", "aria-hidden": true }), _jsx(Text, { size: "xs", className: "text-info", children: "Viewing the published version \u2014 read-only." }), _jsx(Spacer, {}), _jsx("button", { type: "button", onClick: () => setPerspective('draft'), className: "text-xs font-medium underline-offset-2 hover:underline", children: "Switch to Draft" })] })) : null, publishedPending ? (_jsx("div", { className: "grid min-h-0 flex-1 place-items-center p-8", children: _jsx(Text, { size: "sm", tone: "muted", children: publishedSnapshot.status === 'loading'
|
|
468
602
|
? 'Loading the published version…'
|
|
469
|
-
: 'No published version found for this document.' }) })) :
|
|
603
|
+
: 'No published version found for this document.' }) })) : activeTab === COMPARE_TAB && compare ? (
|
|
604
|
+
// EC-298: the version compare view takes over the main area, with the
|
|
605
|
+
// shared inspector (Versions pane) staying on as the navigator — an
|
|
606
|
+
// in-app surface, not a modal, so arbitrary versions can be browsed.
|
|
607
|
+
_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto", children: _jsx(VersionCompare, {}) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })) : canvasField ? (_jsx("div", { className: "min-h-0 flex-1 overflow-hidden", children: _jsx(CompositionFieldEditor, { field: canvasField, value: getValue(canvasField), onChange: (v) => setValue(canvasField, v), readOnly: viewReadOnly || source.isFieldReadOnly(canvasField), trailingPanes: trailingPanes }, `${canvasField.name}:${restoreNonce}:${perspective}`) })) : activeTab === REFERENCES_TAB ? (
|
|
470
608
|
// EC-269: mounted only while this tab is active → reference data loads on
|
|
471
609
|
// demand and is torn down on leave (no fetching during normal editing).
|
|
472
610
|
_jsx("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: _jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto w-full max-w-2xl", children: _jsx(ReferencesPanel, { projectId: projectId, source: { type: 'document', id: documentKey(ref) }, target: { type: 'document', id: documentKey(ref) } }) }) }) })) : (_jsxs("div", { className: "flex min-h-0 flex-1 overflow-hidden", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-auto p-4 sm:p-6", children: _jsx("div", { className: "mx-auto w-full max-w-2xl", children: _jsx(Stack, { gap: "6", children: groups.map((group) => {
|
|
@@ -474,14 +612,18 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
474
612
|
// entirely (its fields are also exempt from validation).
|
|
475
613
|
if (hiddenGroups.has(group.name))
|
|
476
614
|
return null;
|
|
477
|
-
// EC-273: drop a fieldset whose every field is
|
|
478
|
-
//
|
|
479
|
-
|
|
615
|
+
// EC-273/EC-240: drop a fieldset whose every field is hidden — whether
|
|
616
|
+
// content-conditionally or for this principal — so the form never
|
|
617
|
+
// shows an empty section header.
|
|
618
|
+
const visible = group.fields.filter(isFieldShown);
|
|
480
619
|
if (visible.length === 0)
|
|
481
620
|
return null;
|
|
482
621
|
const section = group.section;
|
|
483
622
|
return (_jsx(CollapsibleSection, { collapsible: section?.collapsible, defaultOpen: section?.defaultOpen, title: _jsx(Text, { size: "sm", weight: "semibold", className: section?.title ? undefined : 'capitalize', children: section?.title ?? group.name }), children: _jsx("div", { className: "grid grid-cols-1 gap-4 p-4 md:grid-cols-12", children: visible.map((field) => (_jsx("div", { className: cn('col-span-1', fieldWidthColSpan(field.form?.width)), children: renderFieldRow(field) }, field.name))) }) }, group.name));
|
|
484
|
-
}) }) }) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })),
|
|
623
|
+
}) }) }) }), _jsx(InspectorSidebar, { panes: trailingPanes, activeId: inspectorPaneId, onActiveChange: setInspectorPaneId, className: "w-[320px] shrink-0 border-l border-border" })] })), explicitSave ? (_jsx(UnsavedChangesGuard, { when: dirty, onSave: () => {
|
|
624
|
+
save();
|
|
625
|
+
return true;
|
|
626
|
+
}, description: "You have unsaved changes to this record. Save them before leaving, or discard them." })) : null, confirmAction !== null ? (_jsx(ReferenceConfirmDialog, { projectId: projectId, action: confirmAction, targets: confirmTargets, open: true, onOpenChange: (open) => {
|
|
485
627
|
if (!open)
|
|
486
628
|
setConfirmAction(null);
|
|
487
629
|
}, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open)
|
|
@@ -498,8 +640,8 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
498
640
|
* confirm-through (not blocked), so these feed the Issues panel + the publish
|
|
499
641
|
* confirmation, never the button's disabled gate. No reuse capability → none.
|
|
500
642
|
*/
|
|
501
|
-
function unpublishedEmbedIssues(collection, draft,
|
|
502
|
-
const capability = resolveReuseCapability(getStudioRegistry().registry.list(),
|
|
643
|
+
function unpublishedEmbedIssues(collection, draft, source) {
|
|
644
|
+
const capability = resolveReuseCapability(getStudioRegistry().registry.list(), source.collections);
|
|
503
645
|
if (!capability)
|
|
504
646
|
return [];
|
|
505
647
|
const issues = [];
|
|
@@ -512,21 +654,21 @@ function unpublishedEmbedIssues(collection, draft, cms) {
|
|
|
512
654
|
if (seen.has(key))
|
|
513
655
|
continue;
|
|
514
656
|
seen.add(key);
|
|
515
|
-
const exists =
|
|
516
|
-
if (exists &&
|
|
657
|
+
const exists = source.get(ref) !== undefined;
|
|
658
|
+
if (exists && (source.publishing?.isPublished(ref) ?? false))
|
|
517
659
|
continue;
|
|
518
|
-
issues.push(unpublishedEmbedIssue(collection, draft, field.name, ref, exists,
|
|
660
|
+
issues.push(unpublishedEmbedIssue(collection, draft, field.name, ref, exists, source));
|
|
519
661
|
}
|
|
520
662
|
}
|
|
521
663
|
return issues;
|
|
522
664
|
}
|
|
523
665
|
/** One unpublished/missing reuse-embed issue (EC-300). */
|
|
524
|
-
function unpublishedEmbedIssue(collection, draft, field, ref, exists,
|
|
666
|
+
function unpublishedEmbedIssue(collection, draft, field, ref, exists, source) {
|
|
525
667
|
return {
|
|
526
668
|
code: exists ? 'unpublished-relation-target' : 'unknown-relation-target',
|
|
527
669
|
severity: 'error',
|
|
528
670
|
message: exists
|
|
529
|
-
? `Embeds the reusable “${
|
|
671
|
+
? `Embeds the reusable “${source.titleOf(ref)}”, which isn’t published — it will be empty on the live site until you publish it.`
|
|
530
672
|
: `Embeds a reusable that no longer exists (“${ref.id}”) — fix or remove the embed.`,
|
|
531
673
|
path: [field],
|
|
532
674
|
collectionId: collection.id,
|
|
@@ -546,6 +688,16 @@ function PerspectiveButton({ label, active, accent, disabled, title, onClick, })
|
|
|
546
688
|
: 'bg-background text-foreground shadow-sm'
|
|
547
689
|
: 'text-muted-foreground hover:text-foreground'), children: label }));
|
|
548
690
|
}
|
|
691
|
+
/**
|
|
692
|
+
* EC-246d: the slug's derived route path, shown read-only directly under the slug
|
|
693
|
+
* input. The slug is the single editable source; the path is composed by the route
|
|
694
|
+
* resolver (`urlForDocument`) from slug + parent chain and never stored. Draft-aware,
|
|
695
|
+
* so it updates as the editor types. Until a slug resolves a route (a fresh
|
|
696
|
+
* hierarchy page), a hint explains where the URL comes from.
|
|
697
|
+
*/
|
|
698
|
+
function PathPreview({ url }) {
|
|
699
|
+
return (_jsxs(Row, { gap: "1", className: "text-xs text-muted-foreground", "data-testid": "slug-path-preview", children: [_jsx(Link2, { className: "size-3 shrink-0", "aria-hidden": true }), url ? (_jsxs(Text, { as: "span", size: "xs", tone: "muted", className: "min-w-0 truncate", title: url.path, children: ["Served at ", _jsx("span", { className: "font-medium text-foreground/80", children: url.path })] })) : (_jsx(Text, { as: "span", size: "xs", tone: "muted", children: "The URL is derived from the slug (and parent) \u2014 set a slug to define it." }))] }));
|
|
700
|
+
}
|
|
549
701
|
function groupFields(fields, collection) {
|
|
550
702
|
const order = groupOrder(collection.form);
|
|
551
703
|
const byGroup = new Map();
|