canopycms 0.2.0-int.119 → 0.2.0
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/CHANGELOG.md +123 -62
- package/dist/api/assets.js +20 -10
- package/dist/assets/editor-thumbnail-widths.d.ts +11 -0
- package/dist/assets/editor-thumbnail-widths.js +7 -0
- package/dist/assets/finalize.d.ts +7 -6
- package/dist/assets/finalize.js +15 -9
- package/dist/assets/materialize.d.ts +17 -2
- package/dist/assets/materialize.js +57 -4
- package/dist/assets/store-s3.d.ts +7 -3
- package/dist/assets/store-s3.js +25 -6
- package/dist/assets/types.d.ts +7 -2
- package/dist/cli/cli.js +40 -23
- package/dist/config/types.d.ts +3 -2
- package/dist/dev-content-watcher.js +8 -1
- package/dist/editor/Editor.js +60 -7
- package/dist/editor/EntryNavigator.d.ts +9 -4
- package/dist/editor/EntryNavigator.js +39 -17
- package/dist/editor/PreviewFrame.d.ts +3 -1
- package/dist/editor/PreviewFrame.js +43 -2
- package/dist/editor/PreviewPane.d.ts +2 -0
- package/dist/editor/components/BranchLockBanner.d.ts +7 -8
- package/dist/editor/components/BranchLockBanner.js +25 -2
- package/dist/editor/components/BranchRowLock.d.ts +14 -0
- package/dist/editor/components/BranchRowLock.js +21 -0
- package/dist/editor/components/EditorHeader.js +3 -18
- package/dist/editor/components/PreviewLinkToast.d.ts +5 -0
- package/dist/editor/components/PreviewLinkToast.js +33 -0
- package/dist/editor/fields/FieldLabel.d.ts +1 -1
- package/dist/editor/fields/FieldLabel.js +1 -1
- package/dist/editor/fields/ImageField.d.ts +1 -1
- package/dist/editor/fields/ImageField.js +36 -27
- package/dist/editor/hooks/editor-history.d.ts +7 -0
- package/dist/editor/hooks/editor-history.js +80 -0
- package/dist/editor/hooks/useBranchActions.js +3 -1
- package/dist/editor/hooks/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +6 -2
- package/dist/editor/hooks/useEntryManager.d.ts +9 -2
- package/dist/editor/hooks/useEntryManager.js +131 -17
- package/dist/editor/media/AssetCard.js +4 -2
- package/dist/editor/media/AssetListRow.js +4 -2
- package/dist/editor/media/CropStep.d.ts +8 -10
- package/dist/editor/media/CropStep.js +39 -27
- package/dist/editor/media/crop-availability.d.ts +6 -0
- package/dist/editor/media/crop-availability.js +19 -0
- package/dist/editor/media/crop-math.d.ts +19 -13
- package/dist/editor/media/crop-math.js +50 -11
- package/dist/editor/preview-bridge.d.ts +7 -0
- package/dist/editor/preview-bridge.js +93 -1
- package/dist/editor/preview-path.d.ts +5 -0
- package/dist/editor/preview-path.js +18 -0
- package/dist/editor/theme.d.ts +1 -0
- package/dist/editor/theme.js +38 -1
- package/dist/server.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -15,12 +15,13 @@ export type FinalizeAssetResult = {
|
|
|
15
15
|
* Run the finalize pipeline over raw bytes and persist the result.
|
|
16
16
|
*
|
|
17
17
|
* Order matters here too: dedup FIRST (after hashing, inside the pipeline) -
|
|
18
|
-
* if an identical blob already exists, return the existing meta
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* point for an asset's existence,
|
|
22
|
-
* leaves only orphaned (harmless,
|
|
23
|
-
* record pointing at a missing blob.
|
|
18
|
+
* if an identical blob already exists, return the existing meta, touching
|
|
19
|
+
* only its hidden marker and any editor thumbnails it lacks (first-name-wins).
|
|
20
|
+
* Otherwise write the original, then the optional public object, then
|
|
21
|
+
* `putMetaIfAbsent` LAST: meta is the commit point for an asset's existence,
|
|
22
|
+
* so a crash between `putOriginal` and here leaves only orphaned (harmless,
|
|
23
|
+
* content-addressed) blobs, never a meta record pointing at a missing blob.
|
|
24
|
+
* The editor thumbnails come after the commit.
|
|
24
25
|
* @internal Exported for tests.
|
|
25
26
|
*/
|
|
26
27
|
export declare function finalizeAsset(store: AssetStore, input: FinalizeInput): Promise<FinalizeAssetResult>;
|
package/dist/assets/finalize.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { runFinalizePipeline } from './pipeline.js';
|
|
7
7
|
import { ASSET_PREFIXES } from './keys.js';
|
|
8
|
+
import { storeEditorThumbnails } from './materialize.js';
|
|
8
9
|
import { getErrorMessage } from '../utils/error.js';
|
|
9
10
|
import { canopyLogWarn } from '../utils/logger.js';
|
|
10
11
|
/**
|
|
@@ -25,12 +26,13 @@ export function isValidStagingKey(key) {
|
|
|
25
26
|
* Run the finalize pipeline over raw bytes and persist the result.
|
|
26
27
|
*
|
|
27
28
|
* Order matters here too: dedup FIRST (after hashing, inside the pipeline) -
|
|
28
|
-
* if an identical blob already exists, return the existing meta
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* point for an asset's existence,
|
|
32
|
-
* leaves only orphaned (harmless,
|
|
33
|
-
* record pointing at a missing blob.
|
|
29
|
+
* if an identical blob already exists, return the existing meta, touching
|
|
30
|
+
* only its hidden marker and any editor thumbnails it lacks (first-name-wins).
|
|
31
|
+
* Otherwise write the original, then the optional public object, then
|
|
32
|
+
* `putMetaIfAbsent` LAST: meta is the commit point for an asset's existence,
|
|
33
|
+
* so a crash between `putOriginal` and here leaves only orphaned (harmless,
|
|
34
|
+
* content-addressed) blobs, never a meta record pointing at a missing blob.
|
|
35
|
+
* The editor thumbnails come after the commit.
|
|
34
36
|
* @internal Exported for tests.
|
|
35
37
|
*/
|
|
36
38
|
export async function finalizeAsset(store, input) {
|
|
@@ -50,6 +52,8 @@ export async function finalizeAsset(store, input) {
|
|
|
50
52
|
catch (err) {
|
|
51
53
|
canopyLogWarn(`[canopycms] could not restore removed asset ${meta.hash32} to the library on re-upload: ${getErrorMessage(err)}`);
|
|
52
54
|
}
|
|
55
|
+
// Stores any thumbnail the existing asset lacks; this upload's bytes are its original's.
|
|
56
|
+
await storeEditorThumbnails(store, existing, data, { skipStored: true });
|
|
53
57
|
return { ok: true, meta: existing };
|
|
54
58
|
}
|
|
55
59
|
// Either put may find its key taken (`already-exists`): by an earlier attempt that stopped
|
|
@@ -62,11 +66,13 @@ export async function finalizeAsset(store, input) {
|
|
|
62
66
|
const putResult = await store.putMetaIfAbsent(meta.hash32, meta);
|
|
63
67
|
if (putResult === 'already-exists') {
|
|
64
68
|
// Lost a race against a concurrent identical upload - the winner's meta
|
|
65
|
-
// (already committed) is authoritative; return it rather than ours.
|
|
69
|
+
// (already committed) is authoritative; return it rather than ours. The
|
|
70
|
+
// winner's own finalize stores its thumbnails.
|
|
66
71
|
const winner = await store.getMeta(meta.hash32);
|
|
67
|
-
|
|
68
|
-
return { ok: true, meta: winner };
|
|
72
|
+
return { ok: true, meta: winner ?? meta };
|
|
69
73
|
}
|
|
74
|
+
// After the commit point: a thumbnail is keyed by the committed meta's slug.
|
|
75
|
+
await storeEditorThumbnails(store, meta, data);
|
|
70
76
|
return { ok: true, meta };
|
|
71
77
|
}
|
|
72
78
|
/**
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* before that build is released) and canopycms-cdk's lazy transform Lambda.
|
|
7
7
|
*/
|
|
8
8
|
import { type ParsedTransformPath } from './transform-directives.js';
|
|
9
|
-
import type { AssetStore, CreateOnlyResult } from './types.js';
|
|
9
|
+
import type { AssetMeta, AssetStore, CreateOnlyResult } from './types.js';
|
|
10
10
|
/** Every transform output is stored under a content-addressed key, so it never changes. */
|
|
11
11
|
export declare const TRANSFORM_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
12
12
|
/**
|
|
@@ -39,11 +39,26 @@ export type StoreTransformResult = {
|
|
|
39
39
|
* `canonicalizeTransformPath`, so the stored pixels always match their key. A rejection is a fact
|
|
40
40
|
* about the asset or the URL and is never worth retrying; a thrown error belongs to the store or
|
|
41
41
|
* the environment. A 404's `error` names what was missing, so a caller answering a browser
|
|
42
|
-
* replaces it.
|
|
42
|
+
* replaces it. A caller already holding the asset's committed meta and original bytes passes them
|
|
43
|
+
* as `source`, and the store is not read.
|
|
43
44
|
*/
|
|
44
45
|
export declare function storeTransform(store: AssetStore, parsed: ParsedTransformPath, key: string, options?: {
|
|
45
46
|
tags?: Readonly<Record<string, string>>;
|
|
47
|
+
source?: {
|
|
48
|
+
meta: AssetMeta;
|
|
49
|
+
data: Uint8Array;
|
|
50
|
+
};
|
|
46
51
|
}): Promise<StoreTransformResult>;
|
|
52
|
+
/**
|
|
53
|
+
* Store the editor thumbnails of a raster asset whose meta is committed, from `data`, its
|
|
54
|
+
* original's bytes. Best effort: the raw route computes a missing one on first view, so a failure
|
|
55
|
+
* is logged, never thrown, and the first one stops the rest, which would decode the same bytes in
|
|
56
|
+
* the same environment. One width at a time, so one decode is in memory. A multi-page (animated)
|
|
57
|
+
* source is skipped. `skipStored` HEADs each key first, for an asset that may already have them.
|
|
58
|
+
*/
|
|
59
|
+
export declare function storeEditorThumbnails(store: AssetStore, meta: AssetMeta, data: Uint8Array, options?: {
|
|
60
|
+
skipStored?: boolean;
|
|
61
|
+
}): Promise<void>;
|
|
47
62
|
/** One key a build references, and where it was referenced, for the failure report. */
|
|
48
63
|
export interface MaterializeTarget {
|
|
49
64
|
/** `assets/t/{directives}/{hash32}/{slug}.{ext}`. */
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* before that build is released) and canopycms-cdk's lazy transform Lambda.
|
|
7
7
|
*/
|
|
8
8
|
import { getErrorMessage, isNodeError } from '../utils/error.js';
|
|
9
|
+
import { canopyLogWarn } from '../utils/logger.js';
|
|
9
10
|
import { ASSET_PREFIXES } from './asset-prefixes.js';
|
|
10
11
|
import { loadSharp } from './sharp-loader.js';
|
|
11
12
|
import { applyTransform } from './transform.js';
|
|
12
|
-
import {
|
|
13
|
+
import { EDITOR_THUMBNAIL_WIDTHS } from './editor-thumbnail-widths.js';
|
|
14
|
+
import { canonicalizeTransformPath, formatDirectives, } from './transform-directives.js';
|
|
13
15
|
/** Every transform output is stored under a content-addressed key, so it never changes. */
|
|
14
16
|
export const TRANSFORM_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
15
17
|
/**
|
|
@@ -25,10 +27,15 @@ export const LAZY_TRANSFORM_TAG = { key: 'canopy-transform', value: 'lazy' };
|
|
|
25
27
|
* `canonicalizeTransformPath`, so the stored pixels always match their key. A rejection is a fact
|
|
26
28
|
* about the asset or the URL and is never worth retrying; a thrown error belongs to the store or
|
|
27
29
|
* the environment. A 404's `error` names what was missing, so a caller answering a browser
|
|
28
|
-
* replaces it.
|
|
30
|
+
* replaces it. A caller already holding the asset's committed meta and original bytes passes them
|
|
31
|
+
* as `source`, and the store is not read.
|
|
29
32
|
*/
|
|
30
33
|
export async function storeTransform(store, parsed, key, options = {}) {
|
|
31
|
-
const
|
|
34
|
+
const { source } = options;
|
|
35
|
+
if (source && source.meta.hash32 !== parsed.hash32) {
|
|
36
|
+
throw new Error(`source is asset ${source.meta.hash32}, not ${parsed.hash32}`);
|
|
37
|
+
}
|
|
38
|
+
const meta = source?.meta ?? (await store.getMeta(parsed.hash32));
|
|
32
39
|
if (!meta) {
|
|
33
40
|
return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no meta in the store` };
|
|
34
41
|
}
|
|
@@ -51,7 +58,9 @@ export async function storeTransform(store, parsed, key, options = {}) {
|
|
|
51
58
|
if (requestedFormat === undefined && parsed.ext !== meta.ext) {
|
|
52
59
|
return { ok: false, status: 400, error: 'Extension does not match the source format' };
|
|
53
60
|
}
|
|
54
|
-
const original =
|
|
61
|
+
const original = source
|
|
62
|
+
? { data: source.data, ext: meta.ext }
|
|
63
|
+
: await store.readOriginal(parsed.hash32, meta.ext);
|
|
55
64
|
if (!original) {
|
|
56
65
|
return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no original in the store` };
|
|
57
66
|
}
|
|
@@ -68,6 +77,50 @@ export async function storeTransform(store, parsed, key, options = {}) {
|
|
|
68
77
|
});
|
|
69
78
|
return { ok: true, data: transformed.data, contentType: transformed.contentType, stored };
|
|
70
79
|
}
|
|
80
|
+
/** The canonical keys of an asset's `EDITOR_THUMBNAIL_WIDTHS`, the keys the raw route reads them at. */
|
|
81
|
+
function editorThumbnailTargets(meta) {
|
|
82
|
+
return Object.values(EDITOR_THUMBNAIL_WIDTHS).map((width) => {
|
|
83
|
+
const canonical = canonicalizeTransformPath([formatDirectives({ identity: false, width }), meta.hash32, `${meta.slug}.${meta.ext}`], 'any');
|
|
84
|
+
if (!canonical.ok)
|
|
85
|
+
throw new Error(canonical.error);
|
|
86
|
+
return { key: `${ASSET_PREFIXES.transform}/${canonical.canonicalPath}`, parsed: canonical };
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Store the editor thumbnails of a raster asset whose meta is committed, from `data`, its
|
|
91
|
+
* original's bytes. Best effort: the raw route computes a missing one on first view, so a failure
|
|
92
|
+
* is logged, never thrown, and the first one stops the rest, which would decode the same bytes in
|
|
93
|
+
* the same environment. One width at a time, so one decode is in memory. A multi-page (animated)
|
|
94
|
+
* source is skipped. `skipStored` HEADs each key first, for an asset that may already have them.
|
|
95
|
+
*/
|
|
96
|
+
export async function storeEditorThumbnails(store, meta, data, options = {}) {
|
|
97
|
+
if (meta.kind !== 'raster')
|
|
98
|
+
return;
|
|
99
|
+
let failure;
|
|
100
|
+
try {
|
|
101
|
+
// An animated source re-encodes every frame at every width, over 15 s for a large GIF: too
|
|
102
|
+
// long to hold an upload for, so its first view transforms it instead. The probe decodes no
|
|
103
|
+
// pixels.
|
|
104
|
+
const sharp = await loadSharp();
|
|
105
|
+
if (((await sharp(data).metadata()).pages ?? 1) > 1)
|
|
106
|
+
return;
|
|
107
|
+
for (const { key, parsed } of editorThumbnailTargets(meta)) {
|
|
108
|
+
if (options.skipStored && (await store.hasPublicObject(key)))
|
|
109
|
+
continue;
|
|
110
|
+
const result = await storeTransform(store, parsed, key, { source: { meta, data } });
|
|
111
|
+
if (!result.ok) {
|
|
112
|
+
failure = `${key}: ${result.error}`;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
failure = getErrorMessage(err);
|
|
119
|
+
}
|
|
120
|
+
if (failure !== undefined) {
|
|
121
|
+
canopyLogWarn(`[canopycms] could not store editor thumbnails of ${meta.hash32}: ${failure}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
71
124
|
/** The `schemaVersion` of every `MaterializeReport`; a consumer gating on the JSON checks it. */
|
|
72
125
|
export const MATERIALIZE_REPORT_SCHEMA_VERSION = 1;
|
|
73
126
|
/** sharp cannot load in this process, so nothing missing can be produced here. */
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* six asset prefixes, plus a caller's output prefix for `copyPublicObject`.
|
|
6
6
|
*/
|
|
7
7
|
import { type AssetPrefixes } from './keys.js';
|
|
8
|
-
import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, HiddenAssetMarker, ListedAssetMeta, PublicObject, StagedUploadTarget } from './types.js';
|
|
8
|
+
import type { AssetMeta, AssetStore, BeginUploadInput, CreateOnlyResult, HiddenAssetMarker, ListedAssetMeta, PresignedRead, PublicObject, StagedUploadTarget } from './types.js';
|
|
9
9
|
export interface S3AssetStoreOptions {
|
|
10
10
|
bucket: string;
|
|
11
11
|
region: string;
|
|
@@ -103,8 +103,12 @@ export declare class S3AssetStore implements AssetStore {
|
|
|
103
103
|
hasPublicObject(key: string): Promise<boolean>;
|
|
104
104
|
listPublicObjectKeys(prefix: string): AsyncIterable<string>;
|
|
105
105
|
private listKeys;
|
|
106
|
-
/**
|
|
107
|
-
|
|
106
|
+
/**
|
|
107
|
+
* HEADs first: signing is local and succeeds for a missing key too. A URL signed with temporary
|
|
108
|
+
* credentials dies when they expire, whatever its own expiry, so `maxAgeSeconds` also stops short
|
|
109
|
+
* of the credentials' `expiration` when the provider reports one.
|
|
110
|
+
*/
|
|
111
|
+
presignPublicObjectRead(key: string): Promise<PresignedRead | null>;
|
|
108
112
|
putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
|
|
109
113
|
getMeta(hash32: string): Promise<AssetMeta | null>;
|
|
110
114
|
/**
|
package/dist/assets/store-s3.js
CHANGED
|
@@ -14,8 +14,15 @@ const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
|
14
14
|
/** What `metaKey` and `hiddenKey` put after the hash32. */
|
|
15
15
|
const JSON_SUFFIX = '.json';
|
|
16
16
|
const PRESIGN_EXPIRY_SECONDS = 15 * 60;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* A presigned read is signed at the start of its window, so every read of one key inside a window
|
|
19
|
+
* gets the same URL, and a browser can reuse both the redirect to it and the object behind it.
|
|
20
|
+
*/
|
|
21
|
+
const PRESIGNED_READ_WINDOW_SECONDS = 60 * 60;
|
|
22
|
+
/** Valid a full window past the window's end, so a redirect cached until then never expires first. */
|
|
23
|
+
const PRESIGNED_READ_EXPIRY_SECONDS = 2 * PRESIGNED_READ_WINDOW_SECONDS;
|
|
24
|
+
/** How long before its signing credentials expire a presigned read stops being reusable. */
|
|
25
|
+
const CREDENTIAL_EXPIRY_MARGIN_SECONDS = 60;
|
|
19
26
|
function matchesAwsError(err, name, httpStatusCode) {
|
|
20
27
|
if (!(err instanceof Error))
|
|
21
28
|
return false;
|
|
@@ -248,13 +255,25 @@ export class S3AssetStore {
|
|
|
248
255
|
}
|
|
249
256
|
}
|
|
250
257
|
}
|
|
251
|
-
/**
|
|
258
|
+
/**
|
|
259
|
+
* HEADs first: signing is local and succeeds for a missing key too. A URL signed with temporary
|
|
260
|
+
* credentials dies when they expire, whatever its own expiry, so `maxAgeSeconds` also stops short
|
|
261
|
+
* of the credentials' `expiration` when the provider reports one.
|
|
262
|
+
*/
|
|
252
263
|
async presignPublicObjectRead(key) {
|
|
253
264
|
if (!(await this.hasPublicObject(key)))
|
|
254
265
|
return null;
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
266
|
+
// Read before signing: a provider refresh in between leaves the signing credentials the newer.
|
|
267
|
+
const { expiration } = await this.client.config.credentials();
|
|
268
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
269
|
+
const windowStart = nowSeconds - (nowSeconds % PRESIGNED_READ_WINDOW_SECONDS);
|
|
270
|
+
const url = await getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: PRESIGNED_READ_EXPIRY_SECONDS, signingDate: new Date(windowStart * 1000) });
|
|
271
|
+
let maxAgeSeconds = windowStart + PRESIGNED_READ_WINDOW_SECONDS - nowSeconds;
|
|
272
|
+
if (expiration) {
|
|
273
|
+
const credentialSeconds = Math.floor(expiration.getTime() / 1000) - nowSeconds;
|
|
274
|
+
maxAgeSeconds = Math.min(maxAgeSeconds, credentialSeconds - CREDENTIAL_EXPIRY_MARGIN_SECONDS);
|
|
275
|
+
}
|
|
276
|
+
return { url, maxAgeSeconds: Math.max(0, maxAgeSeconds) };
|
|
258
277
|
}
|
|
259
278
|
async putMetaIfAbsent(hash32, meta) {
|
|
260
279
|
return this.putIfAbsent({
|
package/dist/assets/types.d.ts
CHANGED
|
@@ -50,6 +50,11 @@ export interface PublicObject {
|
|
|
50
50
|
contentDisposition?: string;
|
|
51
51
|
cacheControl?: string;
|
|
52
52
|
}
|
|
53
|
+
/** A presigned GET, and how long a client may keep reusing that URL (0: not at all). */
|
|
54
|
+
export interface PresignedRead {
|
|
55
|
+
url: string;
|
|
56
|
+
maxAgeSeconds: number;
|
|
57
|
+
}
|
|
53
58
|
/**
|
|
54
59
|
* What a create-only write did. Every key the store writes this way is content-addressed, so no
|
|
55
60
|
* correct writer ever replaces an object: `already-exists` means an object was already at the key
|
|
@@ -102,8 +107,8 @@ export interface AssetStore {
|
|
|
102
107
|
hasPublicObject(key: string): Promise<boolean>;
|
|
103
108
|
/** Every public object key under `prefix`, in the store's own pages. S3 only. */
|
|
104
109
|
listPublicObjectKeys?(prefix: string): AsyncIterable<string>;
|
|
105
|
-
/** A
|
|
106
|
-
presignPublicObjectRead?(key: string): Promise<
|
|
110
|
+
/** A URL a browser can GET `key` from directly, or `null` if absent. S3 only. */
|
|
111
|
+
presignPublicObjectRead?(key: string): Promise<PresignedRead | null>;
|
|
107
112
|
/**
|
|
108
113
|
* Meta is create-only and never deleted: every transform not yet stored, at upload, release or
|
|
109
114
|
* after a lazy output expires, is computed from it, and content anywhere may still name the file.
|
package/dist/cli/cli.js
CHANGED
|
@@ -13321,6 +13321,21 @@ var init_generate_ai_content = __esm({
|
|
|
13321
13321
|
}
|
|
13322
13322
|
});
|
|
13323
13323
|
|
|
13324
|
+
// dist/utils/logger.js
|
|
13325
|
+
function canopyLogWarn3(...args) {
|
|
13326
|
+
active3.warn(...args);
|
|
13327
|
+
}
|
|
13328
|
+
function canopyLogError2(...args) {
|
|
13329
|
+
active3.error(...args);
|
|
13330
|
+
}
|
|
13331
|
+
var active3;
|
|
13332
|
+
var init_logger3 = __esm({
|
|
13333
|
+
"dist/utils/logger.js"() {
|
|
13334
|
+
"use strict";
|
|
13335
|
+
active3 = console;
|
|
13336
|
+
}
|
|
13337
|
+
});
|
|
13338
|
+
|
|
13324
13339
|
// dist/assets/asset-prefixes.js
|
|
13325
13340
|
var ASSET_PREFIXES;
|
|
13326
13341
|
var init_asset_prefixes = __esm({
|
|
@@ -13339,21 +13354,6 @@ var init_asset_prefixes = __esm({
|
|
|
13339
13354
|
}
|
|
13340
13355
|
});
|
|
13341
13356
|
|
|
13342
|
-
// dist/utils/logger.js
|
|
13343
|
-
function canopyLogWarn3(...args) {
|
|
13344
|
-
active3.warn(...args);
|
|
13345
|
-
}
|
|
13346
|
-
function canopyLogError2(...args) {
|
|
13347
|
-
active3.error(...args);
|
|
13348
|
-
}
|
|
13349
|
-
var active3;
|
|
13350
|
-
var init_logger3 = __esm({
|
|
13351
|
-
"dist/utils/logger.js"() {
|
|
13352
|
-
"use strict";
|
|
13353
|
-
active3 = console;
|
|
13354
|
-
}
|
|
13355
|
-
});
|
|
13356
|
-
|
|
13357
13357
|
// dist/assets/sharp-loader.js
|
|
13358
13358
|
function loadSharp() {
|
|
13359
13359
|
if (!loading) {
|
|
@@ -13723,7 +13723,11 @@ var init_transform = __esm({
|
|
|
13723
13723
|
|
|
13724
13724
|
// dist/assets/materialize.js
|
|
13725
13725
|
async function storeTransform(store, parsed, key, options = {}) {
|
|
13726
|
-
const
|
|
13726
|
+
const { source } = options;
|
|
13727
|
+
if (source && source.meta.hash32 !== parsed.hash32) {
|
|
13728
|
+
throw new Error(`source is asset ${source.meta.hash32}, not ${parsed.hash32}`);
|
|
13729
|
+
}
|
|
13730
|
+
const meta = source?.meta ?? await store.getMeta(parsed.hash32);
|
|
13727
13731
|
if (!meta) {
|
|
13728
13732
|
return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no meta in the store` };
|
|
13729
13733
|
}
|
|
@@ -13741,7 +13745,7 @@ async function storeTransform(store, parsed, key, options = {}) {
|
|
|
13741
13745
|
if (requestedFormat === void 0 && parsed.ext !== meta.ext) {
|
|
13742
13746
|
return { ok: false, status: 400, error: "Extension does not match the source format" };
|
|
13743
13747
|
}
|
|
13744
|
-
const original = await store.readOriginal(parsed.hash32, meta.ext);
|
|
13748
|
+
const original = source ? { data: source.data, ext: meta.ext } : await store.readOriginal(parsed.hash32, meta.ext);
|
|
13745
13749
|
if (!original) {
|
|
13746
13750
|
return { ok: false, status: 404, error: `Asset ${parsed.hash32} has no original in the store` };
|
|
13747
13751
|
}
|
|
@@ -14144,7 +14148,7 @@ function matchesAwsError(err2, name, httpStatusCode) {
|
|
|
14144
14148
|
const shaped = err2;
|
|
14145
14149
|
return shaped.name === name || shaped.$metadata?.httpStatusCode === httpStatusCode;
|
|
14146
14150
|
}
|
|
14147
|
-
var DEFAULT_MAX_UPLOAD_BYTES, JSON_SUFFIX, PRESIGN_EXPIRY_SECONDS, PRESIGNED_READ_EXPIRY_SECONDS, isPreconditionFailed, isConditionalRequestConflict, CONFLICT_ATTEMPTS, CONFLICT_BASE_DELAY_MS, isNoSuchKey, S3AssetStore;
|
|
14151
|
+
var DEFAULT_MAX_UPLOAD_BYTES, JSON_SUFFIX, PRESIGN_EXPIRY_SECONDS, PRESIGNED_READ_WINDOW_SECONDS, PRESIGNED_READ_EXPIRY_SECONDS, CREDENTIAL_EXPIRY_MARGIN_SECONDS, isPreconditionFailed, isConditionalRequestConflict, CONFLICT_ATTEMPTS, CONFLICT_BASE_DELAY_MS, isNoSuchKey, S3AssetStore;
|
|
14148
14152
|
var init_store_s3 = __esm({
|
|
14149
14153
|
"dist/assets/store-s3.js"() {
|
|
14150
14154
|
"use strict";
|
|
@@ -14153,7 +14157,9 @@ var init_store_s3 = __esm({
|
|
|
14153
14157
|
DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
14154
14158
|
JSON_SUFFIX = ".json";
|
|
14155
14159
|
PRESIGN_EXPIRY_SECONDS = 15 * 60;
|
|
14156
|
-
|
|
14160
|
+
PRESIGNED_READ_WINDOW_SECONDS = 60 * 60;
|
|
14161
|
+
PRESIGNED_READ_EXPIRY_SECONDS = 2 * PRESIGNED_READ_WINDOW_SECONDS;
|
|
14162
|
+
CREDENTIAL_EXPIRY_MARGIN_SECONDS = 60;
|
|
14157
14163
|
isPreconditionFailed = (err2) => matchesAwsError(err2, "PreconditionFailed", 412);
|
|
14158
14164
|
isConditionalRequestConflict = (err2) => err2 instanceof Error && err2.name === "ConditionalRequestConflict";
|
|
14159
14165
|
CONFLICT_ATTEMPTS = 4;
|
|
@@ -14358,13 +14364,24 @@ var init_store_s3 = __esm({
|
|
|
14358
14364
|
}
|
|
14359
14365
|
}
|
|
14360
14366
|
}
|
|
14361
|
-
/**
|
|
14367
|
+
/**
|
|
14368
|
+
* HEADs first: signing is local and succeeds for a missing key too. A URL signed with temporary
|
|
14369
|
+
* credentials dies when they expire, whatever its own expiry, so `maxAgeSeconds` also stops short
|
|
14370
|
+
* of the credentials' `expiration` when the provider reports one.
|
|
14371
|
+
*/
|
|
14362
14372
|
async presignPublicObjectRead(key) {
|
|
14363
14373
|
if (!await this.hasPublicObject(key))
|
|
14364
14374
|
return null;
|
|
14365
|
-
|
|
14366
|
-
|
|
14367
|
-
|
|
14375
|
+
const { expiration } = await this.client.config.credentials();
|
|
14376
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
14377
|
+
const windowStart = nowSeconds - nowSeconds % PRESIGNED_READ_WINDOW_SECONDS;
|
|
14378
|
+
const url = await getSignedUrl(this.client, new GetObjectCommand({ Bucket: this.bucket, Key: key }), { expiresIn: PRESIGNED_READ_EXPIRY_SECONDS, signingDate: new Date(windowStart * 1e3) });
|
|
14379
|
+
let maxAgeSeconds = windowStart + PRESIGNED_READ_WINDOW_SECONDS - nowSeconds;
|
|
14380
|
+
if (expiration) {
|
|
14381
|
+
const credentialSeconds = Math.floor(expiration.getTime() / 1e3) - nowSeconds;
|
|
14382
|
+
maxAgeSeconds = Math.min(maxAgeSeconds, credentialSeconds - CREDENTIAL_EXPIRY_MARGIN_SECONDS);
|
|
14383
|
+
}
|
|
14384
|
+
return { url, maxAgeSeconds: Math.max(0, maxAgeSeconds) };
|
|
14368
14385
|
}
|
|
14369
14386
|
async putMetaIfAbsent(hash32, meta) {
|
|
14370
14387
|
return this.putIfAbsent({
|
package/dist/config/types.d.ts
CHANGED
|
@@ -144,8 +144,9 @@ export interface ReferenceFieldConfig extends BaseFieldConfig {
|
|
|
144
144
|
export interface ImageFieldConfig extends BaseFieldConfig {
|
|
145
145
|
type: 'image';
|
|
146
146
|
/**
|
|
147
|
-
* "W:H" aspect ratio (e.g. "16:9", "1:1")
|
|
148
|
-
*
|
|
147
|
+
* "W:H" aspect ratio (e.g. "16:9", "1:1"). Picking an image opens a crop step locked to it,
|
|
148
|
+
* and Crop keeps that shape. Without it, a pick isn't cropped and Crop is free-form.
|
|
149
|
+
* Validated at config-parse time: positive integers on both sides.
|
|
149
150
|
*/
|
|
150
151
|
aspect?: string;
|
|
151
152
|
/** Allow an empty `alt`. Default: alt text is required (accessibility). */
|
|
@@ -194,7 +194,14 @@ export function startDevContentWatcher(services, options = {}) {
|
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
196
|
};
|
|
197
|
-
|
|
197
|
+
// A save can truncate and then write. chokidar's 50 ms per-path throttle passes only the first
|
|
198
|
+
// `change`, which can land between the two steps: a check then reads a half-written file and
|
|
199
|
+
// reports a divergence nothing retracts. `awaitWriteFinish` emits only once the file's size has
|
|
200
|
+
// held still, and bypasses that throttle.
|
|
201
|
+
const watcher = chokidar.watch(workingTreeContentDir, {
|
|
202
|
+
ignoreInitial: true,
|
|
203
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 25 },
|
|
204
|
+
});
|
|
198
205
|
watcher.on('add', () => void check());
|
|
199
206
|
watcher.on('change', () => void check());
|
|
200
207
|
watcher.on('unlink', () => void check());
|
package/dist/editor/Editor.js
CHANGED
|
@@ -9,6 +9,8 @@ const noop = () => { };
|
|
|
9
9
|
import { EntryNavigator } from './EntryNavigator.js';
|
|
10
10
|
import { FormRenderer } from './FormRenderer.js';
|
|
11
11
|
import { PreviewPane } from './PreviewPane.js';
|
|
12
|
+
import { displayPreviewPath, previewPageKey } from './preview-path.js';
|
|
13
|
+
import { showPreviewLinkOutcome } from './components/PreviewLinkToast.js';
|
|
12
14
|
import { clientOperatingStrategy } from '../operating-mode/client.js';
|
|
13
15
|
import { EditorShell } from './shell/EditorShell.js';
|
|
14
16
|
import { PaneLayout } from './shell/PaneLayout.js';
|
|
@@ -31,7 +33,7 @@ import { EntrySchemaUnavailableError } from './hooks/useEntryManager.js';
|
|
|
31
33
|
import { useEntryLinkResolution } from './hooks/useEntryLinkResolution.js';
|
|
32
34
|
import { useReferenceResolution } from './hooks/useReferenceResolution.js';
|
|
33
35
|
import { usePreviewMarks } from './hooks/usePreviewMarks.js';
|
|
34
|
-
import { BranchLockBanner, EditorHeader, EditorRail } from './components/index.js';
|
|
36
|
+
import { BranchLockBanner, describeBranchLock, EditorHeader, EditorRail } from './components/index.js';
|
|
35
37
|
import { showSaveAllResult } from './components/SaveAllResult.js';
|
|
36
38
|
import { RenameEntryModal } from './components/RenameEntryModal.js';
|
|
37
39
|
import { QuickOpenPalette } from './components/QuickOpenPalette.js';
|
|
@@ -60,6 +62,7 @@ import { findFieldTarget, focusFieldTarget } from './field-target.js';
|
|
|
60
62
|
import { UndoToastMessage } from './components/UndoToastMessage.js';
|
|
61
63
|
import { getNotificationDuration } from './utils/env.js';
|
|
62
64
|
import { MayHaveLandedError } from './unanswered.js';
|
|
65
|
+
import { sanitizeBranchName } from '../paths/branch-name.js';
|
|
63
66
|
// Stable fallbacks, so reference resolution's memo and effect don't re-run on every render
|
|
64
67
|
// while no entry is open.
|
|
65
68
|
const EMPTY_SCHEMA = [];
|
|
@@ -155,7 +158,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
155
158
|
labels: [],
|
|
156
159
|
}));
|
|
157
160
|
const applySubmitStampsRef = useRef(() => { });
|
|
158
|
-
const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
|
|
161
|
+
const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
|
|
159
162
|
initialBranch: branchName,
|
|
160
163
|
operatingMode,
|
|
161
164
|
setBusy: setBranchesLoading,
|
|
@@ -176,6 +179,12 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
176
179
|
// of unlocked that invites a click the server rejects -- and the pane shows a
|
|
177
180
|
// loading state during that window anyway.
|
|
178
181
|
const branchContentLocked = currentBranch?.writeBlocked ?? true;
|
|
182
|
+
const branchLoading = !currentBranch && (branchesLoading || branchSummaries.length === 0);
|
|
183
|
+
// A row's lock reason on an unanswered branch would be "details could not be loaded", which is
|
|
184
|
+
// not yet true; once the fetch has failed it is, and the lock says so.
|
|
185
|
+
const branchRowsPending = branchLoading && !branchesLoadFailed;
|
|
186
|
+
// Set once the branch actions below exist; they depend on the drafts, which depend on the entries.
|
|
187
|
+
const switchBranchRef = useRef(async () => { });
|
|
179
188
|
// 2. Entry manager (depends on branchNameState, owns selectedPath)
|
|
180
189
|
const { selectedPath, setSelectedPath, closeEntry, entries: entriesState, collections: collectionsFromApi, currentEntry, availableSchemas, entriesInitializing, entriesLoadFailed, navigatorOpen, setNavigatorOpen, refreshEntries, refreshContent, handleCreateEntry, renameEntry, loadEntry, readEntryValue, saveEntry, getEntryVersion, adoptEntryVersion, followMovedEntry, recreateEntry, createModalOpen, createModalCollection, createModalError, createModalCreating, createModalExistingSlugs, handleCreateModalSubmit, closeCreateModal, } = useEntryManager({
|
|
181
190
|
initialEntries: entries,
|
|
@@ -191,6 +200,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
191
200
|
}),
|
|
192
201
|
resolvePreviewRoute: (entry) => buildPreviewRoute(entry, { previewBaseByCollection, contentRoot }),
|
|
193
202
|
setBusy: setEntriesLoading,
|
|
203
|
+
switchBranch: (branch) => switchBranchRef.current(branch),
|
|
204
|
+
// To the entry menu, which names the entry opened; a dialog keeps focus.
|
|
205
|
+
onHistoryRestore: () => requestAnimationFrame(() => {
|
|
206
|
+
if (!document.activeElement?.closest('[role="dialog"]')) {
|
|
207
|
+
navigatorToggleRef.current?.focus({ preventScroll: true });
|
|
208
|
+
}
|
|
209
|
+
}),
|
|
194
210
|
});
|
|
195
211
|
// The content overlay and a maximized preview hide the form; `navigatorOpen` is only the
|
|
196
212
|
// overlay's. Anything that focuses something in the form reveals it first. Opening an entry
|
|
@@ -205,6 +221,26 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
205
221
|
setSelectedPath(path);
|
|
206
222
|
setNavigatorOpen(false);
|
|
207
223
|
};
|
|
224
|
+
// A preview link opens its entry, or a page no entry has in a new tab. The opener is cut by hand,
|
|
225
|
+
// since `noopener` would hide whether the browser blocked the tab.
|
|
226
|
+
const openPreviewLink = (href) => {
|
|
227
|
+
const mount = { basePath, previewPrefix };
|
|
228
|
+
const base = window.location.href;
|
|
229
|
+
const page = previewPageKey(href, mount, base);
|
|
230
|
+
const entry = page === undefined
|
|
231
|
+
? undefined
|
|
232
|
+
: entriesState.find((e) => e.previewSrc && previewPageKey(e.previewSrc, mount, base) === page);
|
|
233
|
+
if (entry) {
|
|
234
|
+
openEntry(entry.path);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const opened = window.open(href, '_blank');
|
|
238
|
+
if (opened)
|
|
239
|
+
opened.opener = null;
|
|
240
|
+
const url = new URL(href);
|
|
241
|
+
const shown = url.origin === window.location.origin ? `${url.pathname}${url.search}` : href;
|
|
242
|
+
showPreviewLinkOutcome(href, displayPreviewPath(shown, mount), Boolean(opened));
|
|
243
|
+
};
|
|
208
244
|
// An open overlay docks open, as the URL's ask for no entry does at load; `navigatorOpen` is only
|
|
209
245
|
// the overlay's, so a docked panel never resurfaces as one. Waits for the stored preferences, so
|
|
210
246
|
// the open state it sets lands on them, not on the defaults they replace.
|
|
@@ -270,6 +306,17 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
270
306
|
onBranchCreated: addCreatedBranch,
|
|
271
307
|
userId: userContext?.userId,
|
|
272
308
|
});
|
|
309
|
+
switchBranchRef.current = async (branch) => {
|
|
310
|
+
const listed = (name) => name === branch || name === sanitizeBranchName(branch);
|
|
311
|
+
if (branchSummaries.length > 0 && !branchSummaries.some((b) => listed(b.name))) {
|
|
312
|
+
notifications.show({
|
|
313
|
+
message: `Branch “${branch}” no longer exists or you can no longer open it, so you're still on “${branchNameState}”.`,
|
|
314
|
+
color: 'yellow',
|
|
315
|
+
});
|
|
316
|
+
throw new Error(`Branch ${branch} is not listed`);
|
|
317
|
+
}
|
|
318
|
+
await handleBranchChange(branch);
|
|
319
|
+
};
|
|
273
320
|
// 5. Comment system (depends on branchNameState)
|
|
274
321
|
const { comments, getCommentUser, focusedFieldPath, highlightThreadId, commentsPanelOpen, setCommentsPanelOpen, handleAddComment, handleResolveThread, handleJumpToField, handleJumpToEntry, handleJumpToBranch, } = useCommentSystem({
|
|
275
322
|
branchName: branchNameState,
|
|
@@ -859,8 +906,8 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
859
906
|
setDeletingEntryReferencedBy(result.referencedBy);
|
|
860
907
|
return;
|
|
861
908
|
}
|
|
862
|
-
if (result.ok
|
|
863
|
-
closeEntry();
|
|
909
|
+
if (result.ok)
|
|
910
|
+
closeEntry(deletingEntryPath, branchNameState);
|
|
864
911
|
closeDeleteEntryModal();
|
|
865
912
|
}
|
|
866
913
|
finally {
|
|
@@ -1208,7 +1255,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1208
1255
|
if (maximized && !contentPanelDocked)
|
|
1209
1256
|
setNavigatorOpen(false);
|
|
1210
1257
|
setPreviewMaximized(maximized);
|
|
1211
|
-
}, resizing: paneResizing, siteChrome: siteChrome, onSiteChromeChange: setSiteChrome, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
|
|
1258
|
+
}, resizing: paneResizing, siteChrome: siteChrome, onSiteChromeChange: setSiteChrome, onNavigate: openPreviewLink, notice: previewError && (_jsx(Alert, { color: "red", title: "Preview error", withCloseButton: true, onClose: () => setPreviewError(null), radius: 0, children: previewErrorField
|
|
1212
1259
|
? `${previewErrorField}: ${previewError.message}`
|
|
1213
1260
|
: previewError.message })) })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "var(--mantine-color-body)", style: {
|
|
1214
1261
|
display: 'flex',
|
|
@@ -1266,7 +1313,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1266
1313
|
missing: e.status === 'missing' || undefined,
|
|
1267
1314
|
noPage: !e.previewSrc || undefined,
|
|
1268
1315
|
readOnly: e.canEdit === false || undefined,
|
|
1269
|
-
})), selectedPath: selectedPath, onSelect: openEntry, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: userIsAdmin ? handleReorderEntry : undefined, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked,
|
|
1316
|
+
})), selectedPath: selectedPath, onSelect: openEntry, onTreeControllerReady: handleTreeControllerReady, expandedStateRef: treeExpandedStateRef, onExpandedStateChange: handleExpandedStateChange, onDeleteEntry: handleDeleteEntry, onRenameEntry: handleRenameEntry, onReorderEntry: userIsAdmin ? handleReorderEntry : undefined, hiddenRootPath: hiddenRootPath, loading: entriesInitializing, readOnly: branchContentLocked, readOnlyReason: branchRowsPending
|
|
1317
|
+
? undefined
|
|
1318
|
+
: describeBranchLock({
|
|
1319
|
+
branchStatus: currentBranch?.status,
|
|
1320
|
+
branchReadOnly: currentBranch?.readOnly,
|
|
1321
|
+
branchWriteBlocked: branchContentLocked,
|
|
1322
|
+
}), dirtyPaths: dirtyPaths, revealCollection: collectionReveal, onRevealed: () => setCollectionReveal(undefined) }) }) }));
|
|
1270
1323
|
return (_jsx(CanopyCMSProvider, { ...(themeOptions ?? {}), children: _jsx(AssetContextProvider, { basePath: basePath, children: _jsx(EditorShell, { topBar: _jsx(EditorHeader, { currentEntry: currentEntry, branchName: branchNameState, operatingMode: operatingMode, branchStatus: currentBranch?.status, busy: busy, breadcrumbSegments: breadcrumbSegments, editedFiles: editedFiles, modifiedCount: modifiedCount,
|
|
1271
1324
|
// A draft kept for an unavailable entry stays in storage but cannot be saved.
|
|
1272
1325
|
hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
|
|
@@ -1279,7 +1332,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1279
1332
|
// fail-closed computation of "are writes blocked" in this component.
|
|
1280
1333
|
branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
|
|
1281
1334
|
// A failed fetch leaves the list empty too; the banner says that one.
|
|
1282
|
-
branchLoading:
|
|
1335
|
+
branchLoading: branchLoading, onCollectionOpen: revealCollection, navigatorToggleRef: navigatorToggleRef, branchToggleRef: branchToggleRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), entryUnavailable: currentEntryUnavailable, onEntrySelect: openEntry, onBranchReloadData: () => {
|
|
1283
1336
|
void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
|
|
1284
1337
|
}, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onSave: currentEntryUnavailable ? noop : handleSave, onSaveAll: runSaveAll, saveAllProgress: saveAllProgress, saving: singleSaving?.branch === branchNameState &&
|
|
1285
1338
|
singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
|
|
@@ -62,12 +62,17 @@ export interface EntryNavigatorProps {
|
|
|
62
62
|
/** When true and the tree is empty, show a loading indicator instead of "No content" */
|
|
63
63
|
loading?: boolean;
|
|
64
64
|
/**
|
|
65
|
-
* When true,
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
65
|
+
* When true, replaces each row's mutation menu (create, rename, delete, reorder, add/edit
|
|
66
|
+
* collection) with a lock -- selection/navigation stays enabled. Set on a branch that takes no
|
|
67
|
+
* writes, where they would only 403 after the fact. An entry the user may not edit keeps its own
|
|
68
|
+
* lock and gets none here. Default false.
|
|
69
69
|
*/
|
|
70
70
|
readOnly?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Why the branch takes no writes (`describeBranchLock`), the tooltip of each row's lock. While
|
|
73
|
+
* readOnly without one, the lock's slot stays empty.
|
|
74
|
+
*/
|
|
75
|
+
readOnlyReason?: string;
|
|
71
76
|
/** Entry paths with unsaved changes; each shows a dot, and so does every collection holding one. */
|
|
72
77
|
dirtyPaths?: ReadonlySet<string>;
|
|
73
78
|
/**
|