canopycms 0.0.67 → 0.0.68-int.101
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/README.md +24 -17
- package/dist/ai/handler.js +8 -0
- package/dist/api/admin-branch-health.js +17 -20
- package/dist/api/admin.d.ts +30 -9
- package/dist/api/admin.js +32 -2
- package/dist/api/assets.js +84 -76
- package/dist/api/branch-create-window.d.ts +12 -0
- package/dist/api/branch-create-window.js +13 -0
- package/dist/api/branch-review.d.ts +3 -3
- package/dist/api/branch-review.js +14 -5
- package/dist/api/branch-status.d.ts +5 -5
- package/dist/api/branch-status.js +26 -6
- package/dist/api/branch-withdraw.d.ts +2 -2
- package/dist/api/branch-withdraw.js +7 -2
- package/dist/api/branch.d.ts +15 -2
- package/dist/api/branch.js +118 -92
- package/dist/api/client.d.ts +18 -6
- package/dist/api/client.js +79 -12
- package/dist/api/content.d.ts +6 -5
- package/dist/api/content.js +44 -41
- package/dist/api/entries.js +16 -15
- package/dist/api/github-sync.d.ts +19 -1
- package/dist/api/github-sync.js +57 -10
- package/dist/api/index.d.ts +1 -1
- package/dist/api/reference-options.js +1 -1
- package/dist/api/resolve-references.js +14 -40
- package/dist/api/settings-helpers.d.ts +1 -1
- package/dist/api/settings-helpers.js +1 -4
- package/dist/api/user.d.ts +3 -0
- package/dist/api/user.js +3 -0
- package/dist/assets/asset-url.d.ts +22 -16
- package/dist/assets/asset-url.js +42 -25
- package/dist/assets/factory.d.ts +5 -0
- package/dist/assets/factory.js +5 -0
- package/dist/assets/finalize.js +3 -0
- package/dist/assets/index.d.ts +1 -1
- package/dist/assets/materialize.d.ts +153 -0
- package/dist/assets/materialize.js +402 -0
- package/dist/assets/store-local.d.ts +19 -5
- package/dist/assets/store-local.js +69 -10
- package/dist/assets/store-s3.d.ts +38 -7
- package/dist/assets/store-s3.js +128 -22
- package/dist/assets/transform-directives.d.ts +52 -13
- package/dist/assets/transform-directives.js +87 -27
- package/dist/assets/transform.d.ts +3 -3
- package/dist/assets/transform.js +13 -8
- package/dist/assets/types.d.ts +31 -4
- package/dist/auth/file-based-auth-cache.js +7 -8
- package/dist/authorization/content.d.ts +5 -4
- package/dist/authorization/content.js +5 -5
- package/dist/authorization/path.d.ts +11 -6
- package/dist/authorization/path.js +11 -6
- package/dist/authorization/types.d.ts +2 -2
- package/dist/branch-health.d.ts +7 -1
- package/dist/branch-health.js +8 -3
- package/dist/branch-metadata.d.ts +5 -5
- package/dist/branch-metadata.js +44 -36
- package/dist/branch-provisioning.d.ts +167 -0
- package/dist/branch-provisioning.js +501 -0
- package/dist/branch-schema-cache.d.ts +5 -3
- package/dist/branch-schema-cache.js +29 -11
- package/dist/branch-sparse.d.ts +23 -0
- package/dist/branch-sparse.js +118 -0
- package/dist/branch-workspace.d.ts +42 -3
- package/dist/branch-workspace.js +240 -94
- package/dist/build/asset-refs.d.ts +93 -0
- package/dist/build/asset-refs.js +251 -0
- package/dist/build-identity.d.ts +3 -0
- package/dist/build-identity.js +13 -0
- package/dist/cli/asset-refs.d.ts +52 -0
- package/dist/cli/asset-refs.js +194 -0
- package/dist/cli/cli.d.ts +15 -1
- package/dist/cli/cli.js +5928 -2195
- package/dist/cli/configured-asset-store.d.ts +12 -0
- package/dist/cli/configured-asset-store.js +46 -0
- package/dist/cli/generate-ai-content.js +2185 -587
- package/dist/cli/github-app-manifest.d.ts +4 -3
- package/dist/cli/github-app-manifest.js +4 -3
- package/dist/cli/init.js +30 -27
- package/dist/cli/migrate.js +12 -8
- package/dist/cli/sync.js +83 -37
- package/dist/cli/template-files/Dockerfile.cms.template +8 -0
- package/dist/cli/template-files/cdk-app.ts.template +4 -0
- package/dist/cli/template-files/cms-stack.ts.template +23 -12
- package/dist/cli/template-files/deploy-cms.yml.template +2 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +1 -1
- package/dist/config/helpers.js +1 -2
- package/dist/config/schemas/config.d.ts +19 -32
- package/dist/config/schemas/config.js +9 -11
- package/dist/config/schemas/field.js +1 -0
- package/dist/config/schemas/media.d.ts +4 -13
- package/dist/config/schemas/media.js +5 -8
- package/dist/config/schemas/url.d.ts +4 -20
- package/dist/config/schemas/url.js +6 -22
- package/dist/config/types.d.ts +30 -20
- package/dist/config/validation.d.ts +6 -0
- package/dist/config/validation.js +37 -0
- package/dist/content-listing.d.ts +12 -12
- package/dist/content-listing.js +10 -6
- package/dist/content-reader.d.ts +11 -2
- package/dist/content-reader.js +22 -14
- package/dist/content-store.d.ts +37 -9
- package/dist/content-store.js +70 -43
- package/dist/content-tree.d.ts +1 -1
- package/dist/content-tree.js +2 -2
- package/dist/context.d.ts +27 -9
- package/dist/context.js +154 -70
- package/dist/editor/BranchManager.d.ts +7 -1
- package/dist/editor/BranchManager.js +29 -14
- package/dist/editor/CanopyEditor.d.ts +1 -1
- package/dist/editor/CanopyEditor.js +2 -6
- package/dist/editor/Editor.d.ts +4 -3
- package/dist/editor/Editor.js +32 -22
- package/dist/editor/FormRenderer.js +34 -18
- package/dist/editor/PreviewFrame.d.ts +24 -0
- package/dist/editor/PreviewFrame.js +140 -0
- package/dist/editor/admin/SystemHealthPanel.js +47 -7
- package/dist/editor/components/BranchesDrawer.d.ts +9 -0
- package/dist/editor/components/BranchesDrawer.js +4 -0
- package/dist/editor/components/NoEditPermissionNotice.d.ts +10 -0
- package/dist/editor/components/NoEditPermissionNotice.js +17 -0
- package/dist/editor/context/AssetContext.d.ts +18 -35
- package/dist/editor/context/AssetContext.js +20 -29
- package/dist/editor/context/index.d.ts +1 -1
- package/dist/editor/context/index.js +1 -1
- package/dist/editor/editor-config.d.ts +1 -2
- package/dist/editor/editor-config.js +0 -23
- package/dist/editor/editor-utils.d.ts +24 -21
- package/dist/editor/editor-utils.js +75 -62
- package/dist/editor/fields/BlockField.d.ts +1 -0
- package/dist/editor/fields/BlockField.js +6 -4
- package/dist/editor/fields/CodeField.d.ts +1 -0
- package/dist/editor/fields/CodeField.js +2 -2
- package/dist/editor/fields/DateTimeField.d.ts +1 -0
- package/dist/editor/fields/DateTimeField.js +2 -2
- package/dist/editor/fields/FieldDescription.d.ts +16 -0
- package/dist/editor/fields/FieldDescription.js +11 -0
- package/dist/editor/fields/ImageField.d.ts +1 -0
- package/dist/editor/fields/ImageField.js +3 -2
- package/dist/editor/fields/InlineGroupField.js +4 -1
- package/dist/editor/fields/MarkdownField.d.ts +1 -0
- package/dist/editor/fields/MarkdownField.js +115 -20
- package/dist/editor/fields/NumberField.d.ts +1 -0
- package/dist/editor/fields/NumberField.js +2 -2
- package/dist/editor/fields/NumberListField.d.ts +1 -0
- package/dist/editor/fields/NumberListField.js +2 -2
- package/dist/editor/fields/ObjectField.d.ts +1 -0
- package/dist/editor/fields/ObjectField.js +5 -2
- package/dist/editor/fields/ReferenceField.d.ts +1 -0
- package/dist/editor/fields/ReferenceField.js +5 -4
- package/dist/editor/fields/SelectField.d.ts +1 -0
- package/dist/editor/fields/SelectField.js +2 -2
- package/dist/editor/fields/StringListField.d.ts +1 -0
- package/dist/editor/fields/StringListField.js +2 -2
- package/dist/editor/fields/TextField.d.ts +1 -0
- package/dist/editor/fields/TextField.js +2 -2
- package/dist/editor/fields/ToggleField.d.ts +1 -0
- package/dist/editor/fields/ToggleField.js +7 -4
- package/dist/editor/fields/entry-link/InsertEntryLink.d.ts +1 -1
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -2
- package/dist/editor/fields/mdx-jsx-support.d.ts +1 -0
- package/dist/editor/fields/mdx-jsx-support.js +137 -0
- package/dist/editor/hooks/create-branch-request.d.ts +20 -0
- package/dist/editor/hooks/create-branch-request.js +52 -0
- package/dist/editor/hooks/useBranchActions.d.ts +13 -2
- package/dist/editor/hooks/useBranchActions.js +48 -17
- package/dist/editor/hooks/useBranchManager.d.ts +21 -0
- package/dist/editor/hooks/useBranchManager.js +204 -37
- package/dist/editor/hooks/useBranchesData.d.ts +6 -0
- package/dist/editor/hooks/useBranchesData.js +9 -3
- package/dist/editor/hooks/useCommentSystem.js +25 -6
- package/dist/editor/hooks/useDraftManager.d.ts +16 -1
- package/dist/editor/hooks/useDraftManager.js +193 -36
- package/dist/editor/hooks/useEntriesData.js +3 -3
- package/dist/editor/hooks/useEntryManager.d.ts +2 -1
- package/dist/editor/hooks/useEntryManager.js +34 -19
- package/dist/editor/media/AssetCard.d.ts +1 -1
- package/dist/editor/media/crop-math.d.ts +3 -4
- package/dist/editor/media/crop-math.js +11 -22
- package/dist/editor/media/editor-image-src.d.ts +7 -0
- package/dist/editor/media/editor-image-src.js +12 -0
- package/dist/editor/preview-asset-base.d.ts +8 -0
- package/dist/editor/preview-asset-base.js +13 -0
- package/dist/editor/preview-bridge.d.ts +9 -21
- package/dist/editor/preview-bridge.js +19 -139
- package/dist/editor/preview-path.d.ts +10 -0
- package/dist/editor/preview-path.js +20 -0
- package/dist/editor/theme.js +2 -2
- package/dist/entry-schema-registry.d.ts +4 -4
- package/dist/entry-schema-registry.js +10 -6
- package/dist/entry-schema.d.ts +27 -4
- package/dist/entry-schema.js +20 -3
- package/dist/git-manager.d.ts +173 -6
- package/dist/git-manager.js +594 -76
- package/dist/github-service.d.ts +15 -0
- package/dist/github-service.js +19 -1
- package/dist/http/handler.js +37 -13
- package/dist/http/index.d.ts +2 -0
- package/dist/http/index.js +2 -0
- package/dist/http/router.d.ts +2 -0
- package/dist/http/router.js +2 -1
- package/dist/http/types.d.ts +2 -0
- package/dist/http/worker-not-ready.d.ts +10 -0
- package/dist/http/worker-not-ready.js +24 -0
- package/dist/operating-mode/client-unsafe-strategy.js +0 -6
- package/dist/operating-mode/types.d.ts +1 -4
- package/dist/paths/branch-name.d.ts +5 -0
- package/dist/paths/branch-name.js +9 -0
- package/dist/paths/branch.d.ts +6 -2
- package/dist/paths/branch.js +16 -10
- package/dist/paths/index.d.ts +3 -3
- package/dist/paths/index.js +3 -3
- package/dist/paths/normalize.d.ts +7 -0
- package/dist/paths/normalize.js +9 -0
- package/dist/paths/validation.js +0 -2
- package/dist/preview.d.ts +8 -0
- package/dist/preview.js +7 -0
- package/dist/reference-resolver.d.ts +5 -21
- package/dist/reference-resolver.js +9 -44
- package/dist/resolve-canopy-user.js +2 -1
- package/dist/schema/schema-store.d.ts +54 -31
- package/dist/schema/schema-store.js +77 -31
- package/dist/server.d.ts +64 -9
- package/dist/server.js +48 -8
- package/dist/services.d.ts +18 -6
- package/dist/services.js +85 -70
- package/dist/settings-workspace.d.ts +23 -3
- package/dist/settings-workspace.js +197 -45
- package/dist/static/seo.d.ts +2 -14
- package/dist/static/seo.js +2 -25
- package/dist/submission-attribution.d.ts +73 -0
- package/dist/submission-attribution.js +221 -0
- package/dist/sync-core.d.ts +12 -1
- package/dist/sync-core.js +31 -13
- package/dist/task-queue/worker-status.d.ts +8 -0
- package/dist/task-queue/worker-status.js +17 -0
- package/dist/types.d.ts +31 -2
- package/dist/utils/content-serialize.d.ts +5 -2
- package/dist/utils/content-serialize.js +98 -24
- package/dist/utils/content-write-lock.d.ts +18 -7
- package/dist/utils/content-write-lock.js +18 -9
- package/dist/utils/debug.d.ts +8 -0
- package/dist/utils/debug.js +10 -2
- package/dist/utils/git.d.ts +32 -0
- package/dist/utils/git.js +42 -0
- package/dist/utils/occ-json-write.js +10 -2
- package/dist/utils/provision-log.d.ts +25 -0
- package/dist/utils/provision-log.js +49 -0
- package/dist/utils/provisioning-lock.d.ts +35 -5
- package/dist/utils/provisioning-lock.js +73 -12
- package/dist/utils/request-timing.d.ts +25 -0
- package/dist/utils/request-timing.js +101 -0
- package/dist/utils/sanitize-href.d.ts +8 -22
- package/dist/utils/sanitize-href.js +11 -28
- package/dist/utils/url-prefix.d.ts +30 -0
- package/dist/utils/url-prefix.js +61 -0
- package/dist/utils/yaml-source-splice.d.ts +58 -0
- package/dist/utils/yaml-source-splice.js +515 -0
- package/dist/validation/entry-validator.js +9 -3
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/worker/canopy-state.d.ts +45 -0
- package/dist/worker/canopy-state.js +75 -0
- package/dist/worker/cms-worker.d.ts +8 -3
- package/dist/worker/cms-worker.js +38 -2
- package/dist/worker/git-sync.d.ts +30 -12
- package/dist/worker/git-sync.js +253 -58
- package/dist/worker/history-rewrite.d.ts +1 -1
- package/dist/worker/history-rewrite.js +1 -1
- package/dist/worker/provisioned-workspace.d.ts +35 -0
- package/dist/worker/provisioned-workspace.js +50 -0
- package/dist/worker/rebase.d.ts +1 -1
- package/dist/worker/rebase.js +95 -23
- package/dist/worker/remote-git-maintenance.d.ts +3 -0
- package/dist/worker/remote-git-maintenance.js +11 -0
- package/dist/worker/sparse-cone.d.ts +20 -0
- package/dist/worker/sparse-cone.js +132 -0
- package/dist/worker/task-runner.js +99 -14
- package/dist/worker/worker-context.d.ts +6 -1
- package/package.json +8 -2
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* `mutate()` to revalidate.
|
|
14
14
|
*/
|
|
15
15
|
import useSWR from 'swr';
|
|
16
|
+
import { isNonApiResponse } from '../../api/client.js';
|
|
16
17
|
/** Cache key for the branches list. */
|
|
17
18
|
export const BRANCHES_KEY = 'canopy:branches';
|
|
18
19
|
/**
|
|
@@ -21,15 +22,20 @@ export const BRANCHES_KEY = 'canopy:branches';
|
|
|
21
22
|
*/
|
|
22
23
|
export async function fetchBranches(apiClient) {
|
|
23
24
|
const result = await apiClient.branches.list();
|
|
24
|
-
if (result.status === 404) {
|
|
25
|
+
if (result.status === 404 && !isNonApiResponse(result)) {
|
|
25
26
|
// No branch endpoint available; stay branchless rather than erroring --
|
|
26
27
|
// the branch dropdown stays clickable so the user can retry from there.
|
|
27
|
-
|
|
28
|
+
// A proxy's 404 page means the API was not reached, so it is an error.
|
|
29
|
+
return { branches: [], receivedAt: Date.now() };
|
|
28
30
|
}
|
|
29
31
|
if (!result.ok) {
|
|
30
32
|
throw new Error(result.error ?? `Failed to load branches: ${result.status}`);
|
|
31
33
|
}
|
|
32
|
-
return {
|
|
34
|
+
return {
|
|
35
|
+
branches: result.data?.branches ?? [],
|
|
36
|
+
defaultBranch: result.data?.defaultBranch,
|
|
37
|
+
receivedAt: Date.now(),
|
|
38
|
+
};
|
|
33
39
|
}
|
|
34
40
|
/**
|
|
35
41
|
* How often to re-poll while at least one branch is mid-flight, in ms.
|
|
@@ -4,6 +4,7 @@ import { notifications } from '@mantine/notifications';
|
|
|
4
4
|
import { normalizeCanopyPath } from '../canopy-path.js';
|
|
5
5
|
import { useApiClient } from '../context/index.js';
|
|
6
6
|
import { resolveMessageOrigin } from '../preview-bridge.js';
|
|
7
|
+
import { isSamePreviewPath } from '../preview-path.js';
|
|
7
8
|
import { commentsKey, fetchComments, useCommentsData } from './useCommentsData.js';
|
|
8
9
|
/**
|
|
9
10
|
* Custom hook for managing the comment system.
|
|
@@ -112,6 +113,16 @@ export function useCommentSystem(options) {
|
|
|
112
113
|
}, [activeCommentContext, options.selectedPath, options.branchName]);
|
|
113
114
|
// Listen for field focus messages from preview frame
|
|
114
115
|
useEffect(() => {
|
|
116
|
+
// Delayed undo steps (highlight restore, focus clear). Cleanup runs them early rather than
|
|
117
|
+
// dropping them, so an entry switch never leaves a field highlighted or focused.
|
|
118
|
+
const pending = new Map();
|
|
119
|
+
const later = (undo, ms) => {
|
|
120
|
+
const id = window.setTimeout(() => {
|
|
121
|
+
pending.delete(id);
|
|
122
|
+
undo();
|
|
123
|
+
}, ms);
|
|
124
|
+
pending.set(id, undo);
|
|
125
|
+
};
|
|
115
126
|
const handleFocus = (event) => {
|
|
116
127
|
// Only accept messages from the preview's origin (same-origin when previewSrc is
|
|
117
128
|
// relative). Origin-only by design: this hook has no handle on the preview iframe
|
|
@@ -121,8 +132,11 @@ export function useCommentSystem(options) {
|
|
|
121
132
|
const msg = event.data;
|
|
122
133
|
if (msg?.type !== 'canopycms:preview:focus')
|
|
123
134
|
return;
|
|
135
|
+
const currentPath = options.currentEntry?.previewSrc ?? options.currentEntry?.path;
|
|
124
136
|
if (msg.entryPath &&
|
|
125
|
-
msg.entryPath !==
|
|
137
|
+
(typeof msg.entryPath !== 'string' ||
|
|
138
|
+
currentPath === undefined ||
|
|
139
|
+
!isSamePreviewPath(msg.entryPath, currentPath)))
|
|
126
140
|
return;
|
|
127
141
|
const normalizedPath = msg.fieldPath ? normalizeCanopyPath(msg.fieldPath) : undefined;
|
|
128
142
|
const target = normalizedPath
|
|
@@ -132,21 +146,26 @@ export function useCommentSystem(options) {
|
|
|
132
146
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
133
147
|
const previous = target.style.boxShadow;
|
|
134
148
|
target.style.boxShadow = '0 0 0 3px rgba(79, 70, 229, 0.35)';
|
|
135
|
-
|
|
149
|
+
later(() => {
|
|
136
150
|
target.style.boxShadow = previous;
|
|
137
151
|
}, 1200);
|
|
138
152
|
// Set focused field path to trigger FieldWrapper auto-focus
|
|
139
153
|
if (normalizedPath) {
|
|
140
154
|
setFocusedFieldPath(normalizedPath);
|
|
141
155
|
// Clear after brief delay to allow FieldWrapper to detect the change
|
|
142
|
-
|
|
143
|
-
setFocusedFieldPath(undefined);
|
|
144
|
-
}, 100);
|
|
156
|
+
later(() => setFocusedFieldPath(undefined), 100);
|
|
145
157
|
}
|
|
146
158
|
}
|
|
147
159
|
};
|
|
148
160
|
window.addEventListener('message', handleFocus);
|
|
149
|
-
return () =>
|
|
161
|
+
return () => {
|
|
162
|
+
window.removeEventListener('message', handleFocus);
|
|
163
|
+
for (const [id, undo] of pending) {
|
|
164
|
+
window.clearTimeout(id);
|
|
165
|
+
undo();
|
|
166
|
+
}
|
|
167
|
+
pending.clear();
|
|
168
|
+
};
|
|
150
169
|
}, [options.currentEntry]);
|
|
151
170
|
const handleJumpToField = (entryPath, canopyPath, threadId) => {
|
|
152
171
|
if (entryPath !== options.selectedPath) {
|
|
@@ -8,13 +8,19 @@ export interface UseDraftManagerOptions {
|
|
|
8
8
|
entries: EditorEntry[];
|
|
9
9
|
initialValues?: Record<string, FormValue>;
|
|
10
10
|
loadEntry: (entry: EditorEntry) => Promise<FormValue>;
|
|
11
|
+
/**
|
|
12
|
+
* Server read that records no OCC token (useEntryManager's `readEntryValue`),
|
|
13
|
+
* for checking restored drafts of unopened entries. Without it they stay
|
|
14
|
+
* unverified and count as dirty.
|
|
15
|
+
*/
|
|
16
|
+
readEntryValue?: (entry: EditorEntry) => Promise<FormValue>;
|
|
11
17
|
saveEntry: (entry: EditorEntry, value: FormValue) => Promise<FormValue>;
|
|
12
18
|
/**
|
|
13
19
|
* The OCC version token currently held for an entry on the branch being
|
|
14
20
|
* shown (useEntryManager's `getEntryVersion`). Stamps each draft with the
|
|
15
21
|
* version it was based on, and detects at save time that the token has
|
|
16
22
|
* since moved on. Optional: without it no base versions are recorded and
|
|
17
|
-
* conflict detection falls back entirely to
|
|
23
|
+
* conflict detection falls back entirely to saveEntry's token check.
|
|
18
24
|
*/
|
|
19
25
|
getEntryVersion?: (contentId: string) => number | undefined;
|
|
20
26
|
setBusy: (busy: boolean) => void;
|
|
@@ -26,6 +32,11 @@ export interface UseDraftManagerOptions {
|
|
|
26
32
|
*/
|
|
27
33
|
onSaved?: () => void;
|
|
28
34
|
}
|
|
35
|
+
/** The unsaved work a branch-level action would leave behind. `labels` names the entries (it may be shorter than `count`). */
|
|
36
|
+
export interface UnsavedSummary {
|
|
37
|
+
count: number;
|
|
38
|
+
labels: string[];
|
|
39
|
+
}
|
|
29
40
|
export interface UseDraftManagerReturn {
|
|
30
41
|
drafts: Record<string, FormValue>;
|
|
31
42
|
setDrafts: React.Dispatch<React.SetStateAction<Record<string, FormValue>>>;
|
|
@@ -46,6 +57,10 @@ export interface UseDraftManagerReturn {
|
|
|
46
57
|
isDirtyForEntry: (entryPath: string) => boolean;
|
|
47
58
|
isSelectedDirty: () => boolean;
|
|
48
59
|
isAnyDirty: () => boolean;
|
|
60
|
+
/** Resolves once restored drafts have been checked against the server (or a read failed), capped so a hung read cannot block. */
|
|
61
|
+
whenDraftsVerified: () => Promise<void>;
|
|
62
|
+
/** `whenDraftsVerified`, then the entries still counted dirty. */
|
|
63
|
+
resolveUnsaved: () => Promise<UnsavedSummary>;
|
|
49
64
|
/**
|
|
50
65
|
* Per-field validation errors for the selected entry, keyed by canonical
|
|
51
66
|
* canopy path (e.g. `blocks[0].title`). Populated when a save is blocked by
|
|
@@ -63,6 +63,9 @@ const parsePersistedDrafts = (raw) => {
|
|
|
63
63
|
baseVersions[id] = null;
|
|
64
64
|
return { drafts, baseVersions };
|
|
65
65
|
};
|
|
66
|
+
const VERIFY_CONCURRENCY = 4;
|
|
67
|
+
/** How long `whenDraftsVerified` waits before answering with whatever is known. */
|
|
68
|
+
const VERIFY_WAIT_CAP_MS = 3000;
|
|
66
69
|
/**
|
|
67
70
|
* Custom hook for managing draft state (localStorage persistence, save/discard).
|
|
68
71
|
*/
|
|
@@ -103,40 +106,79 @@ export function useDraftManager(options) {
|
|
|
103
106
|
const loadedValue = currentId ? loadedValues[currentId] : undefined;
|
|
104
107
|
const effectiveValue = selectedValue ?? loadedValue;
|
|
105
108
|
const fieldErrors = useMemo(() => (errorState && errorState.entryId === currentId ? errorState.errors : {}), [errorState, currentId]);
|
|
106
|
-
//
|
|
109
|
+
// Server values read for restored drafts of unopened entries that differ from
|
|
110
|
+
// the draft. Not `loadedValues`: that map gates the entry load and OCC token.
|
|
111
|
+
const [verifiedBaselines, setVerifiedBaselines] = useState({});
|
|
112
|
+
// Ids whose verification read failed: still dirty, but no longer awaited.
|
|
113
|
+
const [unreadableIds, setUnreadableIds] = useState(() => new Set());
|
|
114
|
+
const requestedIdsRef = useRef(new Set());
|
|
115
|
+
const verifyQueueRef = useRef([]);
|
|
116
|
+
const verifyActiveRef = useRef(0);
|
|
117
|
+
const verifyWaitersRef = useRef([]);
|
|
118
|
+
const entryById = useMemo(() => new Map(options.entries.map((e) => [e.contentId, e])), [options.entries]);
|
|
119
|
+
// Empty while a branch switch loads, so only a non-empty list can say an entry is gone.
|
|
120
|
+
const entriesKnown = options.entries.length > 0;
|
|
121
|
+
// The draft ids that count as unsaved work:
|
|
107
122
|
//
|
|
108
|
-
//
|
|
123
|
+
// 1. A draft with no baseline (loaded or verified value) counts as dirty: it
|
|
124
|
+
// cannot be proven to match the server, and counting it keeps the
|
|
125
|
+
// branch-switch guard from silently discarding restored drafts.
|
|
126
|
+
// Verification (below) removes the ones that turn out pristine.
|
|
109
127
|
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
// cannot prove such a draft matches server state, so we conservatively treat it
|
|
113
|
-
// as unsaved work — this is what keeps the branch-switch guard from silently
|
|
114
|
-
// discarding restored drafts.
|
|
128
|
+
// 2. A draft for an entry that no longer exists can never be loaded or saved,
|
|
129
|
+
// so it is not counted. It stays in storage.
|
|
115
130
|
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
// 3. The comparison is `fast-deep-equal`, so key insertion order of a
|
|
132
|
+
// rehydrated draft does not make it dirty.
|
|
133
|
+
const dirtyIds = useMemo(() => Object.keys(drafts).filter((id) => {
|
|
134
|
+
if (entriesKnown && !entryById.has(id))
|
|
135
|
+
return false;
|
|
136
|
+
const baseline = loadedValues[id] ?? verifiedBaselines[id];
|
|
137
|
+
return baseline === undefined || !equal(drafts[id], baseline);
|
|
138
|
+
}), [drafts, loadedValues, verifiedBaselines, entriesKnown, entryById]);
|
|
139
|
+
const modifiedCount = dirtyIds.length;
|
|
140
|
+
const editedFiles = useMemo(() => dirtyIds
|
|
141
|
+
.map((id) => {
|
|
142
|
+
const entry = entryById.get(id);
|
|
143
|
+
return entry ? { path: entry.path, label: entry.label } : null;
|
|
144
|
+
})
|
|
145
|
+
.filter((x) => x !== null), [dirtyIds, entryById]);
|
|
146
|
+
// Draft ids verification can still settle.
|
|
147
|
+
const unresolvedIds = useMemo(() => options.readEntryValue && entriesKnown
|
|
148
|
+
? Object.keys(drafts).filter((id) => entryById.has(id) &&
|
|
149
|
+
loadedValues[id] === undefined &&
|
|
150
|
+
verifiedBaselines[id] === undefined &&
|
|
151
|
+
!unreadableIds.has(id))
|
|
152
|
+
: [], [
|
|
153
|
+
drafts,
|
|
154
|
+
loadedValues,
|
|
155
|
+
verifiedBaselines,
|
|
156
|
+
unreadableIds,
|
|
157
|
+
entriesKnown,
|
|
158
|
+
entryById,
|
|
159
|
+
options.readEntryValue,
|
|
160
|
+
]);
|
|
161
|
+
// Async callbacks outlive the render that created them; these give them current values.
|
|
162
|
+
const latestRef = useRef({ drafts, loadedValues, unresolvedCount: 0, modifiedCount, editedFiles });
|
|
163
|
+
latestRef.current = {
|
|
164
|
+
drafts,
|
|
165
|
+
loadedValues,
|
|
166
|
+
unresolvedCount: unresolvedIds.length,
|
|
167
|
+
modifiedCount,
|
|
168
|
+
editedFiles,
|
|
169
|
+
};
|
|
170
|
+
const latestOptionsRef = useRef(options);
|
|
171
|
+
latestOptionsRef.current = options;
|
|
134
172
|
// Clear drafts when branch changes (before localStorage restore)
|
|
135
173
|
const prevBranchRef = useRef(options.branchName);
|
|
136
174
|
useEffect(() => {
|
|
137
175
|
if (prevBranchRef.current && prevBranchRef.current !== options.branchName) {
|
|
138
176
|
setDrafts({});
|
|
139
177
|
setLoadedValues({});
|
|
178
|
+
setVerifiedBaselines({});
|
|
179
|
+
setUnreadableIds(new Set());
|
|
180
|
+
requestedIdsRef.current = new Set();
|
|
181
|
+
verifyQueueRef.current = [];
|
|
140
182
|
// Base versions are file mtimes, i.e. inherently per-branch -- carrying
|
|
141
183
|
// them across a switch would compare one branch's version against
|
|
142
184
|
// another's.
|
|
@@ -261,6 +303,102 @@ export function useDraftManager(options) {
|
|
|
261
303
|
console.warn('Failed to persist drafts', err);
|
|
262
304
|
}
|
|
263
305
|
}, [drafts, storageKey]);
|
|
306
|
+
// A draft equal to its entry's loaded value is not an edit. Drops it so it
|
|
307
|
+
// neither counts as dirty nor lingers in storage.
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
const redundant = Object.keys(drafts).filter((id) => loadedValues[id] !== undefined && equal(drafts[id], loadedValues[id]));
|
|
310
|
+
if (redundant.length === 0)
|
|
311
|
+
return;
|
|
312
|
+
setDrafts((prev) => {
|
|
313
|
+
const next = { ...prev };
|
|
314
|
+
let changed = false;
|
|
315
|
+
for (const id of redundant) {
|
|
316
|
+
if (id in next && equal(next[id], loadedValues[id])) {
|
|
317
|
+
delete next[id];
|
|
318
|
+
changed = true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return changed ? next : prev;
|
|
322
|
+
});
|
|
323
|
+
}, [drafts, loadedValues]);
|
|
324
|
+
// Checks one restored draft against the server. The branch is re-read at
|
|
325
|
+
// settle time: a switch mid-read makes the result another branch's value.
|
|
326
|
+
const verifyDraft = async (job) => {
|
|
327
|
+
const id = job.entry.contentId;
|
|
328
|
+
const read = latestOptionsRef.current.readEntryValue;
|
|
329
|
+
if (!read)
|
|
330
|
+
return;
|
|
331
|
+
try {
|
|
332
|
+
const server = await read(job.entry);
|
|
333
|
+
if (latestOptionsRef.current.branchName !== job.branch)
|
|
334
|
+
return;
|
|
335
|
+
const { drafts: latestDrafts, loadedValues: latestLoaded } = latestRef.current;
|
|
336
|
+
// Gone, or the entry's own load has taken over as the baseline.
|
|
337
|
+
if (latestDrafts[id] === undefined || latestLoaded[id] !== undefined)
|
|
338
|
+
return;
|
|
339
|
+
if (equal(latestDrafts[id], server)) {
|
|
340
|
+
setDrafts((prev) => {
|
|
341
|
+
if (!(id in prev) || !equal(prev[id], server))
|
|
342
|
+
return prev;
|
|
343
|
+
const next = { ...prev };
|
|
344
|
+
delete next[id];
|
|
345
|
+
return next;
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
setVerifiedBaselines((prev) => ({ ...prev, [id]: server }));
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// Stays unverified, so the draft keeps counting as dirty.
|
|
354
|
+
if (latestOptionsRef.current.branchName !== job.branch)
|
|
355
|
+
return;
|
|
356
|
+
setUnreadableIds((prev) => new Set(prev).add(id));
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
const pumpVerification = () => {
|
|
360
|
+
while (verifyActiveRef.current < VERIFY_CONCURRENCY && verifyQueueRef.current.length > 0) {
|
|
361
|
+
const job = verifyQueueRef.current.shift();
|
|
362
|
+
if (!job)
|
|
363
|
+
break;
|
|
364
|
+
verifyActiveRef.current++;
|
|
365
|
+
void verifyDraft(job).finally(() => {
|
|
366
|
+
verifyActiveRef.current--;
|
|
367
|
+
pumpVerification();
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
// Once per branch session, reads the server value of every restored draft
|
|
372
|
+
// whose entry has not loaded. The selected entry is left to its own load,
|
|
373
|
+
// which fills `loadedValues` and lets the effect above drop a pristine draft.
|
|
374
|
+
useEffect(() => {
|
|
375
|
+
if (!options.readEntryValue || !entriesKnown)
|
|
376
|
+
return;
|
|
377
|
+
// `drafts` lags a branch switch by a render (see draftsStorageKeyRef); reading
|
|
378
|
+
// the old branch's drafts under the new branch's name would be wrong.
|
|
379
|
+
if (draftsStorageKeyRef.current !== storageKey)
|
|
380
|
+
return;
|
|
381
|
+
const todo = unresolvedIds.filter((id) => id !== currentId && !requestedIdsRef.current.has(id));
|
|
382
|
+
if (todo.length === 0)
|
|
383
|
+
return;
|
|
384
|
+
for (const id of todo) {
|
|
385
|
+
requestedIdsRef.current.add(id);
|
|
386
|
+
const entry = entryById.get(id);
|
|
387
|
+
if (entry)
|
|
388
|
+
verifyQueueRef.current.push({ entry, branch: options.branchName });
|
|
389
|
+
}
|
|
390
|
+
pumpVerification();
|
|
391
|
+
// pumpVerification/verifyDraft only touch refs and stable setters.
|
|
392
|
+
}, [unresolvedIds, currentId, storageKey]);
|
|
393
|
+
// Wakes `whenDraftsVerified` callers once nothing is left to settle.
|
|
394
|
+
useEffect(() => {
|
|
395
|
+
if (unresolvedIds.length > 0)
|
|
396
|
+
return;
|
|
397
|
+
const waiters = verifyWaitersRef.current;
|
|
398
|
+
verifyWaitersRef.current = [];
|
|
399
|
+
for (const wake of waiters)
|
|
400
|
+
wake();
|
|
401
|
+
}, [unresolvedIds]);
|
|
264
402
|
// Recomputes field errors as the user edits, or as the selected entry's
|
|
265
403
|
// schema/format changes while it stays open, so each error clears when its
|
|
266
404
|
// field is fixed or no longer required. Errors for a different entry are
|
|
@@ -316,7 +454,8 @@ export function useDraftManager(options) {
|
|
|
316
454
|
return 'ok';
|
|
317
455
|
const currentVersion = options.getEntryVersion?.(currentId);
|
|
318
456
|
// No server version known for this entry at all -- nothing to compare
|
|
319
|
-
// against, so this check has no opinion
|
|
457
|
+
// against, so this check has no opinion: saveEntry refuses a save without
|
|
458
|
+
// a token, and the server refuses a version-less update.
|
|
320
459
|
if (currentVersion === undefined)
|
|
321
460
|
return 'ok';
|
|
322
461
|
const base = draftBaseVersionsRef.current[currentId];
|
|
@@ -406,9 +545,8 @@ export function useDraftManager(options) {
|
|
|
406
545
|
// to become `saved` below, so removing the draft key is a no-op for
|
|
407
546
|
// the rendered value while fixing the "phantom dirty" bug: a draft
|
|
408
547
|
// that lingers forever is what made every fresh page load show Save
|
|
409
|
-
// enabled with zero real edits (see
|
|
410
|
-
//
|
|
411
|
-
// treated as dirty).
|
|
548
|
+
// enabled with zero real edits (see `dirtyIds` above: a draft with no
|
|
549
|
+
// baseline is conservatively treated as dirty).
|
|
412
550
|
setDrafts((prev) => {
|
|
413
551
|
if (!(currentId in prev))
|
|
414
552
|
return prev;
|
|
@@ -627,7 +765,8 @@ export function useDraftManager(options) {
|
|
|
627
765
|
const id = entry.contentId;
|
|
628
766
|
if (!drafts[id])
|
|
629
767
|
return false;
|
|
630
|
-
|
|
768
|
+
const baseline = loadedValues[id] ?? verifiedBaselines[id];
|
|
769
|
+
return baseline === undefined || !equal(drafts[id], baseline);
|
|
631
770
|
};
|
|
632
771
|
const isSelectedDirty = () => {
|
|
633
772
|
if (!currentId)
|
|
@@ -636,13 +775,29 @@ export function useDraftManager(options) {
|
|
|
636
775
|
return false;
|
|
637
776
|
return !loadedValues[currentId] || !equal(drafts[currentId], loadedValues[currentId]);
|
|
638
777
|
};
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
// Used for branch-switch guards so unsaved work in non-selected entries is not
|
|
642
|
-
// silently discarded. Derived from `modifiedCount`, so its semantics note
|
|
643
|
-
// above also applies: localStorage-restored drafts without a loaded value
|
|
644
|
-
// count as dirty.
|
|
778
|
+
// True if ANY counted draft is dirty (see `dirtyIds`). Used for branch-level
|
|
779
|
+
// guards so unsaved work in non-selected entries is not silently discarded.
|
|
645
780
|
const isAnyDirty = () => modifiedCount > 0;
|
|
781
|
+
const whenDraftsVerified = () => {
|
|
782
|
+
if (latestRef.current.unresolvedCount === 0)
|
|
783
|
+
return Promise.resolve();
|
|
784
|
+
return new Promise((resolve) => {
|
|
785
|
+
const wake = () => {
|
|
786
|
+
clearTimeout(timer);
|
|
787
|
+
resolve();
|
|
788
|
+
};
|
|
789
|
+
const timer = setTimeout(() => {
|
|
790
|
+
verifyWaitersRef.current = verifyWaitersRef.current.filter((w) => w !== wake);
|
|
791
|
+
resolve();
|
|
792
|
+
}, VERIFY_WAIT_CAP_MS);
|
|
793
|
+
verifyWaitersRef.current.push(wake);
|
|
794
|
+
});
|
|
795
|
+
};
|
|
796
|
+
const resolveUnsaved = async () => {
|
|
797
|
+
await whenDraftsVerified();
|
|
798
|
+
const { modifiedCount: count, editedFiles: files } = latestRef.current;
|
|
799
|
+
return { count, labels: files.map((f) => f.label) };
|
|
800
|
+
};
|
|
646
801
|
return {
|
|
647
802
|
drafts,
|
|
648
803
|
setDrafts,
|
|
@@ -660,6 +815,8 @@ export function useDraftManager(options) {
|
|
|
660
815
|
isDirtyForEntry,
|
|
661
816
|
isSelectedDirty,
|
|
662
817
|
isAnyDirty,
|
|
818
|
+
whenDraftsVerified,
|
|
819
|
+
resolveUnsaved,
|
|
663
820
|
fieldErrors,
|
|
664
821
|
};
|
|
665
822
|
}
|
|
@@ -40,7 +40,7 @@ export async function listAllEntries(apiClient, branch) {
|
|
|
40
40
|
...(cursor !== undefined ? { cursor } : {}),
|
|
41
41
|
});
|
|
42
42
|
if (!result.ok || !result.data)
|
|
43
|
-
throw new Error(`Refresh failed: ${result.status}`);
|
|
43
|
+
throw new Error(`Refresh failed: ${result.status}${result.error ? ` — ${result.error}` : ''}`);
|
|
44
44
|
const data = result.data;
|
|
45
45
|
for (const entry of data.entries)
|
|
46
46
|
byPath.set(entry.logicalPath, entry);
|
|
@@ -60,7 +60,7 @@ export async function fetchEntriesAndSchema(apiClient, branch, params) {
|
|
|
60
60
|
// Fetch schema from schema API
|
|
61
61
|
const schemaResult = await apiClient.schema.get({ branch });
|
|
62
62
|
if (!schemaResult.ok || !schemaResult.data) {
|
|
63
|
-
throw new Error(`Schema fetch failed: ${schemaResult.status}`);
|
|
63
|
+
throw new Error(`Schema fetch failed: ${schemaResult.status}${schemaResult.error ? ` — ${schemaResult.error}` : ''}`);
|
|
64
64
|
}
|
|
65
65
|
// Hydrate wire flatSchema: resolve schemaRef -> schema from entrySchemas dict
|
|
66
66
|
const { entrySchemas } = schemaResult.data;
|
|
@@ -85,7 +85,7 @@ export async function fetchEntriesAndSchema(apiClient, branch, params) {
|
|
|
85
85
|
entries: allEntries,
|
|
86
86
|
pagination: { hasMore: false, limit: ENTRIES_PAGE_LIMIT },
|
|
87
87
|
},
|
|
88
|
-
resolvePreviewSrc:
|
|
88
|
+
resolvePreviewSrc: params.resolvePreviewSrc,
|
|
89
89
|
flatSchema: hydratedFlatSchema,
|
|
90
90
|
});
|
|
91
91
|
return { collections, entries, availableSchemas: Object.keys(entrySchemas) };
|
|
@@ -20,7 +20,6 @@ export interface UseEntryManagerOptions {
|
|
|
20
20
|
initialSelectedId?: string;
|
|
21
21
|
branchName: string;
|
|
22
22
|
collections?: EditorCollection[];
|
|
23
|
-
previewBaseByCollection?: Record<string, string>;
|
|
24
23
|
resolvePreviewSrc: (entry: Partial<EditorEntry>) => string | undefined;
|
|
25
24
|
setBusy: (busy: boolean) => void;
|
|
26
25
|
}
|
|
@@ -46,6 +45,8 @@ export interface UseEntryManagerReturn {
|
|
|
46
45
|
handleCreateEntry: (collectionPath: LogicalPath, entryTypeName?: string) => Promise<void>;
|
|
47
46
|
renameEntry: (path: string, newSlug: string) => Promise<void>;
|
|
48
47
|
loadEntry: (entry: EditorEntry) => Promise<FormValue>;
|
|
48
|
+
/** Reads an entry's server value without recording the OCC token that `loadEntry` records. */
|
|
49
|
+
readEntryValue: (entry: EditorEntry) => Promise<FormValue>;
|
|
49
50
|
saveEntry: (entry: EditorEntry, value: FormValue) => Promise<FormValue>;
|
|
50
51
|
/**
|
|
51
52
|
* The OCC version token currently held for `contentId` ON THE BRANCH BEING
|
|
@@ -50,11 +50,9 @@ export function useEntryManager(options) {
|
|
|
50
50
|
// case -- any first visit to a branch, for the whole duration of its fetch),
|
|
51
51
|
// or a stale SWR slot. The editor then auto-selected one of those stale
|
|
52
52
|
// entries (see the selection effect below), and a save could file its OCC
|
|
53
|
-
// token under one contentId and look it up under another
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// Deriving from a stamped record fixes that structurally instead of relying
|
|
57
|
-
// on every code path remembering to clear.
|
|
53
|
+
// token under one contentId and look it up under another. Deriving from a
|
|
54
|
+
// stamped record fixes that structurally instead of relying on every code
|
|
55
|
+
// path remembering to clear.
|
|
58
56
|
const [view, setView] = useState(() => ({
|
|
59
57
|
branch: options.branchName,
|
|
60
58
|
entries: options.initialEntries,
|
|
@@ -86,6 +84,9 @@ export function useEntryManager(options) {
|
|
|
86
84
|
// branch can repopulate the map after the branch-change clear() below and
|
|
87
85
|
// poison the next save with the old branch's mtime — a deterministic 409
|
|
88
86
|
// ("modified by another editor") on save-after-switch, proven by e2e trace.
|
|
87
|
+
// The contentId half matches how useDraftManager keys drafts and loaded
|
|
88
|
+
// values, so a form value and its token always belong to the same file even
|
|
89
|
+
// when a path is deleted and recreated; it also survives a rename.
|
|
89
90
|
const entryVersionsRef = useRef(new Map());
|
|
90
91
|
const versionKey = (branch, contentId) => `${branch}:${contentId}`;
|
|
91
92
|
// PER-BRANCH monotonic tokens guarding every commit of the fetched
|
|
@@ -177,14 +178,11 @@ export function useEntryManager(options) {
|
|
|
177
178
|
.filter((e) => e.collectionPath === createModalCollection.path && e.slug)
|
|
178
179
|
.map((e) => e.slug));
|
|
179
180
|
}, [entriesState, createModalCollection]);
|
|
180
|
-
|
|
181
|
+
// The read both entry paths share; returns the raw payload so `loadEntry` can take its version.
|
|
182
|
+
const readEntryPayload = async (entry, requestBranch) => {
|
|
181
183
|
if (!entry.collectionPath) {
|
|
182
184
|
throw new Error('Entry missing collectionPath');
|
|
183
185
|
}
|
|
184
|
-
// Pin the branch this request targets: if the user switches branches
|
|
185
|
-
// while the read is in flight, the token must be recorded under the
|
|
186
|
-
// branch that actually served it, not the current one.
|
|
187
|
-
const requestBranch = options.branchName;
|
|
188
186
|
// Build path from collectionPath and slug (if it's a collection entry)
|
|
189
187
|
const path = entry.slug ? `${entry.collectionPath}/${entry.slug}` : entry.collectionPath;
|
|
190
188
|
const result = await apiClient.content.read({
|
|
@@ -192,13 +190,24 @@ export function useEntryManager(options) {
|
|
|
192
190
|
path,
|
|
193
191
|
});
|
|
194
192
|
if (!result.ok)
|
|
195
|
-
throw new Error(`Load failed: ${result.status}`);
|
|
193
|
+
throw new Error(`Load failed: ${result.status}${result.error ? ` — ${result.error}` : ''}`);
|
|
194
|
+
return result.data;
|
|
195
|
+
};
|
|
196
|
+
const loadEntry = async (entry) => {
|
|
197
|
+
// Pin the branch this request targets: if the user switches branches
|
|
198
|
+
// while the read is in flight, the token must be recorded under the
|
|
199
|
+
// branch that actually served it, not the current one.
|
|
200
|
+
const requestBranch = options.branchName;
|
|
201
|
+
const data = await readEntryPayload(entry, requestBranch);
|
|
196
202
|
// Capture OCC version token for next save
|
|
197
|
-
if (entry.contentId && typeof
|
|
198
|
-
entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId),
|
|
203
|
+
if (entry.contentId && typeof data?.version === 'number') {
|
|
204
|
+
entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), data.version);
|
|
199
205
|
}
|
|
200
|
-
return normalizeContentPayload(
|
|
206
|
+
return normalizeContentPayload(data);
|
|
201
207
|
};
|
|
208
|
+
// The same read as `loadEntry`, minus the token capture. A value read this way can be
|
|
209
|
+
// compared against but never saved against: only `loadEntry` makes an entry saveable.
|
|
210
|
+
const readEntryValue = async (entry) => normalizeContentPayload(await readEntryPayload(entry, options.branchName));
|
|
202
211
|
// Read side of the same branch-qualified token map `saveEntry` writes/reads.
|
|
203
212
|
// Deliberately keyed off the CURRENT branch (not a pinned one): callers use
|
|
204
213
|
// it to reason about the entry they are looking at right now.
|
|
@@ -220,18 +229,23 @@ export function useEntryManager(options) {
|
|
|
220
229
|
};
|
|
221
230
|
if (entry.entryType)
|
|
222
231
|
writeParams.entryType = entry.entryType;
|
|
223
|
-
const expectedVersion = entry.contentId
|
|
224
|
-
|
|
225
|
-
|
|
232
|
+
const expectedVersion = entryVersionsRef.current.get(versionKey(requestBranch, entry.contentId));
|
|
233
|
+
// Every entry saved here already exists (creates go through handleCreateModalSubmit), so
|
|
234
|
+
// no token means this entry was never successfully read on this branch and nothing could check the save
|
|
235
|
+
// against other editors' work. Refused as a conflict; the server refuses it too.
|
|
236
|
+
if (expectedVersion === undefined) {
|
|
237
|
+
throw new SaveApiError(409, 'This entry has not been loaded from the server, so the save cannot be checked ' +
|
|
238
|
+
"against other editors' changes. Reload it and try again.");
|
|
239
|
+
}
|
|
226
240
|
const writeBody = {
|
|
227
241
|
...payload,
|
|
228
|
-
|
|
242
|
+
expectedVersion,
|
|
229
243
|
};
|
|
230
244
|
const result = await apiClient.content.write(writeParams, writeBody);
|
|
231
245
|
if (!result.ok)
|
|
232
246
|
throw new SaveApiError(result.status, result.error, result.fieldErrors);
|
|
233
247
|
// Update stored version token from write response
|
|
234
|
-
if (
|
|
248
|
+
if (typeof result.data?.version === 'number') {
|
|
235
249
|
entryVersionsRef.current.set(versionKey(requestBranch, entry.contentId), result.data.version);
|
|
236
250
|
}
|
|
237
251
|
// Warning-level issues from the adopter's validateEntry hook: saved, but surface them
|
|
@@ -558,6 +572,7 @@ export function useEntryManager(options) {
|
|
|
558
572
|
handleCreateEntry,
|
|
559
573
|
renameEntry,
|
|
560
574
|
loadEntry,
|
|
575
|
+
readEntryValue,
|
|
561
576
|
saveEntry,
|
|
562
577
|
getEntryVersion,
|
|
563
578
|
collectionByPath: collectionByPath,
|
|
@@ -2,7 +2,7 @@ import React from 'react';
|
|
|
2
2
|
import type { AssetRecord } from '../../api/index.js';
|
|
3
3
|
export interface AssetCardProps {
|
|
4
4
|
asset: AssetRecord;
|
|
5
|
-
/** `
|
|
5
|
+
/** `useAssetContext().baseUrl`, the authenticated raw route - see AssetContext. */
|
|
6
6
|
baseUrl?: string;
|
|
7
7
|
/** Present in picker mode - clicking the card selects the asset. */
|
|
8
8
|
onSelect?: (asset: AssetRecord) => void;
|
|
@@ -16,10 +16,9 @@ export interface CropAreaPercent {
|
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* Convert react-easy-crop's `onCropComplete(croppedArea, croppedAreaPixels)`
|
|
19
|
-
* percentage area into a normalized `CropRect`,
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* space rather than reject a rect that was valid before rounding.
|
|
19
|
+
* percentage area into a normalized `CropRect`, clamped to [0,1] and then
|
|
20
|
+
* rounded by `roundCropRect` — the same rounding the transform URL's canonical
|
|
21
|
+
* form applies, so the stored crop is already canonical.
|
|
23
22
|
*
|
|
24
23
|
* Returns `null` if the input can't be coerced into a valid rect (e.g. a
|
|
25
24
|
* zero-area selection).
|