canopycms 0.1.0 → 0.2.0-int.119
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 +144 -41
- package/dist/api/assets.d.ts +27 -7
- package/dist/api/assets.js +98 -37
- package/dist/api/client.d.ts +5 -3
- package/dist/api/client.js +7 -3
- package/dist/api/index.d.ts +1 -1
- package/dist/assets/asset-prefixes.d.ts +4 -1
- package/dist/assets/asset-prefixes.js +3 -1
- package/dist/assets/finalize.js +14 -1
- package/dist/assets/keys.d.ts +4 -1
- package/dist/assets/keys.js +4 -1
- package/dist/assets/store-local.d.ts +18 -9
- package/dist/assets/store-local.js +58 -26
- package/dist/assets/store-s3.d.ts +19 -4
- package/dist/assets/store-s3.js +56 -13
- package/dist/assets/types.d.ts +26 -2
- package/dist/cli/cli.js +112 -34
- package/dist/editor/BranchManager.js +2 -1
- package/dist/editor/CommentsPanel.js +67 -29
- package/dist/editor/Editor.d.ts +3 -0
- package/dist/editor/Editor.js +58 -36
- package/dist/editor/EntryNavigator.d.ts +8 -1
- package/dist/editor/EntryNavigator.js +31 -16
- package/dist/editor/FormHeader.d.ts +2 -1
- package/dist/editor/FormHeader.js +9 -5
- package/dist/editor/FormRenderer.d.ts +7 -2
- package/dist/editor/FormRenderer.js +51 -16
- package/dist/editor/PreviewFrame.d.ts +11 -2
- package/dist/editor/PreviewFrame.js +20 -4
- package/dist/editor/PreviewPane.d.ts +15 -1
- package/dist/editor/PreviewPane.js +55 -8
- package/dist/editor/PreviewToolbarTools.d.ts +8 -0
- package/dist/editor/PreviewToolbarTools.js +9 -1
- package/dist/editor/branch-status.d.ts +6 -0
- package/dist/editor/branch-status.js +10 -0
- package/dist/editor/comments/BranchComments.d.ts +2 -4
- package/dist/editor/comments/BranchComments.js +7 -11
- package/dist/editor/comments/ThreadCarousel.d.ts +2 -8
- package/dist/editor/comments/ThreadCarousel.js +4 -49
- package/dist/editor/comments/ThreadReplyControls.js +5 -2
- package/dist/editor/components/BranchLockBanner.d.ts +2 -0
- package/dist/editor/components/BranchLockBanner.js +6 -2
- package/dist/editor/components/BranchPill.d.ts +5 -4
- package/dist/editor/components/BranchPill.js +6 -8
- package/dist/editor/components/BranchesDrawer.js +3 -3
- package/dist/editor/components/EditorHeader.d.ts +2 -6
- package/dist/editor/components/EditorHeader.js +29 -28
- package/dist/editor/components/EditorRail.d.ts +3 -4
- package/dist/editor/components/EditorRail.js +3 -4
- package/dist/editor/components/EntryBreadcrumb.d.ts +9 -10
- package/dist/editor/components/EntryBreadcrumb.js +8 -11
- package/dist/editor/components/QuickOpenPalette.d.ts +7 -1
- package/dist/editor/components/QuickOpenPalette.js +6 -1
- package/dist/editor/components/RenameEntryModal.d.ts +6 -1
- package/dist/editor/components/RenameEntryModal.js +20 -5
- package/dist/editor/components/SaveSplitButton.d.ts +3 -2
- package/dist/editor/components/SaveSplitButton.js +25 -12
- package/dist/editor/components/StagedChangesDrawer.d.ts +0 -1
- package/dist/editor/components/StagedChangesDrawer.js +5 -4
- package/dist/editor/components/UndoToastMessage.d.ts +6 -5
- package/dist/editor/components/UndoToastMessage.js +6 -4
- package/dist/editor/components/UnsavedMark.d.ts +9 -0
- package/dist/editor/components/UnsavedMark.js +20 -0
- package/dist/editor/components/UserBadge.js +13 -11
- package/dist/editor/copy.d.ts +4 -0
- package/dist/editor/copy.js +4 -0
- package/dist/editor/editor-utils.d.ts +49 -9
- package/dist/editor/editor-utils.js +117 -16
- package/dist/editor/entry-search.d.ts +4 -2
- package/dist/editor/entry-search.js +2 -3
- package/dist/editor/fields/FieldLabel.d.ts +1 -1
- package/dist/editor/fields/ObjectField.d.ts +13 -2
- package/dist/editor/fields/ObjectField.js +38 -9
- package/dist/editor/fields/entry-link/EntryLinkContext.d.ts +2 -0
- package/dist/editor/fields/entry-link/InsertEntryLink.js +3 -1
- package/dist/editor/group-manager/index.js +1 -1
- package/dist/editor/hooks/useEditorLayout.d.ts +4 -0
- package/dist/editor/hooks/useEditorLayout.js +5 -0
- package/dist/editor/hooks/useEntryManager.d.ts +2 -0
- package/dist/editor/hooks/useEntryManager.js +10 -8
- package/dist/editor/media/AssetCard.d.ts +6 -3
- package/dist/editor/media/AssetCard.js +20 -7
- package/dist/editor/media/AssetListRow.d.ts +9 -4
- package/dist/editor/media/AssetListRow.js +14 -7
- package/dist/editor/media/MediaLibrary.js +5 -4
- package/dist/editor/media/MediaLibraryBody.d.ts +1 -1
- package/dist/editor/media/MediaLibraryBody.js +65 -37
- package/dist/editor/media/useAssetLibrary.d.ts +6 -3
- package/dist/editor/media/useAssetLibrary.js +17 -10
- package/dist/editor/pane-header.d.ts +6 -0
- package/dist/editor/pane-header.js +6 -0
- package/dist/editor/permission-manager/index.js +1 -1
- package/dist/editor/preview-bridge.d.ts +16 -0
- package/dist/editor/preview-bridge.js +12 -3
- package/dist/editor/preview-path.d.ts +11 -4
- package/dist/editor/preview-path.js +62 -19
- package/dist/editor/shell/ActivityRail.d.ts +1 -3
- package/dist/editor/shell/ActivityRail.js +3 -3
- package/dist/editor/shell/EditorShell.d.ts +15 -0
- package/dist/editor/shell/EditorShell.js +17 -1
- package/dist/editor/shell/OverlayHeader.d.ts +19 -0
- package/dist/editor/shell/OverlayHeader.js +15 -0
- package/dist/editor/shell/PaneLayout.d.ts +6 -1
- package/dist/editor/shell/PaneLayout.js +68 -13
- package/dist/editor/shell/SidePanelHost.d.ts +8 -6
- package/dist/editor/shell/SidePanelHost.js +70 -33
- package/dist/editor/shell/TopBar.js +2 -1
- package/dist/editor/theme.js +2 -0
- package/dist/editor/undo-removal.d.ts +19 -10
- package/dist/editor/undo-removal.js +44 -16
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/assets/store-s3.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* S3-backed AssetStore. Same bucket-prefix layout as LocalAssetStore (see
|
|
3
3
|
* keys.ts). Assumes an EXISTING content bucket (versioning/SSE/replication
|
|
4
4
|
* already configured by the site's CDK stack). It reads and writes under the
|
|
5
|
-
*
|
|
5
|
+
* six asset prefixes, plus a caller's output prefix for `copyPublicObject`.
|
|
6
6
|
*/
|
|
7
7
|
import { randomUUID } from 'node:crypto';
|
|
8
8
|
import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand, S3Client, paginateListObjectsV2, } from '@aws-sdk/client-s3';
|
|
@@ -11,6 +11,8 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
|
|
11
11
|
import { isHttpUrlOrSameOriginPath } from '../utils/sanitize-href.js';
|
|
12
12
|
import { ASSET_PREFIXES, createKeyBuilders } from './keys.js';
|
|
13
13
|
const DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
14
|
+
/** What `metaKey` and `hiddenKey` put after the hash32. */
|
|
15
|
+
const JSON_SUFFIX = '.json';
|
|
14
16
|
const PRESIGN_EXPIRY_SECONDS = 15 * 60;
|
|
15
17
|
/** A presigned read is followed by the browser at once; it is never stored or cached. */
|
|
16
18
|
const PRESIGNED_READ_EXPIRY_SECONDS = 5 * 60;
|
|
@@ -234,7 +236,10 @@ export class S3AssetStore {
|
|
|
234
236
|
throw err;
|
|
235
237
|
}
|
|
236
238
|
}
|
|
237
|
-
|
|
239
|
+
listPublicObjectKeys(prefix) {
|
|
240
|
+
return this.listKeys(prefix);
|
|
241
|
+
}
|
|
242
|
+
async *listKeys(prefix) {
|
|
238
243
|
const pages = paginateListObjectsV2({ client: this.client }, { Bucket: this.bucket, Prefix: prefix });
|
|
239
244
|
for await (const page of pages) {
|
|
240
245
|
for (const object of page.Contents ?? []) {
|
|
@@ -264,31 +269,69 @@ export class S3AssetStore {
|
|
|
264
269
|
return null;
|
|
265
270
|
return JSON.parse(Buffer.from(bytes).toString('utf-8'));
|
|
266
271
|
}
|
|
272
|
+
/**
|
|
273
|
+
* One page of meta keys, filtered by a full listing of the hidden markers: few files are hidden,
|
|
274
|
+
* and a listing needs only `s3:ListBucket`. A hidden file's meta is fetched only for
|
|
275
|
+
* `includeHidden`.
|
|
276
|
+
*/
|
|
267
277
|
async listMeta(input) {
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
278
|
+
const metaPrefix = this.keys.metaPrefix();
|
|
279
|
+
const [listed, hidden] = await Promise.all([
|
|
280
|
+
this.client.send(new ListObjectsV2Command({
|
|
281
|
+
Bucket: this.bucket,
|
|
282
|
+
Prefix: metaPrefix,
|
|
283
|
+
MaxKeys: input?.limit,
|
|
284
|
+
ContinuationToken: input?.cursor,
|
|
285
|
+
})),
|
|
286
|
+
this.listHiddenHashes(),
|
|
287
|
+
]);
|
|
274
288
|
const objectKeys = (listed.Contents ?? [])
|
|
275
289
|
.map((obj) => obj.Key)
|
|
276
290
|
.filter((key) => Boolean(key));
|
|
277
291
|
const fetched = await Promise.all(objectKeys.map(async (key) => {
|
|
292
|
+
const isHidden = hidden.has(key.slice(metaPrefix.length, -JSON_SUFFIX.length));
|
|
293
|
+
if (isHidden && !input?.includeHidden)
|
|
294
|
+
return null;
|
|
278
295
|
const bytes = await this.getObjectBytes(key);
|
|
279
|
-
// A meta object deleted between LIST and GET (deleteMeta race) reads
|
|
280
|
-
// as null; skip it rather than failing the whole page.
|
|
281
296
|
if (!bytes)
|
|
282
297
|
return null;
|
|
283
|
-
|
|
298
|
+
const meta = JSON.parse(Buffer.from(bytes).toString('utf-8'));
|
|
299
|
+
return isHidden ? { ...meta, hidden: true } : meta;
|
|
284
300
|
}));
|
|
285
301
|
return {
|
|
286
302
|
items: fetched.filter((meta) => meta !== null),
|
|
287
303
|
nextCursor: listed.IsTruncated ? listed.NextContinuationToken : undefined,
|
|
288
304
|
};
|
|
289
305
|
}
|
|
290
|
-
async
|
|
291
|
-
|
|
306
|
+
async listHiddenHashes() {
|
|
307
|
+
const prefix = this.keys.hiddenPrefix();
|
|
308
|
+
const hashes = new Set();
|
|
309
|
+
for await (const key of this.listKeys(prefix)) {
|
|
310
|
+
if (key.endsWith(JSON_SUFFIX))
|
|
311
|
+
hashes.add(key.slice(prefix.length, -JSON_SUFFIX.length));
|
|
312
|
+
}
|
|
313
|
+
return hashes;
|
|
314
|
+
}
|
|
315
|
+
async hideAsset(hash32, marker) {
|
|
316
|
+
return this.putIfAbsent({
|
|
317
|
+
Key: this.keys.hiddenKey(hash32),
|
|
318
|
+
Body: JSON.stringify(marker),
|
|
319
|
+
ContentType: 'application/json',
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Lists before deleting: a DELETE of an absent key writes a delete marker on a versioned bucket.
|
|
324
|
+
* The listing needs only `s3:ListBucket` covering `asset-hidden/` (a bucket-wide grant does; a
|
|
325
|
+
* policy scoping it by prefix must name this one), no object permission, so finalize can call
|
|
326
|
+
* this on every re-upload under a role that has never hidden a file.
|
|
327
|
+
*/
|
|
328
|
+
async unhideAsset(hash32) {
|
|
329
|
+
const key = this.keys.hiddenKey(hash32);
|
|
330
|
+
const listed = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: key, MaxKeys: 1 }));
|
|
331
|
+
if (listed.Contents?.[0]?.Key !== key)
|
|
332
|
+
return false;
|
|
333
|
+
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
334
|
+
return true;
|
|
292
335
|
}
|
|
293
336
|
async getObject(key) {
|
|
294
337
|
try {
|
package/dist/assets/types.d.ts
CHANGED
|
@@ -19,6 +19,15 @@ export interface AssetMeta {
|
|
|
19
19
|
uploadedBy?: string;
|
|
20
20
|
uploadedAt: string;
|
|
21
21
|
}
|
|
22
|
+
/** A listed asset; `hidden` is set only on a listing that includes hidden files. */
|
|
23
|
+
export type ListedAssetMeta = AssetMeta & {
|
|
24
|
+
hidden?: true;
|
|
25
|
+
};
|
|
26
|
+
/** The body of a hidden marker. Only the marker's presence hides a file; this records who and when. */
|
|
27
|
+
export interface HiddenAssetMarker {
|
|
28
|
+
hiddenAt: string;
|
|
29
|
+
hiddenBy?: string;
|
|
30
|
+
}
|
|
22
31
|
export type StagedUploadTarget = {
|
|
23
32
|
mode: 'direct';
|
|
24
33
|
url: string;
|
|
@@ -95,14 +104,29 @@ export interface AssetStore {
|
|
|
95
104
|
listPublicObjectKeys?(prefix: string): AsyncIterable<string>;
|
|
96
105
|
/** A short-lived URL a browser can GET `key` from directly, or `null` if absent. S3 only. */
|
|
97
106
|
presignPublicObjectRead?(key: string): Promise<string | null>;
|
|
107
|
+
/**
|
|
108
|
+
* Meta is create-only and never deleted: every transform not yet stored, at upload, release or
|
|
109
|
+
* after a lazy output expires, is computed from it, and content anywhere may still name the file.
|
|
110
|
+
*/
|
|
98
111
|
putMetaIfAbsent(hash32: string, meta: AssetMeta): Promise<CreateOnlyResult>;
|
|
99
112
|
getMeta(hash32: string): Promise<AssetMeta | null>;
|
|
113
|
+
/**
|
|
114
|
+
* A page of meta, without hidden files unless `includeHidden`, which marks each hidden one
|
|
115
|
+
* `hidden: true`. A page may hold fewer than `limit` items while `nextCursor` is still set.
|
|
116
|
+
*/
|
|
100
117
|
listMeta(input?: {
|
|
101
118
|
cursor?: string;
|
|
102
119
|
limit?: number;
|
|
120
|
+
includeHidden?: boolean;
|
|
103
121
|
}): Promise<{
|
|
104
|
-
items:
|
|
122
|
+
items: ListedAssetMeta[];
|
|
105
123
|
nextCursor?: string;
|
|
106
124
|
}>;
|
|
107
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Hide a file from `listMeta`: a create-only marker beside its meta, which stays, so every URL
|
|
127
|
+
* and transform keeps working. `already-exists` when it was hidden already.
|
|
128
|
+
*/
|
|
129
|
+
hideAsset(hash32: string, marker: HiddenAssetMarker): Promise<CreateOnlyResult>;
|
|
130
|
+
/** Remove the hidden marker; `false` when there was none. Needs no permission on a store that has never hidden a file. */
|
|
131
|
+
unhideAsset(hash32: string): Promise<boolean>;
|
|
108
132
|
}
|
package/dist/cli/cli.js
CHANGED
|
@@ -13330,6 +13330,8 @@ var init_asset_prefixes = __esm({
|
|
|
13330
13330
|
originals: "asset-originals",
|
|
13331
13331
|
staging: "asset-staging",
|
|
13332
13332
|
meta: "asset-meta",
|
|
13333
|
+
/** One marker per file removed from the media library; its meta stays (see `hideAsset`). */
|
|
13334
|
+
hidden: "asset-hidden",
|
|
13333
13335
|
public: "assets",
|
|
13334
13336
|
/** Transform-output prefix; directive-aware key building lives in transform-directives.ts. */
|
|
13335
13337
|
transform: "assets/t"
|
|
@@ -14115,16 +14117,18 @@ function createKeyBuilders(prefixes = ASSET_PREFIXES) {
|
|
|
14115
14117
|
stagingKey: (uuid) => `${prefixes.staging}/${uuid}`,
|
|
14116
14118
|
metaKey: (hash32) => `${prefixes.meta}/${hash32}.json`,
|
|
14117
14119
|
metaPrefix: () => `${prefixes.meta}/`,
|
|
14120
|
+
hiddenKey: (hash32) => `${prefixes.hidden}/${hash32}.json`,
|
|
14121
|
+
hiddenPrefix: () => `${prefixes.hidden}/`,
|
|
14118
14122
|
publicKey: (hash32, slug, ext) => `${prefixes.public}/${hash32}/${slug}.${ext}`
|
|
14119
14123
|
};
|
|
14120
14124
|
}
|
|
14121
|
-
var keyBuilders, originalKey, stagingKey, metaKey, publicKey, originalPrefix, metaPrefix;
|
|
14125
|
+
var keyBuilders, originalKey, stagingKey, metaKey, hiddenKey, publicKey, originalPrefix, metaPrefix;
|
|
14122
14126
|
var init_keys = __esm({
|
|
14123
14127
|
"dist/assets/keys.js"() {
|
|
14124
14128
|
"use strict";
|
|
14125
14129
|
init_asset_prefixes();
|
|
14126
14130
|
keyBuilders = createKeyBuilders();
|
|
14127
|
-
({ originalKey, stagingKey, metaKey, publicKey } = keyBuilders);
|
|
14131
|
+
({ originalKey, stagingKey, metaKey, hiddenKey, publicKey } = keyBuilders);
|
|
14128
14132
|
({ originalPrefix, metaPrefix } = keyBuilders);
|
|
14129
14133
|
}
|
|
14130
14134
|
});
|
|
@@ -14140,13 +14144,14 @@ function matchesAwsError(err2, name, httpStatusCode) {
|
|
|
14140
14144
|
const shaped = err2;
|
|
14141
14145
|
return shaped.name === name || shaped.$metadata?.httpStatusCode === httpStatusCode;
|
|
14142
14146
|
}
|
|
14143
|
-
var DEFAULT_MAX_UPLOAD_BYTES, PRESIGN_EXPIRY_SECONDS, PRESIGNED_READ_EXPIRY_SECONDS, isPreconditionFailed, isConditionalRequestConflict, CONFLICT_ATTEMPTS, CONFLICT_BASE_DELAY_MS, isNoSuchKey, S3AssetStore;
|
|
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;
|
|
14144
14148
|
var init_store_s3 = __esm({
|
|
14145
14149
|
"dist/assets/store-s3.js"() {
|
|
14146
14150
|
"use strict";
|
|
14147
14151
|
init_sanitize_href3();
|
|
14148
14152
|
init_keys();
|
|
14149
14153
|
DEFAULT_MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
|
|
14154
|
+
JSON_SUFFIX = ".json";
|
|
14150
14155
|
PRESIGN_EXPIRY_SECONDS = 15 * 60;
|
|
14151
14156
|
PRESIGNED_READ_EXPIRY_SECONDS = 5 * 60;
|
|
14152
14157
|
isPreconditionFailed = (err2) => matchesAwsError(err2, "PreconditionFailed", 412);
|
|
@@ -14341,7 +14346,10 @@ var init_store_s3 = __esm({
|
|
|
14341
14346
|
throw err2;
|
|
14342
14347
|
}
|
|
14343
14348
|
}
|
|
14344
|
-
|
|
14349
|
+
listPublicObjectKeys(prefix) {
|
|
14350
|
+
return this.listKeys(prefix);
|
|
14351
|
+
}
|
|
14352
|
+
async *listKeys(prefix) {
|
|
14345
14353
|
const pages = paginateListObjectsV2({ client: this.client }, { Bucket: this.bucket, Prefix: prefix });
|
|
14346
14354
|
for await (const page of pages) {
|
|
14347
14355
|
for (const object of page.Contents ?? []) {
|
|
@@ -14371,27 +14379,67 @@ var init_store_s3 = __esm({
|
|
|
14371
14379
|
return null;
|
|
14372
14380
|
return JSON.parse(Buffer.from(bytes).toString("utf-8"));
|
|
14373
14381
|
}
|
|
14382
|
+
/**
|
|
14383
|
+
* One page of meta keys, filtered by a full listing of the hidden markers: few files are hidden,
|
|
14384
|
+
* and a listing needs only `s3:ListBucket`. A hidden file's meta is fetched only for
|
|
14385
|
+
* `includeHidden`.
|
|
14386
|
+
*/
|
|
14374
14387
|
async listMeta(input) {
|
|
14375
|
-
const
|
|
14376
|
-
|
|
14377
|
-
|
|
14378
|
-
|
|
14379
|
-
|
|
14380
|
-
|
|
14388
|
+
const metaPrefix2 = this.keys.metaPrefix();
|
|
14389
|
+
const [listed, hidden] = await Promise.all([
|
|
14390
|
+
this.client.send(new ListObjectsV2Command({
|
|
14391
|
+
Bucket: this.bucket,
|
|
14392
|
+
Prefix: metaPrefix2,
|
|
14393
|
+
MaxKeys: input?.limit,
|
|
14394
|
+
ContinuationToken: input?.cursor
|
|
14395
|
+
})),
|
|
14396
|
+
this.listHiddenHashes()
|
|
14397
|
+
]);
|
|
14381
14398
|
const objectKeys = (listed.Contents ?? []).map((obj) => obj.Key).filter((key) => Boolean(key));
|
|
14382
14399
|
const fetched = await Promise.all(objectKeys.map(async (key) => {
|
|
14400
|
+
const isHidden = hidden.has(key.slice(metaPrefix2.length, -JSON_SUFFIX.length));
|
|
14401
|
+
if (isHidden && !input?.includeHidden)
|
|
14402
|
+
return null;
|
|
14383
14403
|
const bytes = await this.getObjectBytes(key);
|
|
14384
14404
|
if (!bytes)
|
|
14385
14405
|
return null;
|
|
14386
|
-
|
|
14406
|
+
const meta = JSON.parse(Buffer.from(bytes).toString("utf-8"));
|
|
14407
|
+
return isHidden ? { ...meta, hidden: true } : meta;
|
|
14387
14408
|
}));
|
|
14388
14409
|
return {
|
|
14389
14410
|
items: fetched.filter((meta) => meta !== null),
|
|
14390
14411
|
nextCursor: listed.IsTruncated ? listed.NextContinuationToken : void 0
|
|
14391
14412
|
};
|
|
14392
14413
|
}
|
|
14393
|
-
async
|
|
14394
|
-
|
|
14414
|
+
async listHiddenHashes() {
|
|
14415
|
+
const prefix = this.keys.hiddenPrefix();
|
|
14416
|
+
const hashes = /* @__PURE__ */ new Set();
|
|
14417
|
+
for await (const key of this.listKeys(prefix)) {
|
|
14418
|
+
if (key.endsWith(JSON_SUFFIX))
|
|
14419
|
+
hashes.add(key.slice(prefix.length, -JSON_SUFFIX.length));
|
|
14420
|
+
}
|
|
14421
|
+
return hashes;
|
|
14422
|
+
}
|
|
14423
|
+
async hideAsset(hash32, marker) {
|
|
14424
|
+
return this.putIfAbsent({
|
|
14425
|
+
Key: this.keys.hiddenKey(hash32),
|
|
14426
|
+
Body: JSON.stringify(marker),
|
|
14427
|
+
ContentType: "application/json"
|
|
14428
|
+
});
|
|
14429
|
+
}
|
|
14430
|
+
/**
|
|
14431
|
+
* Lists before deleting: a DELETE of an absent key writes a delete marker on a versioned bucket.
|
|
14432
|
+
* The listing needs only `s3:ListBucket` covering `asset-hidden/` (a bucket-wide grant does; a
|
|
14433
|
+
* policy scoping it by prefix must name this one), no object permission, so finalize can call
|
|
14434
|
+
* this on every re-upload under a role that has never hidden a file.
|
|
14435
|
+
*/
|
|
14436
|
+
async unhideAsset(hash32) {
|
|
14437
|
+
const key = this.keys.hiddenKey(hash32);
|
|
14438
|
+
const listed = await this.client.send(new ListObjectsV2Command({ Bucket: this.bucket, Prefix: key, MaxKeys: 1 }));
|
|
14439
|
+
if (listed.Contents?.[0]?.Key !== key)
|
|
14440
|
+
return false;
|
|
14441
|
+
await this.client.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
|
|
14442
|
+
return true;
|
|
14395
14443
|
}
|
|
14396
14444
|
async getObject(key) {
|
|
14397
14445
|
try {
|
|
@@ -14766,7 +14814,14 @@ var init_asset_refs = __esm({
|
|
|
14766
14814
|
import fs26 from "node:fs/promises";
|
|
14767
14815
|
import path29 from "node:path";
|
|
14768
14816
|
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
14769
|
-
|
|
14817
|
+
function compareNewestFirst(a, b) {
|
|
14818
|
+
if (a.uploadedAt !== b.uploadedAt)
|
|
14819
|
+
return a.uploadedAt < b.uploadedAt ? 1 : -1;
|
|
14820
|
+
if (a.hash32 === b.hash32)
|
|
14821
|
+
return 0;
|
|
14822
|
+
return a.hash32 < b.hash32 ? 1 : -1;
|
|
14823
|
+
}
|
|
14824
|
+
var DEFAULT_MAX_UPLOAD_BYTES2, DEFAULT_LIST_LIMIT, HEADERS_SIDECAR_SUFFIX, TEMP_DIR, ORIGINAL_EXT_RE, HIDDEN_MARKER_RE, LocalAssetStore;
|
|
14770
14825
|
var init_store_local = __esm({
|
|
14771
14826
|
"dist/assets/store-local.js"() {
|
|
14772
14827
|
"use strict";
|
|
@@ -14778,6 +14833,7 @@ var init_store_local = __esm({
|
|
|
14778
14833
|
HEADERS_SIDECAR_SUFFIX = ".headers.json";
|
|
14779
14834
|
TEMP_DIR = ".asset-tmp";
|
|
14780
14835
|
ORIGINAL_EXT_RE = /^[a-z0-9]+$/;
|
|
14836
|
+
HIDDEN_MARKER_RE = /^([a-f0-9]{32})\.json$/;
|
|
14781
14837
|
LocalAssetStore = class {
|
|
14782
14838
|
constructor(options) {
|
|
14783
14839
|
this.capabilities = { directUpload: false };
|
|
@@ -14849,11 +14905,12 @@ var init_store_local = __esm({
|
|
|
14849
14905
|
}
|
|
14850
14906
|
}
|
|
14851
14907
|
/**
|
|
14852
|
-
* Create-only write of a blob and its headers sidecar. Each is written
|
|
14853
|
-
* `link()`ed into place, which fails with EEXIST rather than replacing
|
|
14854
|
-
* The sidecar is linked first, so a reader never finds a blob without
|
|
14855
|
-
* already there was linked by an earlier writer of this key and is kept,
|
|
14856
|
-
* first writer's headers win, as on S3 (a static's Content-Disposition names
|
|
14908
|
+
* Create-only write of a blob and, when `headers` is given, its headers sidecar. Each is written
|
|
14909
|
+
* whole to a temp file, then `link()`ed into place, which fails with EEXIST rather than replacing
|
|
14910
|
+
* (see docs/concurrency.md). The sidecar is linked first, so a reader never finds a blob without
|
|
14911
|
+
* its headers. A sidecar already there was linked by an earlier writer of this key and is kept,
|
|
14912
|
+
* as the blob is: the first writer's headers win, as on S3 (a static's Content-Disposition names
|
|
14913
|
+
* its uploaded file).
|
|
14857
14914
|
*/
|
|
14858
14915
|
async createExclusive(key, data, headers) {
|
|
14859
14916
|
const filePath = this.resolveKey(key);
|
|
@@ -14866,8 +14923,10 @@ var init_store_local = __esm({
|
|
|
14866
14923
|
const tempHeaders = path29.join(tempDir, randomUUID4());
|
|
14867
14924
|
try {
|
|
14868
14925
|
await fs26.writeFile(tempBlob, data);
|
|
14869
|
-
|
|
14870
|
-
|
|
14926
|
+
if (headers) {
|
|
14927
|
+
await fs26.writeFile(tempHeaders, JSON.stringify(headers), "utf-8");
|
|
14928
|
+
await this.linkIfAbsent(tempHeaders, this.headersSidecarPath(filePath));
|
|
14929
|
+
}
|
|
14871
14930
|
return await this.linkIfAbsent(tempBlob, filePath) ? "created" : "already-exists";
|
|
14872
14931
|
} finally {
|
|
14873
14932
|
await Promise.all([tempBlob, tempHeaders].map((temp) => fs26.unlink(temp).catch(() => {
|
|
@@ -14999,6 +15058,11 @@ var init_store_local = __esm({
|
|
|
14999
15058
|
throw err2;
|
|
15000
15059
|
}
|
|
15001
15060
|
}
|
|
15061
|
+
/**
|
|
15062
|
+
* The cursor names the last item returned, and the next page starts after it in sort order, so a
|
|
15063
|
+
* page boundary holds when that item is hidden in between: its meta is never deleted, and the
|
|
15064
|
+
* comparison needs only its place, not its presence in the filtered list.
|
|
15065
|
+
*/
|
|
15002
15066
|
async listMeta(input) {
|
|
15003
15067
|
const limit = input?.limit ?? DEFAULT_LIST_LIMIT;
|
|
15004
15068
|
const dir = this.resolveKey(ASSET_PREFIXES.meta);
|
|
@@ -15022,29 +15086,43 @@ var init_store_local = __esm({
|
|
|
15022
15086
|
}
|
|
15023
15087
|
}
|
|
15024
15088
|
}
|
|
15025
|
-
all.sort(
|
|
15026
|
-
|
|
15027
|
-
|
|
15028
|
-
return a.hash32 < b.hash32 ? 1 : -1;
|
|
15029
|
-
});
|
|
15089
|
+
all.sort(compareNewestFirst);
|
|
15090
|
+
const hidden = await this.hiddenHashes();
|
|
15091
|
+
const listed = input?.includeHidden ? all.map((meta) => hidden.has(meta.hash32) ? { ...meta, hidden: true } : meta) : all.filter((meta) => !hidden.has(meta.hash32));
|
|
15030
15092
|
let startIndex = 0;
|
|
15031
15093
|
if (input?.cursor) {
|
|
15032
15094
|
const afterHash32 = Buffer.from(input.cursor, "base64").toString("utf-8");
|
|
15033
|
-
const
|
|
15034
|
-
|
|
15095
|
+
const anchor = all.find((m) => m.hash32 === afterHash32);
|
|
15096
|
+
if (anchor) {
|
|
15097
|
+
const after = listed.findIndex((m) => compareNewestFirst(m, anchor) > 0);
|
|
15098
|
+
startIndex = after === -1 ? listed.length : after;
|
|
15099
|
+
}
|
|
15035
15100
|
}
|
|
15036
|
-
const items =
|
|
15037
|
-
const hasMore = startIndex + limit <
|
|
15101
|
+
const items = listed.slice(startIndex, startIndex + limit);
|
|
15102
|
+
const hasMore = startIndex + limit < listed.length;
|
|
15038
15103
|
const nextCursor = hasMore ? Buffer.from(items[items.length - 1].hash32, "utf-8").toString("base64") : void 0;
|
|
15039
15104
|
return { items, nextCursor };
|
|
15040
15105
|
}
|
|
15041
|
-
async
|
|
15042
|
-
const filePath = this.resolveKey(metaKey(hash32));
|
|
15106
|
+
async hiddenHashes() {
|
|
15043
15107
|
try {
|
|
15044
|
-
await fs26.
|
|
15108
|
+
const names = await fs26.readdir(this.resolveKey(ASSET_PREFIXES.hidden));
|
|
15109
|
+
return new Set(names.flatMap((name) => HIDDEN_MARKER_RE.exec(name)?.[1] ?? []));
|
|
15045
15110
|
} catch (err2) {
|
|
15046
15111
|
if (isNotFoundError(err2))
|
|
15047
|
-
return;
|
|
15112
|
+
return /* @__PURE__ */ new Set();
|
|
15113
|
+
throw err2;
|
|
15114
|
+
}
|
|
15115
|
+
}
|
|
15116
|
+
async hideAsset(hash32, marker) {
|
|
15117
|
+
return this.createExclusive(hiddenKey(hash32), new TextEncoder().encode(JSON.stringify(marker)));
|
|
15118
|
+
}
|
|
15119
|
+
async unhideAsset(hash32) {
|
|
15120
|
+
try {
|
|
15121
|
+
await fs26.unlink(this.resolveKey(hiddenKey(hash32)));
|
|
15122
|
+
return true;
|
|
15123
|
+
} catch (err2) {
|
|
15124
|
+
if (isNotFoundError(err2))
|
|
15125
|
+
return false;
|
|
15048
15126
|
throw err2;
|
|
15049
15127
|
}
|
|
15050
15128
|
}
|
|
@@ -208,5 +208,6 @@ export const BranchManager = ({ branches, mode, user, currentBranchName, current
|
|
|
208
208
|
...branches.filter((b) => b.name !== currentBranchName),
|
|
209
209
|
]
|
|
210
210
|
: branches;
|
|
211
|
-
|
|
211
|
+
const hasBranchThreads = comments.some((t) => t.type === 'branch');
|
|
212
|
+
return (_jsxs(Stack, { h: "100%", style: { display: 'flex', flexDirection: 'column' }, gap: 0, "data-testid": "branch-manager", children: [currentUserId && onAddComment && onResolveThread && hasBranchThreads && (_jsx(Stack, { pt: "sm", children: _jsx(BranchComments, { comments: comments, currentUserId: currentUserId, canResolve: canResolve, onAddComment: onAddComment, onResolveThread: onResolveThread, highlightThreadId: highlightThreadId, onGetUserMetadata: onGetCommentUserMetadata }) })), !!supportsBranching && (_jsxs(Stack, { gap: "sm", pt: "sm", children: [_jsx(Button, { variant: "light", size: "sm", fullWidth: true, onClick: () => setShowCreateForm(!showCreateForm), disabled: creating, "data-testid": "create-branch-button", children: showCreateForm ? 'Cancel' : `${EDITOR_ACTIONS.newBranch}…` }), _jsx(Collapse, { in: showCreateForm, children: _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsx(TextInput, { label: "Branch name", placeholder: "feature/my-branch", value: newBranchName, onChange: (e) => setNewBranchName(e.target.value), required: true, disabled: creating, "data-testid": "branch-name-input" }), _jsx(TextInput, { label: "Title", placeholder: "Brief description", value: newBranchTitle, onChange: (e) => setNewBranchTitle(e.target.value), disabled: creating, "data-testid": "branch-title-input" }), _jsx(Textarea, { label: "Description", placeholder: "Detailed description of the changes", value: newBranchDescription, onChange: (e) => setNewBranchDescription(e.target.value), minRows: 2, disabled: creating, "data-testid": "branch-description-textarea" }), _jsx(Button, { size: "sm", onClick: handleCreate, disabled: !newBranchName.trim(), loading: creating, fullWidth: true, "data-testid": "create-branch-submit", children: "Create branch" })] }) }) })] })), _jsx(ScrollArea, { style: { flex: 1 }, pb: "md", children: branches.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", py: "md", children: !supportsBranching ? 'Branch management is not available.' : 'No branches available.' })) : (_jsx(Stack, { gap: "sm", children: orderedBranches.map((b) => (_jsx(BranchCard, { branch: b, isCurrent: b.name === currentBranchName, workflowBlockedReason: b.name === currentBranchName ? currentWorkflowBlockedReason : undefined, user: user, onSelect: onSelect, onSubmit: onSubmit, onWithdraw: onWithdraw, onRequestChanges: onRequestChanges, onDelete: onDelete, onGetUserMetadata: onGetUserMetadata, onGetOwnerMetadata: onGetOwnerMetadata }, b.name))) })) })] }));
|
|
212
213
|
};
|
|
@@ -1,23 +1,17 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import {
|
|
2
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
3
3
|
import { useId, useRef, useState } from 'react';
|
|
4
|
-
import { Badge, Button,
|
|
4
|
+
import { Badge, Button, Group, Paper, ScrollArea, Stack, Text, Textarea, Switch, Tooltip, VisuallyHidden, } from '@mantine/core';
|
|
5
|
+
import { IconChevronRight } from '@tabler/icons-react';
|
|
5
6
|
import { UserBadge } from './components/UserBadge.js';
|
|
6
7
|
import { userIdLabel } from './user-display.js';
|
|
8
|
+
import { OverlayDrawer } from './shell/OverlayHeader.js';
|
|
7
9
|
import { RelativeTime } from './components/RelativeTime.js';
|
|
8
10
|
import { ThreadReplyControls } from './comments/ThreadReplyControls.js';
|
|
9
11
|
import { useTrackedWrites } from './comments/use-tracked-writes.js';
|
|
10
12
|
import { CloseRefusedNotice } from './comments/CloseRefusedNotice.js';
|
|
11
13
|
import { ON_REMOVED_ITEM } from './comments/item-anchor-copy.js';
|
|
12
14
|
import { CommentWriteAlert, toCommentWriteFailure, } from './comments/CommentWriteAlert.js';
|
|
13
|
-
/**
|
|
14
|
-
* A jump button that stays focusable while a write is in flight (`aria-disabled`, not `disabled`),
|
|
15
|
-
* so its reason opens on keyboard focus and touch as well as hover.
|
|
16
|
-
*/
|
|
17
|
-
const JumpButton = ({ blocked, onClick, children }) => (_jsx(Tooltip, { label: "Your change is still saving. Try again once it finishes.", disabled: !blocked, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", onClick: () => {
|
|
18
|
-
if (!blocked)
|
|
19
|
-
onClick();
|
|
20
|
-
}, "data-disabled": blocked ? true : undefined, "aria-disabled": blocked ? true : undefined, children: children }) }));
|
|
21
15
|
const THREAD_KIND_LABELS = { field: 'Field', entry: 'Entry', branch: 'Branch' };
|
|
22
16
|
export const CommentsPanel = ({ opened, branchName, comments, canResolve, currentUserId, onAddComment, onResolveThread, onClose, onJumpToField, onJumpToEntry, onJumpToBranch, onGetUserMetadata, entryLabel, fieldLabel, }) => {
|
|
23
17
|
const [newCommentText, setNewCommentText] = useState('');
|
|
@@ -65,27 +59,71 @@ export const CommentsPanel = ({ opened, branchName, comments, canResolve, curren
|
|
|
65
59
|
setIsSubmitting(false);
|
|
66
60
|
}
|
|
67
61
|
};
|
|
68
|
-
|
|
69
|
-
|
|
62
|
+
// "Entry › Field", or undefined for a thread on the branch itself.
|
|
63
|
+
const threadPath = (thread) => {
|
|
70
64
|
if (!thread.entryPath)
|
|
71
|
-
return
|
|
65
|
+
return undefined;
|
|
72
66
|
const entry = entryLabel?.(thread.entryPath) ?? thread.entryPath;
|
|
73
67
|
const field = thread.canopyPath ? (fieldLabel?.(thread) ?? thread.canopyPath) : undefined;
|
|
74
|
-
return
|
|
68
|
+
return field ? `${entry} › ${field}` : entry;
|
|
69
|
+
};
|
|
70
|
+
const threadHeading = (thread) => {
|
|
71
|
+
const kind = THREAD_KIND_LABELS[thread.type];
|
|
72
|
+
const path = threadPath(thread);
|
|
73
|
+
return path ? `${kind} · ${path}` : kind;
|
|
74
|
+
};
|
|
75
|
+
const jumpTarget = (thread) => {
|
|
76
|
+
const { entryPath, canopyPath } = thread;
|
|
77
|
+
if (thread.type === 'field' &&
|
|
78
|
+
entryPath &&
|
|
79
|
+
canopyPath &&
|
|
80
|
+
!thread.onRemovedItem &&
|
|
81
|
+
onJumpToField) {
|
|
82
|
+
return { label: 'Jump to field', go: () => onJumpToField(entryPath, canopyPath, thread.id) };
|
|
83
|
+
}
|
|
84
|
+
// A removed item's thread lives on its entry.
|
|
85
|
+
if ((thread.type === 'entry' || thread.onRemovedItem) && entryPath && onJumpToEntry) {
|
|
86
|
+
return { label: 'Jump to entry', go: () => onJumpToEntry(entryPath, thread.id) };
|
|
87
|
+
}
|
|
88
|
+
if (thread.type === 'branch' && onJumpToBranch) {
|
|
89
|
+
return { label: 'Open branch discussion', go: () => onJumpToBranch(thread.id) };
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
const setHeadingRef = (threadId, node) => {
|
|
94
|
+
if (node)
|
|
95
|
+
headingRefs.current.set(threadId, node);
|
|
96
|
+
else
|
|
97
|
+
headingRefs.current.delete(threadId);
|
|
75
98
|
};
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
99
|
+
// The heading's text names the article; the removed-item badge beside it is outside it.
|
|
100
|
+
const headingText = (thread) => (_jsxs("span", { id: headingId(thread.id), "data-testid": "comment-thread-heading", children: [threadHeading(thread), thread.onRemovedItem && _jsxs(VisuallyHidden, { children: [", ", ON_REMOVED_ITEM.toLowerCase()] })] }));
|
|
101
|
+
return (_jsx(OverlayDrawer, { opened: opened, onClose: close, returnFocus: !jumping, position: "right", size: 420, title: "Comments", children: _jsxs(Stack, { gap: "md", h: "100%", style: { display: 'flex', flexDirection: 'column' }, children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Select a comment's heading to see it where it was left." }), _jsx(CloseRefusedNotice, { refused: closeRefused }), _jsx(Paper, { withBorder: true, p: "md", children: _jsxs(Stack, { gap: "sm", children: [_jsxs(Text, { size: "xs", c: "dimmed", children: ["Posts a branch-level comment on \"", branchName, "\". To comment on a field, use its comment icon."] }), _jsx(Textarea, { placeholder: "Add a comment\u2026", "aria-label": "New branch comment", value: newCommentText, onChange: (e) => setNewCommentText(e.target.value), minRows: 3, disabled: isSubmitting, "data-testid": "comment-textarea" }), error && _jsx(CommentWriteAlert, { failure: error, onClose: () => setError(null) }), _jsx(Button, { onClick: handleAddComment, size: "sm", loading: isSubmitting, disabled: !newCommentText.trim(), "data-testid": "comment-submit", children: "Comment" })] }) }), _jsxs(Group, { justify: "space-between", children: [_jsxs(Text, { ref: threadCountRef, size: "sm", fw: 500, tabIndex: -1, children: [filteredThreads.length, " ", filteredThreads.length === 1 ? 'thread' : 'threads'] }), _jsx(Switch, { label: "Show resolved", checked: showResolved, onChange: (e) => setShowResolved(e.currentTarget.checked), size: "sm" })] }), _jsx(ScrollArea, { style: { flex: 1 }, children: filteredThreads.length === 0 ? (_jsx(Text, { size: "sm", c: "dimmed", ta: "center", py: "xl", children: showResolved ? 'No comments yet' : 'No unresolved comments' })) : (_jsx(Stack, { gap: "md", children: filteredThreads.map((thread) => {
|
|
102
|
+
const target = jumpTarget(thread);
|
|
103
|
+
return (_jsx(Paper, { withBorder: true, p: "md", component: "article", "aria-labelledby": headingId(thread.id), bg: thread.resolved ? 'var(--mantine-color-default-hover)' : undefined, "data-testid": "comment-thread", children: _jsxs(Stack, { gap: "xs", children: [_jsxs(Group, { gap: "xs", children: [target ? (
|
|
104
|
+
// The heading is the jump control, the one tab stop on the card, in an
|
|
105
|
+
// `h3` under the drawer's `h2` so it is reachable by heading. It stays
|
|
106
|
+
// focusable while a write is in flight (`aria-disabled`, not `disabled`),
|
|
107
|
+
// so its reason opens on keyboard focus and touch as well as hover, and
|
|
108
|
+
// keeps its text colour: only the cursor says it is waiting. The visible
|
|
109
|
+
// text is inside the button's name (WCAG 2.5.3), after a hidden prefix
|
|
110
|
+
// that says what it does.
|
|
111
|
+
_jsx(Text, { component: "h3", size: "xs", fw: 600, m: 0, children: _jsx(Tooltip, { label: "Your change is still saving. Try again once it finishes.", disabled: inFlight === 0, multiline: true, maw: 280, children: _jsxs(Button, { ref: (node) => setHeadingRef(thread.id, node), variant: "subtle", color: "neutral", h: "auto", px: 6, py: 2, mx: -6, rightSection: _jsx(IconChevronRight, { size: 14, "aria-hidden": true }), "aria-disabled": inFlight > 0 ? true : undefined, styles: {
|
|
112
|
+
root: { cursor: inFlight > 0 ? 'default' : 'pointer' },
|
|
113
|
+
label: {
|
|
114
|
+
whiteSpace: 'normal',
|
|
115
|
+
textAlign: 'start',
|
|
116
|
+
lineHeight: 1.35,
|
|
117
|
+
overflowWrap: 'anywhere',
|
|
118
|
+
},
|
|
119
|
+
}, onClick: () => {
|
|
120
|
+
if (inFlight === 0)
|
|
121
|
+
jump(target.go);
|
|
122
|
+
}, children: [_jsx(VisuallyHidden, { component: "span", children: `${target.label}: ` }), headingText(thread)] }) }) })) : (_jsx(Text, { component: "h3", ref: (node) => setHeadingRef(thread.id, node), size: "xs", fw: 600, m: 0, tabIndex: -1, children: headingText(thread) })), thread.resolved && (_jsx(Badge, { size: "xs", color: "green", variant: "light", children: "Resolved" })), thread.onRemovedItem && (_jsx(Badge, { size: "xs", color: "gray", variant: "light", c: "var(--mantine-color-text)", children: ON_REMOVED_ITEM }))] }), thread.comments.map((comment, idx) => (_jsxs("div", { children: [idx > 0 && (_jsx("div", { style: {
|
|
123
|
+
borderTop: '1px solid var(--mantine-color-default-border)',
|
|
124
|
+
margin: '8px 0',
|
|
125
|
+
} })), _jsxs(Stack, { gap: 4, children: [_jsxs(Group, { gap: "xs", children: [onGetUserMetadata ? (_jsx(UserBadge, { userId: comment.userId, getUserMetadata: onGetUserMetadata, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })) : (_jsx(Text, { size: "xs", fw: 500, children: userIdLabel(comment.userId) })), _jsx(RelativeTime, { iso: comment.timestamp, size: "xs", c: "dimmed" })] }), _jsx(Text, { size: "sm", style: { whiteSpace: 'pre-wrap' }, children: comment.text })] })] }, comment.id))), !thread.resolved && (_jsx(ThreadReplyControls, { thread: thread, onAddReply: (text) => addComment(text, thread.type, thread.entryPath, thread.canopyPath, thread.id), onResolve: () => resolveThread(thread.id), focusAfterResolve: () => showResolved
|
|
126
|
+
? headingRefs.current.get(thread.id)
|
|
127
|
+
: threadCountRef.current, currentUserId: currentUserId, canResolve: canResolve, replyText: replyDrafts[thread.id] ?? '', onReplyTextChange: (text) => setReplyDrafts((prev) => ({ ...prev, [thread.id]: text })) }))] }) }, thread.id));
|
|
128
|
+
}) })) })] }) }));
|
|
91
129
|
};
|
package/dist/editor/Editor.d.ts
CHANGED
|
@@ -7,7 +7,10 @@ import type { LogicalPath, ContentId } from '../paths/types.js';
|
|
|
7
7
|
export interface EditorEntry {
|
|
8
8
|
path: LogicalPath;
|
|
9
9
|
contentId: ContentId;
|
|
10
|
+
/** What the editor calls the entry: its title, or a labelled singleton's type label. */
|
|
10
11
|
label: string;
|
|
12
|
+
/** The entry's title, kept where `label` is a singleton's type label: link text and search use it. */
|
|
13
|
+
title?: string;
|
|
11
14
|
status?: string;
|
|
12
15
|
schema: EntrySchema;
|
|
13
16
|
previewSrc?: string;
|