@anthusai/papyrus 5.44.0 → 5.45.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/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/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-shell.tsx +1 -1
- package/components/newsroom-organizer.css +48 -0
- package/components/newsroom-references-view.tsx +181 -20
- package/components/reference-feed.tsx +469 -0
- package/components/reference-review-card.tsx +12 -15
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +47 -0
- package/components/review-rating-grid.tsx +164 -0
- package/components/topic-steering-workspace.tsx +32 -8
- package/lib/category-repository.ts +4 -1
- package/lib/local-preview-feed-fixture.ts +43 -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/reference-feed-cache.ts +133 -0
- package/lib/reference-feed-ranking.ts +191 -0
- package/lib/reference-preview.ts +112 -0
- package/package.json +11 -1
- package/routes.manifest.json +1 -1
|
@@ -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() })
|
|
@@ -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 } 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" && /\/newsroom\/references(\/|$)/.test(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 (
|
|
@@ -165,7 +165,7 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
|
|
|
165
165
|
<Link className="organizer-masthead" href={backHref} title={backLabel}>{appTitle}</Link>
|
|
166
166
|
<h1 data-newsroom-ops-title>{pageTitle}</h1><OrganizerSearch demo={demo} /><div className="organizer-header-actions">{headerActions}</div><Account placement="header" />
|
|
167
167
|
</header>
|
|
168
|
-
<main className="organizer-main">{demo && <p className="organizer-demo-notice">Demo workspace · synthetic examples</p>}{children}</main>
|
|
168
|
+
<main className="organizer-main">{demo && !session?.shell.dashboard?.realDataPreview && <p className="organizer-demo-notice">Demo workspace · synthetic examples</p>}{children}</main>
|
|
169
169
|
</div>
|
|
170
170
|
{showNavigation && <NewsroomMobileNavigation activeTab={activeTab} items={ORGANIZER_PRIMARY_NAV.map(item => ({ ...item, href: getNewsroomNavHref(item.path, demo) }))}
|
|
171
171
|
chat={consoleState?.shouldOfferConsole ? { open: consoleState.open, onOpen: consoleState.openConsole } : undefined}
|
|
@@ -347,3 +347,51 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
347
347
|
.news-desk-columns--administration .news-desk-newsletter__text{margin:0;padding:12px;max-height:280px;overflow:auto;border:1px solid var(--org-line);font:400 12px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
348
348
|
.news-desk-columns--administration .news-desk-newsletter__filters{display:flex;flex-wrap:wrap;gap:12px 24px}
|
|
349
349
|
.news-desk-columns--administration .news-desk-newsletter__filters .news-desk-doctrine-card__field{min-width:180px}
|
|
350
|
+
|
|
351
|
+
/* The References feed is rounded and borderless (Ryan's design rule): flat tones and rounded rectangles instead of lines and boxes.
|
|
352
|
+
The newsroom chrome squares every "rounded" class above; inside a [data-feed-surface] the rounded classes mean what they say.
|
|
353
|
+
The repeated attribute raises specificity over the squaring rules without !important escalation. */
|
|
354
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-full"],
|
|
355
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-full"] { border-radius: 9999px !important; }
|
|
356
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-[20px]"],
|
|
357
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[20px]"] { border-radius: 20px !important; }
|
|
358
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-2xl"],
|
|
359
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-2xl"] { border-radius: 16px !important; }
|
|
360
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-[16px]"],
|
|
361
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-[16px]"] { border-radius: 16px !important; }
|
|
362
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface] [class*="rounded-3xl"],
|
|
363
|
+
.newsroom-organizer[data-kanbus-theme] [data-feed-surface][data-feed-surface][data-feed-surface][class*="rounded-3xl"] { border-radius: 24px !important; }
|
|
364
|
+
|
|
365
|
+
/* Feed tones. Light: cards a touch lighter than the page, footers between, controls a touch darker. Dark: each step lifts toward the ink.
|
|
366
|
+
Also set on the portaled mobile sheet, which carries the theme attributes itself. */
|
|
367
|
+
[data-kanbus-theme] [data-feed-surface], [data-kanbus-theme][data-feed-surface] {
|
|
368
|
+
--feed-card: color-mix(in oklab, var(--kb-theme-paper) 35%, white);
|
|
369
|
+
--feed-footer: color-mix(in oklab, var(--kb-theme-paper) 70%, white);
|
|
370
|
+
--feed-control: color-mix(in oklab, var(--kb-theme-paper) 90%, var(--kb-theme-ink));
|
|
371
|
+
}
|
|
372
|
+
[data-kanbus-theme][data-color-mode="dark"] [data-feed-surface], [data-kanbus-theme][data-color-mode="dark"][data-feed-surface] {
|
|
373
|
+
--feed-card: color-mix(in oklab, var(--kb-theme-paper) 94%, var(--kb-theme-ink));
|
|
374
|
+
--feed-footer: color-mix(in oklab, var(--kb-theme-paper) 90%, var(--kb-theme-ink));
|
|
375
|
+
--feed-control: color-mix(in oklab, var(--kb-theme-paper) 80%, var(--kb-theme-ink));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/* Borderless detail pane, Add panel and mobile sheet ([data-feed-flat]): no border, rule or box anywhere inside. Regions are told apart
|
|
379
|
+
by tone (card, footer, control) and shapes are rounded rectangles. The repeated attribute outranks the newsroom chrome's own rules. */
|
|
380
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat],
|
|
381
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] * { border-width: 0 !important; }
|
|
382
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] *:not(:focus-visible) { box-shadow: none; }
|
|
383
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is([data-slot=separator], hr) { display: none !important; }
|
|
384
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button], [data-slot=badge]):not([aria-pressed=true]):not([data-slot=sheet-close]):not([class*="hover:bg-muted"]) {
|
|
385
|
+
background: var(--feed-control) !important; border-radius: 9999px !important; color: var(--foreground, inherit);
|
|
386
|
+
}
|
|
387
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button])[aria-pressed=true]:not([class*="destructive"]) {
|
|
388
|
+
background: var(--primary) !important; color: var(--primary-foreground) !important; border-radius: 9999px !important;
|
|
389
|
+
}
|
|
390
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(button, [role=button])[aria-pressed=true][class*="destructive"] { border-radius: 9999px !important; }
|
|
391
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] :is(textarea, input:not([type=radio]):not([type=checkbox]), select, details, [data-cyclotron-choice]:not([data-predicted])) {
|
|
392
|
+
background: var(--feed-control) !important; border-radius: 16px !important;
|
|
393
|
+
}
|
|
394
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-cyclotron-choice] { border-radius: 9999px !important; }
|
|
395
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-review-queue-bar] { background: var(--feed-footer) !important; }
|
|
396
|
+
/* The rating grid keeps its own rounded rectangles in the borderless panes (not the pill shape the flat rules give other buttons). */
|
|
397
|
+
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-rating-grid] :is(button, [data-cyclotron-choice]) { border-radius: 16px !important; }
|