@anthusai/papyrus 5.44.0 → 5.46.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/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +8 -0
- package/amplify/functions/monitoring-view/schema.ts +8 -0
- package/bin/papyrus-app.mjs +12 -1
- package/components/monitoring-overview-view.tsx +52 -6
- package/components/monitoring-panel.tsx +61 -3
- package/components/news-desk-client-provider.tsx +16 -1
- package/components/news-desk-page.tsx +2 -1
- package/components/newsroom-access-gate.tsx +7 -5
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +63 -60
- package/components/newsroom-ops-shell.tsx +4 -2
- package/components/newsroom-organizer.css +49 -2
- package/components/newsroom-references-view.tsx +296 -23
- package/components/notification-cards.tsx +364 -0
- package/components/notification-center.tsx +319 -331
- package/components/reference-capture-client.ts +110 -0
- package/components/reference-capture-panel.tsx +180 -0
- package/components/reference-feed.tsx +502 -0
- package/components/reference-review-card.tsx +32 -80
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/review-rating-grid.tsx +177 -0
- package/components/topic-steering-workspace.tsx +43 -9
- package/components/use-reference-ratings.ts +132 -0
- package/lib/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +16 -2
- package/lib/monitoring-overview.ts +20 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +1 -1
- package/lib/notification-feed.ts +250 -0
- package/lib/reference-capture.ts +293 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +235 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-drafts.ts +8 -3
- package/lib/site-brand.ts +2 -0
- package/package.json +17 -1
- package/routes.manifest.json +1 -1
package/amplify/data/resource.js
CHANGED
|
@@ -603,6 +603,12 @@ const schema = a.schema({
|
|
|
603
603
|
.returns(a.json())
|
|
604
604
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
605
605
|
.handler(functionHandler("contentActions", contentActions)),
|
|
606
|
+
correctReferenceCapture: a
|
|
607
|
+
.mutation()
|
|
608
|
+
.arguments({ input: a.json().required() })
|
|
609
|
+
.returns(a.json())
|
|
610
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
611
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
606
612
|
saveItemDraft: a
|
|
607
613
|
.mutation()
|
|
608
614
|
.arguments({ input: a.json().required() })
|
package/amplify/data/resource.ts
CHANGED
|
@@ -657,6 +657,13 @@ const schema = a.schema({
|
|
|
657
657
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
658
658
|
.handler(functionHandler("contentActions", contentActions)),
|
|
659
659
|
|
|
660
|
+
correctReferenceCapture: a
|
|
661
|
+
.mutation()
|
|
662
|
+
.arguments({ input: a.json().required() })
|
|
663
|
+
.returns(a.json())
|
|
664
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
665
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
666
|
+
|
|
660
667
|
saveItemDraft: a
|
|
661
668
|
.mutation()
|
|
662
669
|
.arguments({ input: a.json().required() })
|
package/amplify/data/schema.js
CHANGED
|
@@ -530,6 +530,12 @@ function buildBaseDefinitions(includeEditions) {
|
|
|
530
530
|
.returns(a.json())
|
|
531
531
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
532
532
|
.handler(functionHandler("contentActions", contentActions)),
|
|
533
|
+
correctReferenceCapture: a
|
|
534
|
+
.mutation()
|
|
535
|
+
.arguments({ input: a.json().required() })
|
|
536
|
+
.returns(a.json())
|
|
537
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
538
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
533
539
|
saveItemDraft: a
|
|
534
540
|
.mutation()
|
|
535
541
|
.arguments({ input: a.json().required() })
|
package/amplify/data/schema.ts
CHANGED
|
@@ -582,6 +582,13 @@ const definitions = {
|
|
|
582
582
|
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
583
583
|
.handler(functionHandler("contentActions", contentActions)),
|
|
584
584
|
|
|
585
|
+
correctReferenceCapture: a
|
|
586
|
+
.mutation()
|
|
587
|
+
.arguments({ input: a.json().required() })
|
|
588
|
+
.returns(a.json())
|
|
589
|
+
.authorization((allow) => [allow.groups(contentWriteGroups)])
|
|
590
|
+
.handler(functionHandler("contentActions", contentActions)),
|
|
591
|
+
|
|
585
592
|
saveItemDraft: a
|
|
586
593
|
.mutation()
|
|
587
594
|
.arguments({ input: a.json().required() })
|
|
@@ -365,6 +365,14 @@ def handler(event, context):
|
|
|
365
365
|
client, arguments, event.get("identity") or {}, receipt_store=receipt_store)
|
|
366
366
|
except IntakeError as error:
|
|
367
367
|
return {"ok": False, "errors": [{"code": "intake-rejected", "message": str(error)}]}
|
|
368
|
+
if field == "correctReferenceCapture":
|
|
369
|
+
from papyrus_content.reference_curation_store import CurationError, correct_reference_capture
|
|
370
|
+
|
|
371
|
+
client, _claims = create_authoring_client()
|
|
372
|
+
try:
|
|
373
|
+
return correct_reference_capture(client, arguments, event.get("identity") or {})
|
|
374
|
+
except CurationError as error:
|
|
375
|
+
return {"ok": False, "errors": [{"code": "correction-rejected", "message": str(error)}]}
|
|
368
376
|
if field == "deriveMarkus":
|
|
369
377
|
return derive_markus_action(arguments)
|
|
370
378
|
if field == "saveItemDraft":
|
|
@@ -14,6 +14,14 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
14
14
|
.returns(a.json())
|
|
15
15
|
.authorization((allow) => [allow.groups(editors)])
|
|
16
16
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
17
|
+
// One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
|
|
18
|
+
// the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
|
|
19
|
+
monitoringPage: a
|
|
20
|
+
.query()
|
|
21
|
+
.arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
|
|
22
|
+
.returns(a.json())
|
|
23
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
24
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
17
25
|
monitoringEmailSubmission: a
|
|
18
26
|
.query()
|
|
19
27
|
.arguments({ receiptId: a.string().required() })
|
|
@@ -16,6 +16,14 @@ export const monitoringViewSchemaDefinitions = {
|
|
|
16
16
|
.returns(a.json())
|
|
17
17
|
.authorization((allow) => [allow.groups(editors)])
|
|
18
18
|
.handler(functionHandler("monitoringView", monitoringView)),
|
|
19
|
+
// One more page of one Monitoring list (runs, routerRuns, items, emailSubmissions), after a cursor from
|
|
20
|
+
// the overview or a previous page. filter "held" lists only held email submissions, restarting from the newest.
|
|
21
|
+
monitoringPage: a
|
|
22
|
+
.query()
|
|
23
|
+
.arguments({ list: a.string().required(), cursor: a.string(), filter: a.string(), scope: a.string() })
|
|
24
|
+
.returns(a.json())
|
|
25
|
+
.authorization((allow) => [allow.groups(editors)])
|
|
26
|
+
.handler(functionHandler("monitoringView", monitoringView)),
|
|
19
27
|
monitoringEmailSubmission: a
|
|
20
28
|
.query()
|
|
21
29
|
.arguments({ receiptId: a.string().required() })
|
package/bin/papyrus-app.mjs
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
2
3
|
import { sync } from "../src/sync.mjs";
|
|
3
4
|
|
|
4
5
|
const [command, ...rest] = process.argv.slice(2);
|
|
5
6
|
if (command === "sync") {
|
|
6
7
|
sync();
|
|
8
|
+
} else if (command === "brand" && rest[0] === "diff") {
|
|
9
|
+
const { brandDiff } = await import("../src/brand.mjs");
|
|
10
|
+
const flag = rest.indexOf("--papyrus");
|
|
11
|
+
const checkout = flag === -1 ? undefined : rest[flag + 1];
|
|
12
|
+
try {
|
|
13
|
+
process.exit(brandDiff({ id: rest[1], checkout: checkout && path.resolve(checkout) }).drifted ? 1 : 0);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
console.error(error.message);
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
7
18
|
} else if (command === "videoml-bundle") {
|
|
8
19
|
const preview = rest.includes("--preview");
|
|
9
20
|
const { buildBrowserBundle } = await import("../scripts/videoml/build-browser-bundle.mjs");
|
|
@@ -15,6 +26,6 @@ if (command === "sync") {
|
|
|
15
26
|
process.exit(1);
|
|
16
27
|
}
|
|
17
28
|
} else {
|
|
18
|
-
console.error("Usage: papyrus-app sync | papyrus-app videoml-bundle [--preview]");
|
|
29
|
+
console.error("Usage: papyrus-app sync | papyrus-app brand diff <brand-id> [--papyrus <checkout>] | papyrus-app videoml-bundle [--preview]");
|
|
19
30
|
process.exit(command ? 1 : 0);
|
|
20
31
|
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
type EmailDecisionResult,
|
|
19
19
|
type EmailQuarantine,
|
|
20
20
|
type MonitoringItem,
|
|
21
|
+
type MonitoringListName,
|
|
21
22
|
type MonitoringEmailDetails,
|
|
22
23
|
type MonitoringEmailArticleResolution,
|
|
23
24
|
type EmailReferenceImportItem,
|
|
@@ -30,6 +31,27 @@ import {
|
|
|
30
31
|
|
|
31
32
|
export type MonitoringMode = "publication" | "all";
|
|
32
33
|
|
|
34
|
+
/** How a paged list loads more: the page owns the cursors and the requests, the view only shows the button. */
|
|
35
|
+
export type MonitoringPaging = {
|
|
36
|
+
/** Loads the next page of a list; the page appends it. */
|
|
37
|
+
onLoadMore?: (list: MonitoringListName) => void;
|
|
38
|
+
/** The list whose next page is being fetched. */
|
|
39
|
+
loadingList?: MonitoringListName | null;
|
|
40
|
+
/** Show only email submissions held for review (restarts the email list from its newest held message). */
|
|
41
|
+
heldOnly?: boolean;
|
|
42
|
+
onHeldOnlyChange?: (heldOnly: boolean) => void;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function LoadMore({ list, cursor, paging }: { list: MonitoringListName; cursor: string | null | undefined; paging?: MonitoringPaging }) {
|
|
46
|
+
if (!cursor || !paging?.onLoadMore) return null;
|
|
47
|
+
const loading = paging.loadingList === list;
|
|
48
|
+
return (
|
|
49
|
+
<button type="button" data-monitoring-load-more={list} disabled={loading} onClick={() => paging.onLoadMore?.(list)}>
|
|
50
|
+
{loading ? "Loading…" : "Load more"}
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
const STATUS_WORDS: Record<string, string> = { complete: "Complete", partial: "Partial", stopped: "Stopped", failed: "Failed", error: "Error" };
|
|
34
56
|
const status = (value: string) => STATUS_WORDS[value] ?? value;
|
|
35
57
|
const STOP_WORDS: Record<string, string> = {
|
|
@@ -102,8 +124,9 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
|
|
|
102
124
|
);
|
|
103
125
|
}
|
|
104
126
|
|
|
105
|
-
export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide }: {
|
|
127
|
+
export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide, cursor, paging }: {
|
|
106
128
|
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
|
|
129
|
+
cursor?: string | null; paging?: MonitoringPaging;
|
|
107
130
|
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
108
131
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
109
132
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
@@ -121,6 +144,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
121
144
|
const receiptKey = receiptId ?? "";
|
|
122
145
|
const quarantine = (detail && "submission" in detail ? decidedByReceipt[receiptKey] ?? detail.submission.quarantine : null);
|
|
123
146
|
const accepted = emailSubmissionAccepted(quarantine);
|
|
147
|
+
const groupInfo = detail && "submission" in detail ? items.find((item) => item.receiptId === detail.submission.receiptId)?.group : undefined;
|
|
124
148
|
const reason = reasonByReceipt[receiptKey] ?? "";
|
|
125
149
|
const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
|
|
126
150
|
const selected = selectedByReceipt[receiptKey] ?? {};
|
|
@@ -242,9 +266,23 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
242
266
|
return (
|
|
243
267
|
<article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
|
|
244
268
|
<div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
|
|
269
|
+
{paging?.onHeldOnlyChange ? (
|
|
270
|
+
<div className="news-desk-monitoring__scope" role="group" aria-label="Filter email submissions">
|
|
271
|
+
<button type="button" aria-pressed={!paging.heldOnly} data-email-filter="all" onClick={() => paging.onHeldOnlyChange?.(false)}>All</button>
|
|
272
|
+
<button type="button" aria-pressed={paging.heldOnly === true} data-email-filter="held" onClick={() => paging.onHeldOnlyChange?.(true)}>Held for review</button>
|
|
273
|
+
</div>
|
|
274
|
+
) : null}
|
|
245
275
|
{items.length ? <ul className="news-desk-monitoring__list">
|
|
246
276
|
{items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
|
|
247
277
|
<strong>{item.subject || "(no subject)"}</strong>
|
|
278
|
+
{item.group ? <details data-email-group={item.group.fingerprint}>
|
|
279
|
+
<summary>Received {item.group.count} times · latest {formatUtc(item.observedAt)}</summary>
|
|
280
|
+
<ol>
|
|
281
|
+
{item.group.copies.map((copy) => <li key={copy.receiptId} data-email-copy={copy.receiptId}>
|
|
282
|
+
{formatUtc(copy.observedAt)} · {copy.quarantine ? QUARANTINE_LABELS[copy.quarantine.state] : copy.status}
|
|
283
|
+
</li>)}
|
|
284
|
+
</ol>
|
|
285
|
+
</details> : null}
|
|
248
286
|
<span>{formatUtc(item.observedAt)} · {item.linkCount} candidate links · {item.reviewRequired
|
|
249
287
|
? `${item.linkCount} await editor import` : `${item.processedCount} queued`}</span>
|
|
250
288
|
{item.reviewRequired ? <span>Authentication warnings: private review required; nothing was routed automatically.</span> : null}
|
|
@@ -256,7 +294,8 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
256
294
|
{item.quarantine ? <span data-email-quarantine-state={item.quarantine.state}>{QUARANTINE_LABELS[item.quarantine.state]}</span> : null}
|
|
257
295
|
{onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
|
|
258
296
|
</li>)}
|
|
259
|
-
</ul> : <p>No email submissions are available for this publication
|
|
297
|
+
</ul> : <p>{paging?.heldOnly ? "No email submissions are held for review." : "No email submissions are available for this publication."}</p>}
|
|
298
|
+
<LoadMore list="emailSubmissions" cursor={cursor} paging={paging} />
|
|
260
299
|
{detail && "submission" in detail ? (
|
|
261
300
|
<section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
|
|
262
301
|
<h4>{detail.submission.subject || "Submitted email"}</h4>
|
|
@@ -266,6 +305,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
266
305
|
<h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
|
|
267
306
|
{quarantine.state === "quarantined" ? <>
|
|
268
307
|
<p>This message was held privately and nothing was routed. Accept it to queue its links as if it had been accepted, or Reject it to close it. Sender identity and authentication signals never grant access; the decision is yours and is recorded.</p>
|
|
308
|
+
{groupInfo ? <p data-email-group-note>One decision applies to all {groupInfo.count} copies of this email.</p> : null}
|
|
269
309
|
<label>Why? (optional)
|
|
270
310
|
<textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
|
|
271
311
|
onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
|
|
@@ -278,7 +318,7 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
278
318
|
</button>
|
|
279
319
|
</> : null}
|
|
280
320
|
{quarantine.decision ? <p data-email-decision-record>
|
|
281
|
-
{quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}
|
|
321
|
+
{quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}{quarantine.decision.viaGroup ? " (one decision for every copy of this email)" : ""}
|
|
282
322
|
{quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
|
|
283
323
|
{quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
|
|
284
324
|
</p> : null}
|
|
@@ -323,8 +363,8 @@ export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink,
|
|
|
323
363
|
);
|
|
324
364
|
}
|
|
325
365
|
|
|
326
|
-
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide }: {
|
|
327
|
-
data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
366
|
+
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide, paging }: {
|
|
367
|
+
data: MonitoringOverview; demo: boolean; paging?: MonitoringPaging; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
328
368
|
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
329
369
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
330
370
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
@@ -337,6 +377,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
337
377
|
) : null}
|
|
338
378
|
|
|
339
379
|
<EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
|
|
380
|
+
cursor={data.cursors?.emailSubmissions} paging={paging}
|
|
340
381
|
onResolveArticleLink={demo ? undefined : onResolveArticleLink}
|
|
341
382
|
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
342
383
|
onDecide={demo ? undefined : onDecide} />
|
|
@@ -397,6 +438,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
397
438
|
))}
|
|
398
439
|
</ul>
|
|
399
440
|
) : <p>No collector runs recorded yet.</p>}
|
|
441
|
+
<LoadMore list="runs" cursor={data.cursors?.runs} paging={paging} />
|
|
400
442
|
{data.routerRuns.length ? (
|
|
401
443
|
<ul className="news-desk-monitoring__list" data-monitoring-router-runs>
|
|
402
444
|
{data.routerRuns.map((run) => (
|
|
@@ -409,6 +451,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
409
451
|
))}
|
|
410
452
|
</ul>
|
|
411
453
|
) : null}
|
|
454
|
+
<LoadMore list="routerRuns" cursor={data.cursors?.routerRuns} paging={paging} />
|
|
412
455
|
</article>
|
|
413
456
|
|
|
414
457
|
<article className="news-desk-doctrine-card" data-monitoring-section="queue">
|
|
@@ -418,6 +461,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
418
461
|
{data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
419
462
|
</ul>
|
|
420
463
|
) : <p>No items in the queue.</p>}
|
|
464
|
+
<LoadMore list="items" cursor={data.cursors?.items} paging={paging} />
|
|
421
465
|
{data.hiddenNotRelevant > 0 ? (
|
|
422
466
|
<p className="news-desk-delegation-note" data-monitoring-hidden>
|
|
423
467
|
{data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
|
|
@@ -448,7 +492,9 @@ export function MonitoringOverviewView({
|
|
|
448
492
|
onResolveArticleLink,
|
|
449
493
|
onImportCandidates,
|
|
450
494
|
onDecide,
|
|
495
|
+
paging,
|
|
451
496
|
}: {
|
|
497
|
+
paging?: MonitoringPaging;
|
|
452
498
|
result: MonitoringResult | null;
|
|
453
499
|
canViewAll: boolean;
|
|
454
500
|
mode: MonitoringMode;
|
|
@@ -475,7 +521,7 @@ export function MonitoringOverviewView({
|
|
|
475
521
|
body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector's first run.</Message>;
|
|
476
522
|
} else {
|
|
477
523
|
body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
|
|
478
|
-
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} />;
|
|
524
|
+
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} paging={paging} />;
|
|
479
525
|
}
|
|
480
526
|
const scopeLine = result && "scope" in result
|
|
481
527
|
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
@@ -7,10 +7,18 @@ import {
|
|
|
7
7
|
importMonitoringEmailCandidates,
|
|
8
8
|
loadMonitoringEmailSubmission,
|
|
9
9
|
loadMonitoringOverview,
|
|
10
|
+
loadMonitoringPage,
|
|
10
11
|
resolveMonitoringEmailArticleLink,
|
|
11
12
|
} from "../lib/monitoring-client";
|
|
12
13
|
import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
|
|
13
|
-
import
|
|
14
|
+
import { withLoadedPages } from "../lib/monitoring-paging";
|
|
15
|
+
import type {
|
|
16
|
+
EmailReferenceImportItem,
|
|
17
|
+
MonitoringEmailDetails,
|
|
18
|
+
MonitoringListName,
|
|
19
|
+
MonitoringResult,
|
|
20
|
+
} from "../lib/monitoring-overview";
|
|
21
|
+
import type { MonitoringLoadedPages } from "../lib/monitoring-paging";
|
|
14
22
|
import { EmailSubmissions, MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
15
23
|
|
|
16
24
|
const REFRESH_MILLISECONDS = 60_000;
|
|
@@ -50,6 +58,18 @@ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo
|
|
|
50
58
|
const [mode, setMode] = useState<MonitoringMode>("publication");
|
|
51
59
|
const [result, setResult] = useState<MonitoringResult | null>(null);
|
|
52
60
|
const [emailDetail, setEmailDetail] = useState<MonitoringEmailDetails | null>(null);
|
|
61
|
+
// Pages the editor loaded beyond the newest page of each list. The 60 second refresh re-reads only the newest
|
|
62
|
+
// page, so what was loaded stays on screen.
|
|
63
|
+
const [loaded, setLoaded] = useState<MonitoringLoadedPages>({});
|
|
64
|
+
const [heldOnly, setHeldOnly] = useState(false);
|
|
65
|
+
const [held, setHeld] = useState<MonitoringLoadedPages["emailSubmissions"] | null>(null);
|
|
66
|
+
const [loadingList, setLoadingList] = useState<MonitoringListName | null>(null);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
setLoaded({});
|
|
70
|
+
setHeldOnly(false);
|
|
71
|
+
setHeld(null);
|
|
72
|
+
}, [mode]);
|
|
53
73
|
|
|
54
74
|
useEffect(() => {
|
|
55
75
|
if (isDemo) {
|
|
@@ -80,13 +100,51 @@ function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo
|
|
|
80
100
|
setEmailDetail(next);
|
|
81
101
|
};
|
|
82
102
|
|
|
103
|
+
// Appends the next page of a list, or (held filter) the next page of held messages.
|
|
104
|
+
const loadMore = async (list: MonitoringListName) => {
|
|
105
|
+
if (isDemo || !result || !("scope" in result) || loadingList) return;
|
|
106
|
+
const current = withLoadedPages(result as never, loaded, heldOnly ? held : null);
|
|
107
|
+
const cursor = current.cursors?.[list];
|
|
108
|
+
if (!cursor) return;
|
|
109
|
+
setLoadingList(list);
|
|
110
|
+
try {
|
|
111
|
+
const page = await loadMonitoringPage(list, cursor, mode, heldOnly && list === "emailSubmissions");
|
|
112
|
+
if (!("rows" in page)) return;
|
|
113
|
+
if (heldOnly && list === "emailSubmissions") {
|
|
114
|
+
setHeld((existing) => ({ rows: [...(existing?.rows ?? []), ...page.rows] as never, cursor: page.cursor }));
|
|
115
|
+
} else {
|
|
116
|
+
setLoaded((existing) => ({ ...existing, [list]: { rows: [...(existing[list]?.rows ?? []), ...page.rows], cursor: page.cursor } }) as MonitoringLoadedPages);
|
|
117
|
+
}
|
|
118
|
+
} finally {
|
|
119
|
+
setLoadingList(null);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// Held for review restarts the email list at its newest held message, however old.
|
|
124
|
+
const changeHeldOnly = async (next: boolean) => {
|
|
125
|
+
setHeldOnly(next);
|
|
126
|
+
setHeld(null);
|
|
127
|
+
if (!next || isDemo) return;
|
|
128
|
+
setLoadingList("emailSubmissions");
|
|
129
|
+
try {
|
|
130
|
+
const page = await loadMonitoringPage("emailSubmissions", null, mode, true);
|
|
131
|
+
if ("rows" in page) setHeld({ rows: page.rows as never, cursor: page.cursor });
|
|
132
|
+
else setHeldOnly(false);
|
|
133
|
+
} finally {
|
|
134
|
+
setLoadingList(null);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const shown = result && "scope" in result ? withLoadedPages(result as never, loaded, heldOnly ? held ?? { rows: [], cursor: null } : null) : result;
|
|
139
|
+
|
|
83
140
|
const resolveEmailArticleLink = (receiptId: string, candidateIndex: number) =>
|
|
84
141
|
resolveMonitoringEmailArticleLink(receiptId, candidateIndex);
|
|
85
142
|
|
|
86
143
|
const importEmailCandidates = (receiptId: string, candidates: EmailReferenceImportItem[]) =>
|
|
87
144
|
importMonitoringEmailCandidates(receiptId, candidates);
|
|
88
145
|
|
|
89
|
-
return <MonitoringOverviewView result={
|
|
146
|
+
return <MonitoringOverviewView result={shown} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
|
|
90
147
|
emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
|
|
91
|
-
onImportCandidates={importEmailCandidates} onDecide={isDemo ? undefined : decideMonitoringEmailSubmission}
|
|
148
|
+
onImportCandidates={importEmailCandidates} onDecide={isDemo ? undefined : decideMonitoringEmailSubmission}
|
|
149
|
+
paging={isDemo ? undefined : { onLoadMore: (list) => void loadMore(list), loadingList, heldOnly, onHeldOnlyChange: (next) => void changeHeldOnly(next) }} />;
|
|
92
150
|
}
|
|
@@ -6,6 +6,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
|
|
6
6
|
import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
7
7
|
import type { CategorySteeringDashboard } from "../lib/category-repository";
|
|
8
8
|
import { ensureDevSandboxEditorSession } from "./dev-sandbox-editor-sign-in";
|
|
9
|
+
import { discardReferencesHeadStart, prefetchReferencesFeed, wantsReferencesHeadStart } from "./references-prefetch";
|
|
10
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
11
|
+
import { clearSavedNewsroomDisplay, loadSessionDisplay, saveSessionDisplay } from "../lib/reference-feed-cache";
|
|
9
12
|
import {
|
|
10
13
|
loadEditorAssignmentsData,
|
|
11
14
|
loadEditorDoctrineRecordsData,
|
|
@@ -75,7 +78,12 @@ export function NewsDeskClientProvider({
|
|
|
75
78
|
));
|
|
76
79
|
return;
|
|
77
80
|
}
|
|
78
|
-
|
|
81
|
+
// A recent confirmed session is shown (name, "loading" instead of "checking") while the real check runs again.
|
|
82
|
+
// Display only: the check below still decides access, and the backend authorizes every call.
|
|
83
|
+
const remembered = loadSessionDisplay(SITE_BRAND.id);
|
|
84
|
+
setShell((current) => (remembered && !current.dashboard
|
|
85
|
+
? { ...beginAccessCheck(current), phase: "loadingDesk" as const, auth: { status: "signedIn" as const, label: remembered.label } }
|
|
86
|
+
: beginAccessCheck(current)));
|
|
79
87
|
|
|
80
88
|
try {
|
|
81
89
|
await ensureDevSandboxEditorSession(devSandboxEditorAuth);
|
|
@@ -88,6 +96,8 @@ export function NewsDeskClientProvider({
|
|
|
88
96
|
if (bootstrapSequenceRef.current !== sequence) return;
|
|
89
97
|
|
|
90
98
|
if (access.status === "signedOut") {
|
|
99
|
+
clearSavedNewsroomDisplay(SITE_BRAND.id);
|
|
100
|
+
discardReferencesHeadStart();
|
|
91
101
|
setShell((current) => resolveSignedOut(current, access.auth));
|
|
92
102
|
return;
|
|
93
103
|
}
|
|
@@ -100,7 +110,10 @@ export function NewsDeskClientProvider({
|
|
|
100
110
|
return;
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
saveSessionDisplay(SITE_BRAND.id, { label: access.auth.label, isAdmin: access.isAdmin });
|
|
103
114
|
setShell((current) => beginDeskLoad(current, access.auth));
|
|
115
|
+
// The References reads do not depend on the summary dashboard: start them now, side by side with it.
|
|
116
|
+
if (typeof window !== "undefined" && wantsReferencesHeadStart(window.location.pathname)) prefetchReferencesFeed();
|
|
104
117
|
|
|
105
118
|
try {
|
|
106
119
|
const dashboard = await loadEditorNewsDeskDashboard({ isAdmin: access.isAdmin });
|
|
@@ -183,6 +196,8 @@ export function NewsDeskClientProvider({
|
|
|
183
196
|
const unsubscribe = Hub.listen("auth", ({ payload }) => {
|
|
184
197
|
if (payload.event === "signedOut") {
|
|
185
198
|
bootstrapSequenceRef.current += 1;
|
|
199
|
+
clearSavedNewsroomDisplay(SITE_BRAND.id);
|
|
200
|
+
discardReferencesHeadStart();
|
|
186
201
|
setShell((current) => resolveSignedOut(current, { status: "signedOut", label: "Signed out" }));
|
|
187
202
|
return;
|
|
188
203
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withReferenceFeedFixture } from "../lib/local-preview-feed-fixture";
|
|
1
2
|
import { resolveNewsDeskTab } from "../lib/newsroom-section-routing";
|
|
2
3
|
import { NewsDeskWorkspace, type NewsDeskTab } from "./topic-steering-workspace";
|
|
3
4
|
import {
|
|
@@ -67,7 +68,7 @@ export async function NewsDeskPage({ section: routeSection, selectionPath, searc
|
|
|
67
68
|
const analysisProfiles = await loadAnalysisProfileSummaries(getBrandAnalysisProfilesPath(brand));
|
|
68
69
|
const configuredCorpora = await loadConfiguredCorpusSummaries(getBrandSteeringConfigPath(brand));
|
|
69
70
|
const dashboard = useDemoDashboard
|
|
70
|
-
? createDemoCategorySteeringDashboard(brand.id, getBrandNewsroomSectionsConfigPath(brand))
|
|
71
|
+
? withReferenceFeedFixture(createDemoCategorySteeringDashboard(brand.id, getBrandNewsroomSectionsConfigPath(brand)))
|
|
71
72
|
: await loadCategorySteeringDashboard();
|
|
72
73
|
return <NewsDeskWorkspace analysisProfiles={analysisProfiles} configuredCorpora={configuredCorpora} dashboard={dashboard} initialSelection={initialSelection} initialTab={initialTab} />;
|
|
73
74
|
}
|
|
@@ -9,24 +9,26 @@ import { NewsroomOpsShell } from "./newsroom-ops-shell";
|
|
|
9
9
|
import { NewsroomOverviewSkeleton } from "./newsroom-overview-skeleton";
|
|
10
10
|
import { ReaderAuthControl } from "./reader-auth-control";
|
|
11
11
|
import { buttonVariants } from "./ui/button";
|
|
12
|
+
import { ReferencesPageLoading } from "./references-page-loading";
|
|
12
13
|
|
|
13
|
-
export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
|
|
14
|
+
export function NewsDeskAccessGate({ initialTab, shell, showSectionTabs = false }: { initialTab?: string; shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
|
|
14
15
|
const accessPhase = shell?.phase ?? "checkingAccess";
|
|
15
16
|
const pending = accessPhase === "checkingAccess" || accessPhase === "loadingDesk";
|
|
17
|
+
const referencesPage = initialTab === "references";
|
|
16
18
|
|
|
17
19
|
return (
|
|
18
20
|
<NewsroomOpsShell
|
|
19
|
-
activeTab="overview"
|
|
21
|
+
activeTab={referencesPage ? "references" : "overview"}
|
|
20
22
|
appTitle={SITE_BRAND.appTitle}
|
|
21
23
|
backHref="/"
|
|
22
24
|
backLabel={SITE_BRAND.backToHomeLabel}
|
|
23
25
|
headerActions={(
|
|
24
26
|
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
|
|
25
27
|
)}
|
|
26
|
-
pageTitle={showSectionTabs ? "Notification Center" : "Newsroom"}
|
|
27
|
-
showNavigation={showSectionTabs}
|
|
28
|
+
pageTitle={referencesPage ? "References" : showSectionTabs ? "Notification Center" : "Newsroom"}
|
|
29
|
+
showNavigation={showSectionTabs || referencesPage}
|
|
28
30
|
>
|
|
29
|
-
{pending ? (showSectionTabs ? <NewsroomOverviewSkeleton /> : (
|
|
31
|
+
{pending && referencesPage ? <ReferencesPageLoading /> : pending ? (showSectionTabs ? <NewsroomOverviewSkeleton /> : (
|
|
30
32
|
<div aria-label="Loading newsroom" className="space-y-4" data-newsroom-page-skeleton role="status">
|
|
31
33
|
<div className="h-6 w-48 rounded bg-muted" />
|
|
32
34
|
<div className="h-4 w-3/4 rounded bg-muted" />
|
|
@@ -2,13 +2,14 @@ import { NewsDeskClientProvider } from "./news-desk-client-provider";
|
|
|
2
2
|
import { createDemoCategorySteeringDashboard } from "../lib/category-repository";
|
|
3
3
|
import { resolveDevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
4
4
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
5
|
+
import { withReferenceFeedFixture } from "../lib/local-preview-feed-fixture";
|
|
5
6
|
import { getBrandNewsroomSectionsConfigPath, resolveActiveSiteBrand } from "../lib/site-brand-tenant";
|
|
6
7
|
|
|
7
8
|
export function NewsroomClientShell({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
8
9
|
const devSandboxEditorAuth = resolveDevSandboxEditorAuth();
|
|
9
10
|
const activeBrand = resolveActiveSiteBrand();
|
|
10
11
|
const localPreviewDashboard = isLocalPreviewEnabled()
|
|
11
|
-
? createDemoCategorySteeringDashboard(activeBrand.id, getBrandNewsroomSectionsConfigPath(activeBrand))
|
|
12
|
+
? withReferenceFeedFixture(createDemoCategorySteeringDashboard(activeBrand.id, getBrandNewsroomSectionsConfigPath(activeBrand)))
|
|
12
13
|
: null;
|
|
13
14
|
|
|
14
15
|
return (
|