@elytracms/studio 0.0.43 → 0.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/convex/cms.ts +59 -21
- package/convex/content.ts +6 -3
- package/convex/delivery.ts +8 -5
- package/convex/triggers.ts +8 -1
- package/dist/components/app-views/studio-config.d.ts +12 -0
- package/dist/components/app-views/studio-config.js.map +1 -1
- package/dist/components/assets/assets-collection-view-source.d.ts +13 -1
- package/dist/components/assets/assets-collection-view-source.js +42 -51
- package/dist/components/assets/assets-collection-view-source.js.map +1 -1
- package/dist/components/cms/cms-store.js +9 -11
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/detail-editor.js +110 -338
- package/dist/components/cms/detail-editor.js.map +1 -1
- package/dist/components/cms/document-staging.d.ts +8 -3
- package/dist/components/cms/document-staging.js +81 -21
- package/dist/components/cms/document-staging.js.map +1 -1
- package/dist/components/cms/use-compare-selection.d.ts +27 -0
- package/dist/components/cms/use-compare-selection.js +29 -0
- package/dist/components/cms/use-compare-selection.js.map +1 -0
- package/dist/components/cms/use-discard-controller.d.ts +44 -0
- package/dist/components/cms/use-discard-controller.js +51 -0
- package/dist/components/cms/use-discard-controller.js.map +1 -0
- package/dist/components/cms/use-embed-inspector-host.d.ts +12 -0
- package/dist/components/cms/use-embed-inspector-host.js +17 -0
- package/dist/components/cms/use-embed-inspector-host.js.map +1 -0
- package/dist/components/cms/use-field-focus.d.ts +38 -0
- package/dist/components/cms/use-field-focus.js +70 -0
- package/dist/components/cms/use-field-focus.js.map +1 -0
- package/dist/components/cms/use-live-url.d.ts +41 -0
- package/dist/components/cms/use-live-url.js +55 -0
- package/dist/components/cms/use-live-url.js.map +1 -0
- package/dist/components/cms/use-publish-controller.d.ts +49 -0
- package/dist/components/cms/use-publish-controller.js +102 -0
- package/dist/components/cms/use-publish-controller.js.map +1 -0
- package/dist/components/cms/use-published-perspective.d.ts +67 -0
- package/dist/components/cms/use-published-perspective.js +67 -0
- package/dist/components/cms/use-published-perspective.js.map +1 -0
- package/dist/components/cms/use-restore-version.d.ts +57 -0
- package/dist/components/cms/use-restore-version.js +66 -0
- package/dist/components/cms/use-restore-version.js.map +1 -0
- package/dist/components/cms/use-save-mode.d.ts +35 -0
- package/dist/components/cms/use-save-mode.js +54 -0
- package/dist/components/cms/use-save-mode.js.map +1 -0
- package/dist/components/cms/version-compare.js +13 -16
- package/dist/components/cms/version-compare.js.map +1 -1
- package/dist/components/cms/versions-pane.js +21 -19
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/components/transfer-dialog.d.ts +11 -6
- package/dist/components/components/transfer-dialog.js +98 -25
- package/dist/components/components/transfer-dialog.js.map +1 -1
- package/dist/components/components/transfer.d.ts +74 -8
- package/dist/components/components/transfer.js +87 -2
- package/dist/components/components/transfer.js.map +1 -1
- package/dist/components/rail-store-status.d.ts +7 -5
- package/dist/components/rail-store-status.js +12 -9
- package/dist/components/rail-store-status.js.map +1 -1
- package/dist/components/rail-view-source.d.ts +111 -0
- package/dist/components/rail-view-source.js +139 -0
- package/dist/components/rail-view-source.js.map +1 -0
- package/dist/components/redirects/redirects-collection-view-source.d.ts +13 -1
- package/dist/components/redirects/redirects-collection-view-source.js +42 -70
- package/dist/components/redirects/redirects-collection-view-source.js.map +1 -1
- package/dist/components/section-state.d.ts +12 -3
- package/dist/components/section-state.js +8 -4
- package/dist/components/section-state.js.map +1 -1
- package/dist/components/users/users-collection-view-source.d.ts +13 -1
- package/dist/components/users/users-collection-view-source.js +60 -79
- package/dist/components/users/users-collection-view-source.js.map +1 -1
- package/dist/lib/ai/anthropic-provider.d.ts +29 -0
- package/dist/lib/ai/anthropic-provider.js +59 -0
- package/dist/lib/ai/anthropic-provider.js.map +1 -0
- package/dist/lib/ai/apply-content.d.ts +9 -0
- package/dist/lib/ai/apply-content.js +170 -0
- package/dist/lib/ai/apply-content.js.map +1 -0
- package/dist/lib/ai/config.d.ts +58 -0
- package/dist/lib/ai/config.js +134 -0
- package/dist/lib/ai/config.js.map +1 -0
- package/dist/lib/ai/fake-provider.d.ts +34 -0
- package/dist/lib/ai/fake-provider.js +31 -0
- package/dist/lib/ai/fake-provider.js.map +1 -0
- package/dist/lib/ai/index.d.ts +22 -0
- package/dist/lib/ai/index.js +23 -0
- package/dist/lib/ai/index.js.map +1 -0
- package/dist/lib/ai/knowledge.d.ts +39 -0
- package/dist/lib/ai/knowledge.js +60 -0
- package/dist/lib/ai/knowledge.js.map +1 -0
- package/dist/lib/ai/provenance.d.ts +20 -0
- package/dist/lib/ai/provenance.js +15 -0
- package/dist/lib/ai/provenance.js.map +1 -0
- package/dist/lib/ai/route.d.ts +24 -0
- package/dist/lib/ai/route.js +90 -0
- package/dist/lib/ai/route.js.map +1 -0
- package/dist/lib/ai/shape.d.ts +35 -0
- package/dist/lib/ai/shape.js +113 -0
- package/dist/lib/ai/shape.js.map +1 -0
- package/dist/lib/ai/types.d.ts +227 -0
- package/dist/lib/ai/types.js +2 -0
- package/dist/lib/ai/types.js.map +1 -0
- package/dist/lib/i18n/messages.en.js +4 -2
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/workspace/index.d.ts +9 -0
- package/dist/lib/workspace/index.js +11 -0
- package/dist/lib/workspace/index.js.map +1 -1
- package/package.json +8 -2
|
@@ -1,17 +1,15 @@
|
|
|
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
3
|
import { Check, ChevronDown, CircleAlert, CircleCheck, Eye, EyeOff, GitCompareArrows, History, Loader2, Lock, MoreHorizontal, RotateCcw, Save, Tag, Trash2, X, } from 'lucide-react';
|
|
4
|
-
import { useCallback,
|
|
5
|
-
import { FIELD_WIDTH_SPANS, SIDEBAR_FIELD_GROUP,
|
|
4
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
5
|
+
import { FIELD_WIDTH_SPANS, SIDEBAR_FIELD_GROUP, documentKey, groupOrder, groupSectionOf, hasAliases, hiddenGroupNames, isBlocksField, isFieldHidden, isFieldLocalized, isSingleton, resolveDocumentForLocale, resolveDocumentTitle, resolveField, sidebarFields, tabDescriptorOf, tabOrder, titleFieldOf, staticFieldHidden, } from '@elytracms/core/cms-core';
|
|
6
6
|
import { blockRootsOf } from '@elytracms/core/project-graph';
|
|
7
7
|
import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, CollapsibleSection, Field, Heading, Row, Spacer, Stack, Text, toast, } from '@elytracms/studio/ui';
|
|
8
8
|
import { getStudioRegistry } from '@elytracms/studio/lib/registry';
|
|
9
9
|
import { collectEmbedRefs, resolveReuseCapability } from '../builder/reuse';
|
|
10
10
|
import { cn } from '@elytracms/studio/lib/utils';
|
|
11
|
-
import { useOperationCache } from '@elytracms/studio/lib/query';
|
|
12
11
|
import { localizedLabel, translate } from '@elytracms/studio/lib/i18n';
|
|
13
|
-
import { LocaleSwitcher, activeLocaleValues, displayedFieldFallback, fieldConnection, fillFieldFromBase, resetFieldToBase, setFieldValue,
|
|
14
|
-
import { parseDocumentFieldPath } from '@elytracms/studio/lib/references';
|
|
12
|
+
import { LocaleSwitcher, activeLocaleValues, displayedFieldFallback, fieldConnection, fillFieldFromBase, resetFieldToBase, setFieldValue, } from '@elytracms/studio/lib/locale';
|
|
15
13
|
import { FallbackChip } from './cell';
|
|
16
14
|
import { FieldConnectionControl } from './field-connection';
|
|
17
15
|
import { FieldControl } from './field-control';
|
|
@@ -32,9 +30,6 @@ import { VersionsPane } from './versions-pane';
|
|
|
32
30
|
// EC-322: the lazy-wrapped compare view — its diff UI code-splits off the detail-view
|
|
33
31
|
// critical path and loads only when the Compare tab opens.
|
|
34
32
|
import { VersionCompare } from './version-compare-lazy';
|
|
35
|
-
import { useDocumentVersionsQuery } from './use-document-versions';
|
|
36
|
-
import { discardDraftStatus, flushDirtyDraft } from './discard-draft';
|
|
37
|
-
import { derivePublishedSnapshot, publishedVersionSnapshot } from './versions-model';
|
|
38
33
|
import { IssuesPane } from './issues-pane';
|
|
39
34
|
import { DetailsPane } from './details-pane';
|
|
40
35
|
import { DocumentUrlPath } from './document-url-path';
|
|
@@ -43,8 +38,18 @@ import { DocumentUrlPath } from './document-url-path';
|
|
|
43
38
|
import { EmbedInspectorProvider } from './rich-text/embed-inspector-context';
|
|
44
39
|
import { EmbedInspector } from './rich-text/embed-inspector';
|
|
45
40
|
import { useSetBreadcrumb } from '../breadcrumb-context';
|
|
46
|
-
import {
|
|
47
|
-
|
|
41
|
+
import { resolveDeploymentRoutes } from '@elytracms/studio/lib/workspace';
|
|
42
|
+
// EC-325 — the controller hooks EditorBody orchestrates (each independently
|
|
43
|
+
// unit-tested at its pure core); the shell threads shared draft/ref/replaceDraft
|
|
44
|
+
// state across their boundaries.
|
|
45
|
+
import { usePublishedPerspective } from './use-published-perspective';
|
|
46
|
+
import { useSaveMode } from './use-save-mode';
|
|
47
|
+
import { useDiscardController } from './use-discard-controller';
|
|
48
|
+
import { usePublishController, publishGate } from './use-publish-controller';
|
|
49
|
+
import { useFieldFocus } from './use-field-focus';
|
|
50
|
+
import { useCompareSelection } from './use-compare-selection';
|
|
51
|
+
import { useLiveUrl } from './use-live-url';
|
|
52
|
+
import { useEmbedInspectorHost } from './use-embed-inspector-host';
|
|
48
53
|
export function DetailEditor({ projectId, collectionId, documentId, focusPath, }) {
|
|
49
54
|
// AD-19: the edit shell is a portable surface over a CollectionViewSource, just
|
|
50
55
|
// like the list. `DetailEditor` is the CMS entry point — it builds the CMS source
|
|
@@ -115,89 +120,24 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
115
120
|
// Stable so the document-editor context value (EC-224) doesn't churn each render.
|
|
116
121
|
const ref = useMemo(() => ({ collection: collection.id, id: doc.id }), [collection.id, doc.id]);
|
|
117
122
|
const confirmTargets = useMemo(() => [{ collection: collection.id, id: doc.id }], [collection.id, doc.id]);
|
|
118
|
-
// EC-
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
// snapshot share ONE query (AD-17: the one operations-backed cache,
|
|
130
|
-
// `use-document-versions.ts` — no raw `source.versioning.load` call
|
|
131
|
-
// anywhere in this component). Both need exactly the same
|
|
132
|
-
// `versions`/`publishedVersion` shape, so a second bespoke fetch on
|
|
133
|
-
// perspective switch would just re-read what this already has. Gated the
|
|
134
|
-
// same way the Discard-draft menu item always was: a publishing + versioned
|
|
135
|
-
// rail AND a published document (never a wasted fetch on e.g. the users
|
|
136
|
-
// rail or a never-published draft) — in every real `CollectionViewSource`
|
|
137
|
-
// `capabilities.publish`/`capabilities.versions` are set identically, so
|
|
138
|
-
// this is also exactly when the Published perspective is reachable.
|
|
139
|
-
const canDiscard = canPublish && canVersions;
|
|
140
|
-
const { versionsQuery, versionKey } = useDocumentVersionsQuery(ref, {
|
|
141
|
-
enabled: canDiscard && published,
|
|
142
|
-
});
|
|
143
|
-
const versionsView = versionsQuery.data ?? null;
|
|
144
|
-
const publishedVersionNumber = versionsView?.publishedVersion;
|
|
145
|
-
const publishedSnapshotDoc = publishedVersionSnapshot(versionsView);
|
|
146
|
-
// The Published-perspective snapshot (EC-226), derived from the query
|
|
147
|
-
// above — no second fetch, and `derivePublishedSnapshot` guards a
|
|
148
|
-
// disabled/not-yet-resolved query by reading as 'loading' (never a false
|
|
149
|
-
// 'missing').
|
|
150
|
-
const publishedSnapshot = derivePublishedSnapshot(versionsView);
|
|
151
|
-
// The whole fieldset is read-only when viewing Published. The form/canvas
|
|
152
|
-
// render the pinned snapshot ONLY once it has loaded — never the draft, which
|
|
153
|
-
// must not be presented as published; until then the body shows a placeholder.
|
|
154
|
-
const viewReadOnly = perspective === 'published';
|
|
155
|
-
const viewDoc = viewReadOnly && publishedSnapshot.status === 'ready' ? publishedSnapshot.doc : draft;
|
|
156
|
-
const publishedPending = viewReadOnly && publishedSnapshot.status !== 'ready';
|
|
157
|
-
const discardCache = useOperationCache();
|
|
158
|
-
const [discardConfirm, setDiscardConfirm] = useState(false);
|
|
159
|
-
const [discardBusy, setDiscardBusy] = useState(false);
|
|
160
|
-
// One pure decision (tested in discard-draft.test.ts): hidden / disabled-
|
|
161
|
-
// with-reason / ready. Its semantic equality is the SAME comparator the
|
|
162
|
-
// Versions pane's publish gate uses, so the two never contradict (EC-298).
|
|
163
|
-
const discardStatus = discardDraftStatus({
|
|
164
|
-
canDiscard,
|
|
165
|
-
published,
|
|
166
|
-
viewReadOnly,
|
|
167
|
-
publishedSnapshot: publishedSnapshotDoc,
|
|
168
|
-
collection,
|
|
169
|
-
draft,
|
|
170
|
-
});
|
|
171
|
-
const discardDisabledReason = discardStatus === 'view-read-only'
|
|
172
|
-
? translate('cms.editor.switchToDraftToDiscard')
|
|
173
|
-
: discardStatus === 'up-to-date'
|
|
174
|
-
? translate('cms.editor.discardDraftUpToDateHint')
|
|
175
|
-
: discardStatus === 'loading'
|
|
176
|
-
? translate('common.loading')
|
|
177
|
-
: undefined;
|
|
178
|
-
// Reuses the EC-298 restore machinery (the same op the Compare view's
|
|
179
|
-
// "Restore version" button calls) — never a parallel write path: reverting
|
|
180
|
-
// the draft IS restoring the published version's snapshot, whole-document.
|
|
181
|
-
async function handleDiscardDraft() {
|
|
182
|
-
if (publishedVersionNumber === undefined)
|
|
183
|
-
return;
|
|
184
|
-
setDiscardBusy(true);
|
|
185
|
-
try {
|
|
186
|
-
// EC-325: flush a dirty in-memory buffer FIRST, mirroring the Versions
|
|
187
|
-
// pane's/Compare view's own restores — `documents.restore` snapshots
|
|
188
|
-
// whatever is currently PERSISTED, so an edit still sitting in the
|
|
189
|
-
// autosave debounce window would otherwise be silently unrecoverable.
|
|
190
|
-
flushDirtyDraft({ dirty, save });
|
|
191
|
-
const restored = await source.versioning?.restore(ref, publishedVersionNumber, { kind: 'all' });
|
|
192
|
-
if (restored)
|
|
193
|
-
replaceDraft(restored);
|
|
194
|
-
await discardCache.invalidate(versionKey);
|
|
195
|
-
}
|
|
196
|
-
finally {
|
|
197
|
-
setDiscardBusy(false);
|
|
198
|
-
setDiscardConfirm(false);
|
|
199
|
-
}
|
|
123
|
+
// EC-224 — the working draft's two out-of-band mutators, shared across the
|
|
124
|
+
// controller hooks below and the document-editor context. `save` persists the
|
|
125
|
+
// current buffer; `replaceDraft` adopts a restored snapshot and bumps the nonce
|
|
126
|
+
// to REMOUNT the uncontrolled canvas from it (else the next edit clobbers the
|
|
127
|
+
// restore — the canvas builds its graph once at mount and lifts edits OUT).
|
|
128
|
+
function save() {
|
|
129
|
+
source.save(draft);
|
|
130
|
+
}
|
|
131
|
+
function replaceDraft(next) {
|
|
132
|
+
setDraft(next);
|
|
133
|
+
setRestoreNonce((n) => n + 1);
|
|
200
134
|
}
|
|
135
|
+
// EC-226 / EC-316 / EC-325 — the Draft/Published VIEW perspective AND the pinned
|
|
136
|
+
// published snapshot, both derived from the ONE shared version-history query
|
|
137
|
+
// (AD-17: the single operations-backed cache — no raw `source.versioning.load`
|
|
138
|
+
// in this component). `canDiscard`/`versionKey`/`publishedVersionNumber`/
|
|
139
|
+
// `publishedSnapshotDoc` feed the Discard controller below off that same query.
|
|
140
|
+
const { perspective, setPerspective, published, canDiscard, versionKey, publishedVersionNumber, publishedSnapshotDoc, publishedSnapshot, viewReadOnly, viewDoc, publishedPending, } = usePublishedPerspective({ ref, source, canPublish, canVersions, draft });
|
|
201
141
|
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(doc), [draft, doc]);
|
|
202
142
|
const schemaIssues = useMemo(() => source.validate(draft), [source, draft]);
|
|
203
143
|
// EC-300/AD-16: composition embeds (hard references) whose target isn't published
|
|
@@ -210,38 +150,29 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
210
150
|
const errorCount = schemaIssues.filter((i) => i.severity === 'error').length;
|
|
211
151
|
const hasErrors = errorCount > 0;
|
|
212
152
|
const hasUnpublishedRefs = referenceIssues.length > 0;
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
return () => window.clearTimeout(timer);
|
|
237
|
-
}, [dirty, draft, explicitSave]);
|
|
238
|
-
// Flush a still-pending AUTOSAVE edit on unmount (navigating away inside the
|
|
239
|
-
// debounce window) so nothing is lost. Explicit rails never flush silently —
|
|
240
|
-
// their `UnsavedChangesGuard` catches the navigation instead. Unmount-only.
|
|
241
|
-
useEffect(() => () => {
|
|
242
|
-
if (!explicitSaveRef.current && dirtyRef.current)
|
|
243
|
-
sourceRef.current.save(draftRef.current);
|
|
244
|
-
}, []);
|
|
153
|
+
// EC-225 autosave · EC-240 explicit save — the persistence discipline for this
|
|
154
|
+
// rail (a publishing rail autosaves + flushes on unmount; a non-publishing rail
|
|
155
|
+
// saves explicitly and relies on the `UnsavedChangesGuard` below for navigation).
|
|
156
|
+
const { explicitSave } = useSaveMode({ canPublish, dirty, draft, source });
|
|
157
|
+
// EC-316 / EC-325 — the "Discard changes" controller: the pure enablement
|
|
158
|
+
// decision plus the shared restore sequence (flush → restore the pinned published
|
|
159
|
+
// version whole → replaceDraft → invalidate). Destructured to the names the header
|
|
160
|
+
// menu + confirm dialog already use.
|
|
161
|
+
const { status: discardStatus, disabledReason: discardDisabledReason, confirmOpen: discardConfirm, setConfirmOpen: setDiscardConfirm, busy: discardBusy, discard: handleDiscardDraft, } = useDiscardController({
|
|
162
|
+
canDiscard,
|
|
163
|
+
published,
|
|
164
|
+
viewReadOnly,
|
|
165
|
+
publishedSnapshotDoc,
|
|
166
|
+
publishedVersionNumber,
|
|
167
|
+
versionKey,
|
|
168
|
+
collection,
|
|
169
|
+
draft,
|
|
170
|
+
source,
|
|
171
|
+
docRef: ref,
|
|
172
|
+
replaceDraft,
|
|
173
|
+
dirty,
|
|
174
|
+
save,
|
|
175
|
+
});
|
|
245
176
|
const errorsByField = useMemo(() => {
|
|
246
177
|
const map = new Map();
|
|
247
178
|
for (const issue of issues) {
|
|
@@ -282,40 +213,28 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
282
213
|
// not a field-row — it takes over its tab with the full page-builder canvas.
|
|
283
214
|
// Convention: such a field lives on its own dedicated fieldset tab.
|
|
284
215
|
const canvasField = tabFields.find(isBlocksField);
|
|
285
|
-
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
setInspectorPaneId('versions');
|
|
308
|
-
}, []);
|
|
309
|
-
const setCompareBase = useCallback((base) => {
|
|
310
|
-
setCompare((c) => (c ? { ...c, base } : { base, target: 'draft' }));
|
|
311
|
-
}, []);
|
|
312
|
-
const setCompareTarget = useCallback((target) => {
|
|
313
|
-
setCompare((c) => (c ? { ...c, target } : c));
|
|
314
|
-
}, []);
|
|
315
|
-
const exitCompare = useCallback(() => {
|
|
316
|
-
setCompare(null);
|
|
317
|
-
setActiveTab((prev) => (prev === COMPARE_TAB ? tabs[0] : prev));
|
|
318
|
-
}, [tabs]);
|
|
216
|
+
// EC-172 / EC-283 — the field-focus/highlight controller (jump-to-field from the
|
|
217
|
+
// reference deep-link `focusPath` and the Issues pane). Owns the highlight + the
|
|
218
|
+
// controlled inspector-pane state and the scroll/clear timers.
|
|
219
|
+
const { highlightedField, inspectorPaneId, setInspectorPaneId, focusField } = useFieldFocus({
|
|
220
|
+
collection,
|
|
221
|
+
source,
|
|
222
|
+
focusPath,
|
|
223
|
+
setActiveTab,
|
|
224
|
+
});
|
|
225
|
+
// 0.0.41 — the selection-driven embed-inspector host: a rich-text `componentEmbed`
|
|
226
|
+
// selection swaps the inspector sidebar for the embed's prop form (the `api` is fed
|
|
227
|
+
// to the rich-text field through the EmbedInspector context).
|
|
228
|
+
const { activeEmbed, embedInspectorApi } = useEmbedInspectorHost();
|
|
229
|
+
// EC-298 — the sticky version-compare selection. Opening it focuses the Compare tab
|
|
230
|
+
// + the Versions pane; tabbing to the real fields keeps it open (only its ✕ closes
|
|
231
|
+
// it). `activeTab === COMPARE_TAB` is when the diff is visible.
|
|
232
|
+
const { compare, startCompare, setCompareBase, setCompareTarget, exitCompare } = useCompareSelection({
|
|
233
|
+
tabs,
|
|
234
|
+
compareTab: COMPARE_TAB,
|
|
235
|
+
setActiveTab,
|
|
236
|
+
setInspectorPaneId,
|
|
237
|
+
});
|
|
319
238
|
// A content tab (schema tab or References). Selecting one leaves the Compare
|
|
320
239
|
// view VISIBLE-wise but keeps the comparison open (sticky) — the diff is one
|
|
321
240
|
// click away on its still-present tab chip.
|
|
@@ -333,49 +252,6 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
333
252
|
const titleCase = tab !== REFERENCES_TAB && declaredLabel === undefined;
|
|
334
253
|
return (_jsx("button", { type: "button", "aria-pressed": active, onClick: () => setActiveTab(tab), className: cn('rounded-md px-2.5 py-1 text-xs font-medium transition-colors', titleCase && 'capitalize', active ? 'bg-accent text-accent-foreground' : 'text-muted-foreground hover:bg-muted'), children: display }, tab));
|
|
335
254
|
};
|
|
336
|
-
// Focus a field by name: switch to its tab (and locale, when given), scroll the
|
|
337
|
-
// control into view, and ring-highlight it. Shared by the reference deep-link
|
|
338
|
-
// (`focusPath`, EC-172) and the Issues pane's jump-to-field (EC-283).
|
|
339
|
-
const focusTimersRef = useRef([]);
|
|
340
|
-
const focusField = useCallback((fieldName, localeHint) => {
|
|
341
|
-
const field = collection.fields.find((f) => f.name === fieldName);
|
|
342
|
-
if (!field)
|
|
343
|
-
return;
|
|
344
|
-
// EC-285: a `sidebar`-group field renders in the Details inspector pane, not
|
|
345
|
-
// a content tab — open that pane instead of switching tabs.
|
|
346
|
-
if (field.form?.group === SIDEBAR_FIELD_GROUP) {
|
|
347
|
-
setInspectorPaneId('details');
|
|
348
|
-
}
|
|
349
|
-
else {
|
|
350
|
-
const fieldTabs = tabOrder(collection.form);
|
|
351
|
-
setActiveTab(field.form?.tab ?? fieldTabs[0] ?? 'main');
|
|
352
|
-
}
|
|
353
|
-
if (localeHint !== undefined && source.localeConfig.locales.includes(localeHint)) {
|
|
354
|
-
setStudioLocale(localeHint);
|
|
355
|
-
}
|
|
356
|
-
setHighlightedField(field.name);
|
|
357
|
-
// Clear any timers from a prior jump so back-to-back jumps don't fight.
|
|
358
|
-
focusTimersRef.current.forEach((t) => window.clearTimeout(t));
|
|
359
|
-
const scrollTimer = window.setTimeout(() => {
|
|
360
|
-
// Some controls (relation/asset pickers) don't render the input id —
|
|
361
|
-
// the field label (`htmlFor`) is always present.
|
|
362
|
-
const control = document.getElementById(`f-${field.name}`) ??
|
|
363
|
-
document.querySelector(`label[for="${CSS.escape(`f-${field.name}`)}"]`);
|
|
364
|
-
control?.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
365
|
-
}, 60);
|
|
366
|
-
const clearTimer = window.setTimeout(() => setHighlightedField(null), 3200);
|
|
367
|
-
focusTimersRef.current = [scrollTimer, clearTimer];
|
|
368
|
-
}, [collection, source.localeConfig]);
|
|
369
|
-
useEffect(() => () => focusTimersRef.current.forEach((t) => window.clearTimeout(t)), []);
|
|
370
|
-
// Reference deep links (EC-172): `?field=<index path>` pinpoints the exact field.
|
|
371
|
-
useEffect(() => {
|
|
372
|
-
if (!focusPath)
|
|
373
|
-
return;
|
|
374
|
-
const parsed = parseDocumentFieldPath(focusPath);
|
|
375
|
-
if (!parsed)
|
|
376
|
-
return;
|
|
377
|
-
focusField(parsed.field, parsed.locale);
|
|
378
|
-
}, [focusPath, focusField]);
|
|
379
255
|
function getValue(field) {
|
|
380
256
|
// EC-226: read from the active VIEW (the draft, or the pinned published
|
|
381
257
|
// snapshot when viewing Published) — never directly from `draft`.
|
|
@@ -437,20 +313,6 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
437
313
|
: null;
|
|
438
314
|
return (_jsx(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, fieldLevelReadOnly ? (_jsxs(Badge, { variant: "outline", children: [_jsx(Lock, { className: "size-3" }), " ", translate('cms.editor.readOnlyBadge')] })) : null, _jsx(Spacer, {}), connection && !fieldReadOnly ? (_jsx(FieldConnectionControl, { connection: connection, fieldName: field.name, locale: locale, onFillFromBase: () => setDraft((d) => fillFieldFromBase(collection, d, field.name, locale, source.localeConfig)), onResetToBase: () => setDraft((d) => resetFieldToBase(collection, d, field.name, locale)) })) : null] }), htmlFor: `f-${field.name}`, hint: localizedLabel(field.form?.description) || undefined, 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));
|
|
439
315
|
}
|
|
440
|
-
function save() {
|
|
441
|
-
source.save(draft);
|
|
442
|
-
}
|
|
443
|
-
// EC-224: a version restore swaps the working draft for an older snapshot; the
|
|
444
|
-
// open editor adopts it so the form/canvas re-render from the restored content
|
|
445
|
-
// (the operation already persisted it, so it lands clean/non-dirty).
|
|
446
|
-
function replaceDraft(next) {
|
|
447
|
-
setDraft(next);
|
|
448
|
-
// The canvas editor is uncontrolled after mount (it builds its graph once and
|
|
449
|
-
// lifts edits OUT). A restore replaces the draft from outside, so bump the
|
|
450
|
-
// nonce to REMOUNT the canvas from the restored value — otherwise it keeps the
|
|
451
|
-
// pre-restore tree and the next edit would clobber the restore.
|
|
452
|
-
setRestoreNonce((n) => n + 1);
|
|
453
|
-
}
|
|
454
316
|
// The frame-owned `Versions` pane (EC-224) — appended after any view-supplied
|
|
455
317
|
// pane (the canvas's `Properties`). One instance feeds both the form view's
|
|
456
318
|
// sidebar and the canvas view's sidebar.
|
|
@@ -511,75 +373,20 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
511
373
|
...(canVersions ? [versionsPane] : []),
|
|
512
374
|
issuesPane,
|
|
513
375
|
];
|
|
514
|
-
// EC-268
|
|
515
|
-
//
|
|
516
|
-
|
|
517
|
-
//
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
setPublishConfirm(true);
|
|
529
|
-
return;
|
|
530
|
-
}
|
|
531
|
-
void doPublish();
|
|
532
|
-
}
|
|
533
|
-
// 0.0.39: publish, then return to the rail's list (rail-owned `listPath`, AD-19),
|
|
534
|
-
// the same navigation the removed "Save & close" did. Shares handlePublish's exact
|
|
535
|
-
// gating + confirm-through; the close happens only after the publish resolves.
|
|
536
|
-
function handlePublishAndClose() {
|
|
537
|
-
closeAfterPublishRef.current = true;
|
|
538
|
-
handlePublish();
|
|
539
|
-
}
|
|
540
|
-
async function doPublish() {
|
|
541
|
-
// EC-273: clean conditionally-hidden values from the draft AT PUBLISH TIME.
|
|
542
|
-
// While editing, the draft keeps them so an accidental hide (e.g. flipping a
|
|
543
|
-
// link's type) is recoverable; publishing is the intentional moment we commit
|
|
544
|
-
// the cleanup — into the draft AND the snapshot, so both go clean together (the
|
|
545
|
-
// reference index updates off the saved draft, so a now-unlinked page becomes
|
|
546
|
-
// deletable once the change is published). Also captures the latest edit in case
|
|
547
|
-
// Publish is clicked inside the autosave debounce window; the serial persister
|
|
548
|
-
// runs the upsert before the publish so the snapshot is exactly this draft (EC-225).
|
|
549
|
-
const cleaned = clearHiddenValues(collection, draft);
|
|
550
|
-
if (cleaned !== draft)
|
|
551
|
-
setDraft(cleaned);
|
|
552
|
-
if (cleaned !== draft || dirty)
|
|
553
|
-
source.save(cleaned);
|
|
554
|
-
// Publish snapshots the draft as a version and pins it (EC-224). The live
|
|
555
|
-
// row stays a continuous draft — published-ness is the version pointer
|
|
556
|
-
// (`source.publishing.isPublished`). Pre-gated by `disabled={hasErrors}`; the optimistic
|
|
557
|
-
// flip reconciles against the backend (EC-225), so a logical rejection the
|
|
558
|
-
// gate doesn't cover (schema divergence) self-corrects the badge. A hard
|
|
559
|
-
// failure (transport) rejects the flush — surface it loudly (EC-268).
|
|
560
|
-
setPublishState('pending');
|
|
561
|
-
try {
|
|
562
|
-
await source.publishing?.publish(ref);
|
|
563
|
-
setPublishState('success');
|
|
564
|
-
setTimeout(() => setPublishState('idle'), 1800);
|
|
565
|
-
// "Publish & close": return to the list once the publish landed (never on a
|
|
566
|
-
// failure). Rail-owned `listPath`, falling back to the project index (AD-19).
|
|
567
|
-
if (closeAfterPublishRef.current) {
|
|
568
|
-
closeAfterPublishRef.current = false;
|
|
569
|
-
void navigate({
|
|
570
|
-
to: STUDIO_PROJECT_ROUTE,
|
|
571
|
-
params: { projectId, _splat: source.listPath ?? studioPath.index() },
|
|
572
|
-
});
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
catch (error) {
|
|
576
|
-
closeAfterPublishRef.current = false;
|
|
577
|
-
setPublishState('idle');
|
|
578
|
-
toast.error(error instanceof Error
|
|
579
|
-
? translate('cms.editor.publishFailedReason', { error: error.message })
|
|
580
|
-
: translate('cms.editor.publishFailed'));
|
|
581
|
-
}
|
|
582
|
-
}
|
|
376
|
+
// EC-268 / EC-300 / 0.0.39 — the publish controller: the publish indicator state,
|
|
377
|
+
// the EC-300 confirm-through guard (unpublished hard refs would render empty on
|
|
378
|
+
// the live site), and the "Publish & close" navigation. Handlers are named to
|
|
379
|
+
// match the header split button + the confirm-through dialog below.
|
|
380
|
+
const { publishState, publishConfirm, handlePublish, handlePublishAndClose, confirmPublish, cancelPublishConfirm, } = usePublishController({
|
|
381
|
+
source,
|
|
382
|
+
docRef: ref,
|
|
383
|
+
collection,
|
|
384
|
+
projectId,
|
|
385
|
+
draft,
|
|
386
|
+
setDraft,
|
|
387
|
+
dirty,
|
|
388
|
+
hasUnpublishedRefs,
|
|
389
|
+
});
|
|
583
390
|
function handleConfirmedAction() {
|
|
584
391
|
if (confirmAction === 'unpublish') {
|
|
585
392
|
setConfirmAction(null);
|
|
@@ -628,41 +435,13 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
628
435
|
];
|
|
629
436
|
}, [singleton, collection, draft, locale, source.localeConfig, source.listPath, doc.id, projectId]);
|
|
630
437
|
useSetBreadcrumb(breadcrumbItems);
|
|
631
|
-
// EC-205/EC-216
|
|
632
|
-
//
|
|
633
|
-
// draft
|
|
634
|
-
//
|
|
635
|
-
//
|
|
636
|
-
//
|
|
637
|
-
const
|
|
638
|
-
const liveUrl = useMemo(() => {
|
|
639
|
-
const published = publishedDocs.byId.get(doc.id);
|
|
640
|
-
if (!published)
|
|
641
|
-
return undefined;
|
|
642
|
-
return urlForDocument({
|
|
643
|
-
routes: resolveDeploymentRoutes(),
|
|
644
|
-
frontendUrl: resolveDeploymentFrontendUrl(),
|
|
645
|
-
localeConfig: source.localeConfig,
|
|
646
|
-
}, collection, published, { siblings: [...publishedDocs.byId.values()], locale });
|
|
647
|
-
}, [publishedDocs, collection, doc.id, locale, source.localeConfig]);
|
|
648
|
-
// 0.0.42 (round 3): the composed URL row lives in the header under the title; its
|
|
649
|
-
// LEAF segment carries the open-live affordance, mirroring the retired "View live"
|
|
650
|
-
// button's gating EXACTLY. `href` present (published + frontendUrl) → the leaf opens
|
|
651
|
-
// the live page in a new tab; absent → a muted, non-interactive icon with the same
|
|
652
|
-
// reason tooltip (loading / publish-first / set-frontendUrl). The draft-derived path
|
|
653
|
-
// itself still updates as you type — only the OPEN-live action is published-gated, so
|
|
654
|
-
// the draft/live distinction is preserved.
|
|
655
|
-
const headerLive = routableCollection
|
|
656
|
-
? liveUrl?.href
|
|
657
|
-
? { href: liveUrl.href, title: translate('cms.editor.openLiveUrl', { path: liveUrl.path }) }
|
|
658
|
-
: {
|
|
659
|
-
title: publishedDocs.status === 'loading'
|
|
660
|
-
? translate('cms.editor.loadingLiveUrl')
|
|
661
|
-
: !published
|
|
662
|
-
? translate('cms.editor.publishToViewLive')
|
|
663
|
-
: translate('cms.editor.setFrontendUrlHint'),
|
|
664
|
-
}
|
|
665
|
-
: undefined;
|
|
438
|
+
// EC-205/EC-216 — the header composed-URL row's live-link descriptor. Derives
|
|
439
|
+
// strictly from PUBLISHED data (the pinned snapshot + published ancestors), never
|
|
440
|
+
// the draft, so an unpublished slug/parent edit can't leak into the live URL; the
|
|
441
|
+
// draft-derived path itself (rendered by `DocumentUrlPath`) still updates as you
|
|
442
|
+
// type — only the OPEN-live action is published-gated. `undefined` when the
|
|
443
|
+
// collection is non-routable.
|
|
444
|
+
const { headerLive } = useLiveUrl({ source, collection, doc, locale, published, routableCollection });
|
|
666
445
|
// EC-240: hide the tab bar when there is nothing to navigate — a single content
|
|
667
446
|
// tab with no References/Compare (the users rail). When framework tabs ARE
|
|
668
447
|
// present we KEEP the content tab(s) so there is always a way back to the form;
|
|
@@ -672,14 +451,15 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
672
451
|
const hasFrameworkTabs = showReferences || compare !== null;
|
|
673
452
|
const showContentTabs = tabs.length > 1 || hasFrameworkTabs;
|
|
674
453
|
const showLensRow = showContentTabs || canLocales || canPublish;
|
|
675
|
-
// 0.0.39 split Publish button — the shared
|
|
676
|
-
// primary "Publish" and the "Publish & close" item can
|
|
677
|
-
|
|
678
|
-
const publishTitle =
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
454
|
+
// 0.0.39 split Publish button — the shared gate (disabled + title), computed once
|
|
455
|
+
// (pure `publishGate`) so the primary "Publish" and the "Publish & close" item can
|
|
456
|
+
// never diverge.
|
|
457
|
+
const { disabled: publishDisabled, title: publishTitle } = publishGate({
|
|
458
|
+
hasErrors,
|
|
459
|
+
viewReadOnly,
|
|
460
|
+
publishState,
|
|
461
|
+
errorCount,
|
|
462
|
+
});
|
|
683
463
|
const publishButtonLabel = publishState === 'pending' ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.publishing')] })) : publishState === 'success' ? (_jsxs(_Fragment, { children: [_jsx(CircleCheck, {}), " ", translate('cms.editor.publishedState')] })) : (_jsxs(_Fragment, { children: [_jsx(Eye, {}), " ", translate('cms.editor.publish')] }));
|
|
684
464
|
// Offer "Publish & close" only when there IS a list to return to — the same gate
|
|
685
465
|
// the removed EC-316 "Save & close" used (never a singleton or a list-less rail).
|
|
@@ -738,18 +518,10 @@ function EditorBody({ projectId, collection, doc, focusPath, }) {
|
|
|
738
518
|
if (!open)
|
|
739
519
|
setConfirmAction(null);
|
|
740
520
|
}, onConfirm: handleConfirmedAction })) : null, _jsx(Dialog, { open: discardConfirm, onOpenChange: (open) => { if (!open)
|
|
741
|
-
setDiscardConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.discardDraftTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.discardDraftDescription') })] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDiscardConfirm(false), disabled: discardBusy, children: translate('common.cancel') }), _jsx(Button, { variant: "destructive", size: "sm", "data-testid": "discard-draft-confirm", disabled: discardBusy, onClick: () => void handleDiscardDraft(), children: discardBusy ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.discardingDraft')] })) : (_jsxs(_Fragment, { children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraftConfirm')] })) })] })] }) }), _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open)
|
|
742
|
-
|
|
743
|
-
closeAfterPublishRef.current = false;
|
|
744
|
-
} }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.publishUnpublishedRefsTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.publishUnpublishedRefsDescription') })] }), _jsxs(Stack, { gap: "1", children: [referenceIssues.map((issue, i) => (_jsxs(Text, { size: "xs", tone: "muted", children: ["\u2022 ", issue.message] }, `${issue.meta?.id ?? i}`))), schemaIssues.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", className: "mt-1", children: translate(schemaIssues.length === 1
|
|
521
|
+
setDiscardConfirm(false); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.discardDraftTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.discardDraftDescription') })] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: () => setDiscardConfirm(false), disabled: discardBusy, children: translate('common.cancel') }), _jsx(Button, { variant: "destructive", size: "sm", "data-testid": "discard-draft-confirm", disabled: discardBusy, onClick: () => void handleDiscardDraft(), children: discardBusy ? (_jsxs(_Fragment, { children: [_jsx(Loader2, { className: "animate-spin" }), " ", translate('cms.editor.discardingDraft')] })) : (_jsxs(_Fragment, { children: [_jsx(RotateCcw, {}), " ", translate('cms.editor.discardDraftConfirm')] })) })] })] }) }), _jsx(Dialog, { open: publishConfirm, onOpenChange: (open) => { if (!open)
|
|
522
|
+
cancelPublishConfirm(); }, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: translate('cms.editor.publishUnpublishedRefsTitle') }), _jsx(DialogDescription, { children: translate('cms.editor.publishUnpublishedRefsDescription') })] }), _jsxs(Stack, { gap: "1", children: [referenceIssues.map((issue, i) => (_jsxs(Text, { size: "xs", tone: "muted", children: ["\u2022 ", issue.message] }, `${issue.meta?.id ?? i}`))), schemaIssues.length > 0 ? (_jsx(Text, { size: "xs", tone: "muted", className: "mt-1", children: translate(schemaIssues.length === 1
|
|
745
523
|
? 'cms.editor.publishAlsoHasIssues.one'
|
|
746
|
-
: 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: ()
|
|
747
|
-
setPublishConfirm(false);
|
|
748
|
-
closeAfterPublishRef.current = false;
|
|
749
|
-
}, children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: () => {
|
|
750
|
-
setPublishConfirm(false);
|
|
751
|
-
void doPublish();
|
|
752
|
-
}, children: translate('cms.editor.publishAnyway') })] })] }) })] }) }) }));
|
|
524
|
+
: 'cms.editor.publishAlsoHasIssues.other', { count: schemaIssues.length }) })) : null] }), _jsxs(DialogFooter, { className: "mt-4", children: [_jsx(Button, { variant: "ghost", size: "sm", onClick: cancelPublishConfirm, children: translate('common.cancel') }), _jsx(Button, { size: "sm", "data-testid": "publish-anyway", onClick: confirmPublish, children: translate('cms.editor.publishAnyway') })] })] }) })] }) }) }));
|
|
753
525
|
}
|
|
754
526
|
/**
|
|
755
527
|
* EC-300/AD-16 — errors for composition embeds (hard references) whose target isn't
|