canopycms 0.2.0-int.119 → 0.2.0-int.121
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 +102 -46
- 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/editor/Editor.js +37 -5
- package/dist/editor/EntryNavigator.d.ts +9 -4
- package/dist/editor/EntryNavigator.js +36 -16
- 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 +12 -0
- package/dist/editor/components/BranchRowLock.js +19 -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/useBranchManager.d.ts +2 -0
- package/dist/editor/hooks/useBranchManager.js +1 -0
- 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 +17 -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
|
@@ -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). */
|
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';
|
|
@@ -155,7 +157,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
155
157
|
labels: [],
|
|
156
158
|
}));
|
|
157
159
|
const applySubmitStampsRef = useRef(() => { });
|
|
158
|
-
const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
|
|
160
|
+
const { branchName: branchNameState, setBranchName, branchSummaries, currentBranch, branchesLoadFailed, addCreatedBranch, handleSubmit, handleWithdraw, handleRequestChanges, handleDelete, handleReloadBranchData, loadBranches, } = useBranchManager({
|
|
159
161
|
initialBranch: branchName,
|
|
160
162
|
operatingMode,
|
|
161
163
|
setBusy: setBranchesLoading,
|
|
@@ -176,6 +178,10 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
176
178
|
// of unlocked that invites a click the server rejects -- and the pane shows a
|
|
177
179
|
// loading state during that window anyway.
|
|
178
180
|
const branchContentLocked = currentBranch?.writeBlocked ?? true;
|
|
181
|
+
const branchLoading = !currentBranch && (branchesLoading || branchSummaries.length === 0);
|
|
182
|
+
// A row's lock reason on an unanswered branch would be "details could not be loaded", which is
|
|
183
|
+
// not yet true; once the fetch has failed it is, and the lock says so.
|
|
184
|
+
const branchRowsPending = branchLoading && !branchesLoadFailed;
|
|
179
185
|
// 2. Entry manager (depends on branchNameState, owns selectedPath)
|
|
180
186
|
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
187
|
initialEntries: entries,
|
|
@@ -205,6 +211,26 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
205
211
|
setSelectedPath(path);
|
|
206
212
|
setNavigatorOpen(false);
|
|
207
213
|
};
|
|
214
|
+
// A preview link opens its entry, or a page no entry has in a new tab. The opener is cut by hand,
|
|
215
|
+
// since `noopener` would hide whether the browser blocked the tab.
|
|
216
|
+
const openPreviewLink = (href) => {
|
|
217
|
+
const mount = { basePath, previewPrefix };
|
|
218
|
+
const base = window.location.href;
|
|
219
|
+
const page = previewPageKey(href, mount, base);
|
|
220
|
+
const entry = page === undefined
|
|
221
|
+
? undefined
|
|
222
|
+
: entriesState.find((e) => e.previewSrc && previewPageKey(e.previewSrc, mount, base) === page);
|
|
223
|
+
if (entry) {
|
|
224
|
+
openEntry(entry.path);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
const opened = window.open(href, '_blank');
|
|
228
|
+
if (opened)
|
|
229
|
+
opened.opener = null;
|
|
230
|
+
const url = new URL(href);
|
|
231
|
+
const shown = url.origin === window.location.origin ? `${url.pathname}${url.search}` : href;
|
|
232
|
+
showPreviewLinkOutcome(href, displayPreviewPath(shown, mount), Boolean(opened));
|
|
233
|
+
};
|
|
208
234
|
// An open overlay docks open, as the URL's ask for no entry does at load; `navigatorOpen` is only
|
|
209
235
|
// the overlay's, so a docked panel never resurfaces as one. Waits for the stored preferences, so
|
|
210
236
|
// the open state it sets lands on them, not on the defaults they replace.
|
|
@@ -1208,7 +1234,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1208
1234
|
if (maximized && !contentPanelDocked)
|
|
1209
1235
|
setNavigatorOpen(false);
|
|
1210
1236
|
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
|
|
1237
|
+
}, 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
1238
|
? `${previewErrorField}: ${previewError.message}`
|
|
1213
1239
|
: previewError.message })) })) : (_jsx(Paper, { withBorder: true, shadow: "xs", h: "100%", bg: "var(--mantine-color-body)", style: {
|
|
1214
1240
|
display: 'flex',
|
|
@@ -1266,7 +1292,13 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1266
1292
|
missing: e.status === 'missing' || undefined,
|
|
1267
1293
|
noPage: !e.previewSrc || undefined,
|
|
1268
1294
|
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,
|
|
1295
|
+
})), 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
|
|
1296
|
+
? undefined
|
|
1297
|
+
: describeBranchLock({
|
|
1298
|
+
branchStatus: currentBranch?.status,
|
|
1299
|
+
branchReadOnly: currentBranch?.readOnly,
|
|
1300
|
+
branchWriteBlocked: branchContentLocked,
|
|
1301
|
+
}), dirtyPaths: dirtyPaths, revealCollection: collectionReveal, onRevealed: () => setCollectionReveal(undefined) }) }) }));
|
|
1270
1302
|
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
1303
|
// A draft kept for an unavailable entry stays in storage but cannot be saved.
|
|
1272
1304
|
hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, draftStorageFailed: draftStorageFailed, userContext: userContext, branchCreatedBy: currentBranch?.createdBy, branchAccess: currentBranch?.access,
|
|
@@ -1279,7 +1311,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1279
1311
|
// fail-closed computation of "are writes blocked" in this component.
|
|
1280
1312
|
branchIsProtected: currentBranch?.isProtected ?? true, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked,
|
|
1281
1313
|
// A failed fetch leaves the list empty too; the banner says that one.
|
|
1282
|
-
branchLoading:
|
|
1314
|
+
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
1315
|
void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
|
|
1284
1316
|
}, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onSave: currentEntryUnavailable ? noop : handleSave, onSaveAll: runSaveAll, saveAllProgress: saveAllProgress, saving: singleSaving?.branch === branchNameState &&
|
|
1285
1317
|
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
|
/**
|
|
@@ -8,6 +8,7 @@ import { matchesEntryQuery } from './entry-search.js';
|
|
|
8
8
|
import { EDITOR_ACTIONS } from './copy.js';
|
|
9
9
|
import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
|
|
10
10
|
import { UnsavedMark } from './components/UnsavedMark.js';
|
|
11
|
+
import { BranchRowLock } from './components/BranchRowLock.js';
|
|
11
12
|
import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
|
|
12
13
|
import { UnavailableTypeMessage } from './components/UnavailableTypeMessage.js';
|
|
13
14
|
const UnavailableTypeRow = ({ schemaRefs }) => (_jsxs(Group, { gap: 6, wrap: "nowrap", align: "flex-start", mt: 4, "data-testid": "unavailable-type-message", children: [_jsx(Box, { c: "orange", style: { display: 'flex', flexShrink: 0, paddingTop: 2 }, children: _jsx(IconAlertTriangle, { size: 14, "aria-hidden": true }) }), _jsx(Text, { size: "xs", c: "orange", children: _jsx(UnavailableTypeMessage, { schemaRefs: schemaRefs }) })] }));
|
|
@@ -123,7 +124,7 @@ const stopTreeKeys = (event) => {
|
|
|
123
124
|
event.stopPropagation();
|
|
124
125
|
};
|
|
125
126
|
const UnsavedDot = ({ label }) => (_jsx(Tooltip, { label: label, withinPortal: true, children: _jsx(UnsavedMark, { role: "img", "aria-label": label, "data-testid": "unsaved-dot" }) }));
|
|
126
|
-
export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, dirtyPaths, revealCollection, onRevealed, }) => {
|
|
127
|
+
export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onTreeControllerReady, expandedStateRef, onExpandedStateChange, onDeleteEntry: onDeleteEntryProp, onRenameEntry: onRenameEntryProp, onReorderEntry: onReorderEntryProp, hiddenRootPath, loading, readOnly = false, readOnlyReason, dirtyPaths, revealCollection, onRevealed, }) => {
|
|
127
128
|
// Shadow the entry-mutation callbacks with undefined when readOnly, so every
|
|
128
129
|
// downstream reference (hasEntryMenu, canReorder, the menu items themselves)
|
|
129
130
|
// sees them as absent rather than threading a readOnly check through each
|
|
@@ -476,15 +477,16 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
476
477
|
const status = node.nodeProps?.status;
|
|
477
478
|
const entryMissing = !!node.nodeProps?.missing;
|
|
478
479
|
const entryNoPage = !!node.nodeProps?.noPage;
|
|
479
|
-
// Collection-level mutation callbacks are nulled when readOnly, same as
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
const
|
|
483
|
-
const
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const
|
|
480
|
+
// Collection-level mutation callbacks are nulled when readOnly, same as the entry-level ones
|
|
481
|
+
// above, so hasCollectionMenu is false; a row that would have had a menu shows its lock.
|
|
482
|
+
const rawOnAdd = node.nodeProps?.onAdd;
|
|
483
|
+
const rawOnEdit = node.nodeProps?.onEdit;
|
|
484
|
+
const rawOnAddSubCollection = node.nodeProps?.onAddSubCollection;
|
|
485
|
+
const rawOnDelete = node.nodeProps?.onDelete;
|
|
486
|
+
const onAdd = readOnly ? undefined : rawOnAdd;
|
|
487
|
+
const onEdit = readOnly ? undefined : rawOnEdit;
|
|
488
|
+
const onAddSubCollection = readOnly ? undefined : rawOnAddSubCollection;
|
|
489
|
+
const onDelete = readOnly ? undefined : rawOnDelete;
|
|
488
490
|
const entryPath = node.nodeProps?.entryPath;
|
|
489
491
|
const contentId = node.nodeProps?.contentId;
|
|
490
492
|
const parentCollectionPath = node.nodeProps?.parentCollectionPath;
|
|
@@ -494,7 +496,7 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
494
496
|
const isEntry = node.nodeProps?.isEntry;
|
|
495
497
|
const conflictNotice = node.nodeProps?.conflictNotice;
|
|
496
498
|
const unavailableSchemaRefs = node.nodeProps?.unavailableSchemaRefs;
|
|
497
|
-
// A read-only navigator already says
|
|
499
|
+
// A read-only navigator already says writes are refused, so the add-refused line is not repeated.
|
|
498
500
|
const addRefused = !readOnly && !!node.nodeProps?.addRefused;
|
|
499
501
|
const isLeaf = !hasChildren || isEntry;
|
|
500
502
|
const selected = node.value === selectedPath;
|
|
@@ -504,21 +506,36 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
504
506
|
// Mantine only provides hasChildren=true if children.length > 0, but we want
|
|
505
507
|
// collections to always be expandable
|
|
506
508
|
const showChevron = hasChildren || Boolean(isCollection);
|
|
507
|
-
const
|
|
509
|
+
const reorderPosition = contentId && parentCollectionPath && typeof childIndex === 'number';
|
|
510
|
+
const canReorder = onReorderEntry && reorderPosition;
|
|
508
511
|
const canMoveUp = canReorder && childIndex > 0;
|
|
509
512
|
const canMoveDown = canReorder && typeof totalChildrenCount === 'number' && childIndex < totalChildrenCount - 1;
|
|
510
513
|
// Collections show menu for add/edit/delete actions OR for reordering (subcollections)
|
|
511
514
|
const hasCollectionMenu = isCollection && (onAdd || onEdit || onAddSubCollection || onDelete || canReorder);
|
|
512
515
|
// An unavailable entry cannot be written, so its menu (rename, delete, reorder) is not offered.
|
|
513
516
|
const entryUnavailable = node.nodeProps?.unavailable;
|
|
514
|
-
// The same holds for an entry this user may not edit
|
|
515
|
-
//
|
|
516
|
-
const entryLocked =
|
|
517
|
+
// The same holds for an entry this user may not edit, on any branch: its own lock stays, since a
|
|
518
|
+
// path rule, not the branch, refuses it, and the branch lock leaves that row's slot empty.
|
|
519
|
+
const entryLocked = !!node.nodeProps?.itemReadOnly;
|
|
517
520
|
const hasEntryMenu = isEntry &&
|
|
518
521
|
entryPath &&
|
|
519
522
|
!entryUnavailable &&
|
|
520
523
|
!entryLocked &&
|
|
521
524
|
(onDeleteEntry || onRenameEntry || onReorderEntry);
|
|
525
|
+
// The lock stands where the menu would, on exactly the rows that would otherwise have one. It
|
|
526
|
+
// is always shown: on hover alone, touch and keyboard users would see neither menu nor reason.
|
|
527
|
+
const wouldHaveCollectionMenu = isCollection &&
|
|
528
|
+
!!(rawOnAdd ||
|
|
529
|
+
rawOnEdit ||
|
|
530
|
+
rawOnAddSubCollection ||
|
|
531
|
+
rawOnDelete ||
|
|
532
|
+
(onReorderEntryProp && reorderPosition));
|
|
533
|
+
const wouldHaveEntryMenu = isEntry &&
|
|
534
|
+
!!entryPath &&
|
|
535
|
+
!entryUnavailable &&
|
|
536
|
+
!entryLocked &&
|
|
537
|
+
!!(onDeleteEntryProp || onRenameEntryProp || onReorderEntryProp);
|
|
538
|
+
const showsLockSlot = readOnly && (wouldHaveCollectionMenu || wouldHaveEntryMenu);
|
|
522
539
|
const showEntryDirtyDot = isEntry && !!entryPath && !!dirtyPaths?.has(entryPath);
|
|
523
540
|
const showCollectionDirtyDot = !isEntry && dirtyCollections.has(node.value);
|
|
524
541
|
const adminItems = onAddSubCollection || onEdit || onDelete;
|
|
@@ -566,7 +583,10 @@ export const EntryNavigator = ({ items, collections, selectedPath, onSelect, onT
|
|
|
566
583
|
}, children: "Edit collection\u2026" })), onDelete && (_jsx(DestructiveMenuItem, { leftSection: _jsx(IconTrash, { size: 14 }), onClick: (event) => {
|
|
567
584
|
event.stopPropagation();
|
|
568
585
|
onDelete();
|
|
569
|
-
}, children: "Delete collection" }))] })] })),
|
|
586
|
+
}, children: "Delete collection" }))] })] })), showsLockSlot &&
|
|
587
|
+
(readOnlyReason ? (_jsx(BranchRowLock, { label: isCollection ? 'Collection actions' : 'Entry actions', reason: readOnlyReason })) : (
|
|
588
|
+
// The branch's state is still loading: hold the slot so rows do not shift.
|
|
589
|
+
_jsx(Box, { w: 18, h: 18, "aria-hidden": true, "data-testid": "branch-lock-pending" }))), hasEntryMenu && (_jsxs(Menu, { shadow: "md", width: 180, withinPortal: true, position: "bottom-end", children: [_jsx(Menu.Target, { children: _jsx(ActionIcon, { size: "xs", variant: "subtle", color: "gray", onClick: (event) => event.stopPropagation(), "aria-label": "Entry actions", "data-testid": `entry-menu-${testIdSuffix}`, children: _jsx(IconDots, { size: 14 }) }) }), _jsxs(Menu.Dropdown, { onKeyDown: stopTreeKeys, children: [onRenameEntry && (_jsxs(Menu.Item, { leftSection: _jsx(IconEdit, { size: 14 }), onClick: (event) => {
|
|
570
590
|
event.stopPropagation();
|
|
571
591
|
onRenameEntry(entryPath);
|
|
572
592
|
}, "data-testid": "rename-entry-menu-item", children: [entryNoPage ? EDITOR_ACTIONS.changeSlug : EDITOR_ACTIONS.changeUrl, "\u2026"] })), onReorderEntry && contentId && parentCollectionPath && (_jsxs(_Fragment, { children: [_jsx(ReasonedMenuItem, { leftSection: _jsx(IconArrowUp, { size: 14 }), blockedReason: canMoveUp ? undefined : 'Already first', onClick: (event) => {
|
|
@@ -15,7 +15,7 @@ export type SiteChromeSupport = 'supported' | 'unsupported' | 'unanswered';
|
|
|
15
15
|
* runs until the preview's ready message arrives; when none does, a "Live updates off" chip offers
|
|
16
16
|
* to reload the preview. While `busy`, the page stays in view, dimmed and inert, under the bar.
|
|
17
17
|
*/
|
|
18
|
-
export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, busy, }: {
|
|
18
|
+
export declare const PreviewFrame: ({ src, path, data, isLoading, className, style, highlightEnabled, onPreviewError, onMarks, assetBase, iframeStyle, onReload, onSiteChrome, onNavigate, busy, }: {
|
|
19
19
|
src: string;
|
|
20
20
|
path: string;
|
|
21
21
|
data?: unknown;
|
|
@@ -44,6 +44,8 @@ export declare const PreviewFrame: ({ src, path, data, isLoading, className, sty
|
|
|
44
44
|
onReload?: () => void;
|
|
45
45
|
/** Called with what the page's handshake says of the site's header and footer. */
|
|
46
46
|
onSiteChrome?: (support: SiteChromeSupport) => void;
|
|
47
|
+
/** A preview link's absolute URL, unfollowed; focus follows into the next page unless moved. */
|
|
48
|
+
onNavigate?: (href: string) => void;
|
|
47
49
|
/** The editor is loading the next page's data: this page stays in view, dimmed and inert. */
|
|
48
50
|
busy?: boolean;
|
|
49
51
|
}) => import("react/jsx-runtime").JSX.Element;
|