@anthusai/papyrus 5.42.1 → 5.43.0-next.10
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/schema.js +2 -0
- package/amplify/data/schema.ts +2 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/monitoring-view/schema.js +22 -0
- package/amplify/functions/monitoring-view/schema.ts +22 -0
- package/amplify/functions/monitoring-view/wire.js +1 -0
- package/amplify/functions/monitoring-view/wire.ts +1 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/handler.py +137 -0
- package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +20 -3
- package/amplify/functions/proposal-actions/resource.js +15 -0
- package/amplify/functions/proposal-actions/resource.ts +22 -0
- package/amplify/functions/proposal-actions/schema.js +106 -0
- package/amplify/functions/proposal-actions/schema.ts +108 -0
- package/amplify/functions/proposal-actions/wire.js +10 -0
- package/amplify/functions/proposal-actions/wire.ts +13 -0
- package/amplify/plugins/reader-steering/function/handler.py +13 -72
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +5 -5
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +5 -5
- package/amplify/plugins/reader-steering/schema.js +0 -95
- package/amplify/plugins/reader-steering/schema.ts +0 -95
- package/amplify/site-backend.js +4 -0
- package/amplify/site-backend.ts +4 -0
- package/app/.well-known/papyrus-cli.json/route.ts +9 -0
- package/app/globals.css +31 -35
- package/app/tailwind.css +2 -0
- package/bin/papyrus-infra.mjs +21 -1
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/monitoring-overview-view.tsx +140 -23
- package/components/monitoring-panel.tsx +96 -6
- package/components/news-desk-client-provider.tsx +8 -2
- package/components/news-desk-taxonomy-client.ts +2 -1
- package/components/newsroom-mobile-navigation.tsx +23 -0
- package/components/newsroom-ops-overview.tsx +6 -1
- package/components/newsroom-ops-shell.tsx +8 -10
- package/components/newsroom-organizer.css +1 -0
- package/components/newsroom-references-view.tsx +139 -257
- package/components/notification-center.tsx +345 -63
- package/components/papyrus-console-frame.tsx +66 -0
- package/components/papyrus-console-shell.tsx +14 -42
- package/components/papyrus-private-chat-panel.tsx +50 -16
- package/components/proposals-panel.tsx +107 -0
- package/components/proposals-view.tsx +95 -0
- package/components/reference-review-card.tsx +400 -0
- package/components/topic-steering-workspace.tsx +13 -2
- package/components/use-console-visibility.ts +47 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/monitoring-client.ts +39 -3
- package/lib/monitoring-overview-fixture.ts +2 -2
- package/lib/monitoring-overview.ts +47 -3
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-chat-client.ts +30 -0
- package/lib/newsroom-chat-identity.ts +40 -0
- package/lib/newsroom-organizer-nav.ts +1 -0
- package/lib/papyrus-hosted-chat.ts +62 -80
- package/lib/papyrus-private-chat-client.ts +7 -4
- package/lib/proposals-admin.ts +115 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/prospectus-session.ts +2 -0
- package/lib/prospectus-sign-in-routes.ts +19 -2
- package/lib/prospectus-site-identity.ts +7 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/relevance-decisions.ts +9 -0
- package/lib/review-client.ts +38 -1
- package/lib/review-drafts.ts +231 -0
- package/lib/review-fixture.ts +36 -2
- package/lib/review-requests.ts +66 -24
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/package.json +16 -1
- package/routes.manifest.json +12 -1
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.mjs +1 -1
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import { fetchAuthSession, signIn, signOut } from "aws-amplify/auth";
|
|
4
4
|
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
5
|
+
import { hasEditorRights } from "../lib/staff-groups";
|
|
5
6
|
import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
6
7
|
|
|
7
|
-
const EDITOR_GROUPS = new Set(["editor", "admin"]);
|
|
8
8
|
|
|
9
9
|
function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>): string[] {
|
|
10
10
|
const accessPayload = session.tokens?.accessToken?.payload ?? {};
|
|
@@ -29,7 +29,7 @@ function readSessionGroups(session: Awaited<ReturnType<typeof fetchAuthSession>>
|
|
|
29
29
|
|
|
30
30
|
function sessionHasEditorAccess(session: Awaited<ReturnType<typeof fetchAuthSession>>): boolean {
|
|
31
31
|
if (!session.tokens?.accessToken) return false;
|
|
32
|
-
return readSessionGroups(session)
|
|
32
|
+
return hasEditorRights(readSessionGroups(session));
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export async function ensureDevSandboxEditorSession(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import Link from "next/link";
|
|
3
|
+
import { CheckIcon, XIcon } from "lucide-react";
|
|
3
4
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
4
5
|
import {
|
|
5
6
|
confidenceText,
|
|
@@ -12,7 +13,12 @@ import {
|
|
|
12
13
|
beginEmailSubmissionAction,
|
|
13
14
|
endEmailSubmissionAction,
|
|
14
15
|
isCurrentEmailSubmissionAction,
|
|
16
|
+
emailSubmissionAccepted,
|
|
17
|
+
QUARANTINE_LABELS,
|
|
18
|
+
type EmailDecisionResult,
|
|
19
|
+
type EmailQuarantine,
|
|
15
20
|
type MonitoringItem,
|
|
21
|
+
type MonitoringListName,
|
|
16
22
|
type MonitoringEmailDetails,
|
|
17
23
|
type MonitoringEmailArticleResolution,
|
|
18
24
|
type EmailReferenceImportItem,
|
|
@@ -25,6 +31,27 @@ import {
|
|
|
25
31
|
|
|
26
32
|
export type MonitoringMode = "publication" | "all";
|
|
27
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
|
+
|
|
28
55
|
const STATUS_WORDS: Record<string, string> = { complete: "Complete", partial: "Partial", stopped: "Stopped", failed: "Failed", error: "Error" };
|
|
29
56
|
const status = (value: string) => STATUS_WORDS[value] ?? value;
|
|
30
57
|
const STOP_WORDS: Record<string, string> = {
|
|
@@ -97,8 +124,10 @@ function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string
|
|
|
97
124
|
);
|
|
98
125
|
}
|
|
99
126
|
|
|
100
|
-
function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates }: {
|
|
101
|
-
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen
|
|
127
|
+
export function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImportCandidates, onDecide, cursor, paging }: {
|
|
128
|
+
items: MonitoringEmailSummary[]; detail: MonitoringEmailDetails | null; onOpen?: (receiptId: string) => void;
|
|
129
|
+
cursor?: string | null; paging?: MonitoringPaging;
|
|
130
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
102
131
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
103
132
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
104
133
|
}) {
|
|
@@ -108,8 +137,15 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
|
|
|
108
137
|
const [busyImportByReceipt, setBusyImportByReceipt] = useState<Record<string, boolean>>({});
|
|
109
138
|
const [noticeByReceipt, setNoticeByReceipt] = useState<Record<string, string>>({});
|
|
110
139
|
const [importedByReceipt, setImportedByReceipt] = useState<Record<string, EmailReferenceImportOutcome["references"]>>({});
|
|
140
|
+
const [decidedByReceipt, setDecidedByReceipt] = useState<Record<string, EmailQuarantine>>({});
|
|
141
|
+
const [reasonByReceipt, setReasonByReceipt] = useState<Record<string, string>>({});
|
|
142
|
+
const [busyDecisionByReceipt, setBusyDecisionByReceipt] = useState<Record<string, boolean>>({});
|
|
111
143
|
const receiptId = detail && "submission" in detail ? detail.submission.receiptId : null;
|
|
112
144
|
const receiptKey = receiptId ?? "";
|
|
145
|
+
const quarantine = (detail && "submission" in detail ? decidedByReceipt[receiptKey] ?? detail.submission.quarantine : null);
|
|
146
|
+
const accepted = emailSubmissionAccepted(quarantine);
|
|
147
|
+
const reason = reasonByReceipt[receiptKey] ?? "";
|
|
148
|
+
const busyDecision = busyDecisionByReceipt[receiptKey] ?? false;
|
|
113
149
|
const selected = selectedByReceipt[receiptKey] ?? {};
|
|
114
150
|
const resolved = resolvedByReceipt[receiptKey] ?? {};
|
|
115
151
|
const busyIndex = busyIndexByReceipt[receiptKey] ?? null;
|
|
@@ -138,13 +174,18 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
|
|
|
138
174
|
const result = await onResolveArticleLink(requestReceiptId, index);
|
|
139
175
|
if (!isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) return;
|
|
140
176
|
if (!("url" in result)) {
|
|
141
|
-
|
|
177
|
+
const reason = "reason" in result && typeof result.reason === "string"
|
|
178
|
+
&& /^(?:redirect_[a-z_]+|resolution_record_invalid|resolution_not_recorded|invalid_url|not_public|dns|timeout|connection|tls|loop|http_4xx|http_5xx|blocked|too_large|unreadable|unsafe_destination|no_query|search_unavailable|search_error|no_match)$/.test(result.reason)
|
|
179
|
+
? ` (${result.reason})` : "";
|
|
180
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `The article destination could not be resolved safely${reason}. The original link remains private and unchanged.` }));
|
|
142
181
|
return;
|
|
143
182
|
}
|
|
144
183
|
const candidate = detail && "submission" in detail ? detail.candidateLinks[index] : null;
|
|
145
184
|
setResolvedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: { url: result.url, title: result.title || candidate?.title || "Article" } } }));
|
|
146
185
|
setSelectedByReceipt((current) => ({ ...current, [requestReceiptId]: { ...current[requestReceiptId], [index]: true } }));
|
|
147
|
-
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]:
|
|
186
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: result.foundVia === "search"
|
|
187
|
+
? `The newsletter link could not be followed, so this article was found by searching the headline (match ${Math.round((result.search?.matchScore ?? 0) * 100)}%). Check that it is the right article before importing it.`
|
|
188
|
+
: "Resolved. This request may be recorded as a click by the newsletter sender." }));
|
|
148
189
|
} catch {
|
|
149
190
|
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
150
191
|
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The article destination could not be resolved. Try again later." }));
|
|
@@ -155,6 +196,34 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
|
|
|
155
196
|
}
|
|
156
197
|
};
|
|
157
198
|
|
|
199
|
+
const decide = async (decision: "accept" | "reject") => {
|
|
200
|
+
if (!receiptId || !onDecide) return;
|
|
201
|
+
const requestReceiptId = receiptId;
|
|
202
|
+
const actionKey = `decide:${requestReceiptId}`;
|
|
203
|
+
if (!beginEmailSubmissionAction(inFlightActions.current, actionKey)) return;
|
|
204
|
+
setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: true }));
|
|
205
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "" }));
|
|
206
|
+
try {
|
|
207
|
+
const result = await onDecide(requestReceiptId, decision, reason.trim());
|
|
208
|
+
if ("ok" in result) {
|
|
209
|
+
setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine }));
|
|
210
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: decision === "accept"
|
|
211
|
+
? "Accepted. The receiver queues it within a minute; the links can be added to References once it is queued."
|
|
212
|
+
: "Rejected. The message is closed and kept privately until it expires." }));
|
|
213
|
+
} else if ("error" in result && result.quarantine) {
|
|
214
|
+
setDecidedByReceipt((current) => ({ ...current, [requestReceiptId]: result.quarantine as EmailQuarantine }));
|
|
215
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "Another decision was already recorded for this message; it cannot be changed." }));
|
|
216
|
+
} else {
|
|
217
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. Check your editor access and try again." }));
|
|
218
|
+
}
|
|
219
|
+
} catch {
|
|
220
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: "The decision was not recorded. It is safe to try again." }));
|
|
221
|
+
} finally {
|
|
222
|
+
endEmailSubmissionAction(inFlightActions.current, actionKey);
|
|
223
|
+
setBusyDecisionByReceipt((current) => ({ ...current, [requestReceiptId]: false }));
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
158
227
|
const importSelected = async () => {
|
|
159
228
|
if (!receiptId || !detail || !("submission" in detail) || !onImportCandidates) return;
|
|
160
229
|
const requestReceiptId = receiptId;
|
|
@@ -181,7 +250,7 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
|
|
|
181
250
|
if (result.error) {
|
|
182
251
|
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} of ${candidates.length} selected candidates were registered or matched. ${result.error}` }));
|
|
183
252
|
} else {
|
|
184
|
-
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Import does not approve or publish them.` }));
|
|
253
|
+
setNoticeByReceipt((current) => ({ ...current, [requestReceiptId]: `${result.imported} selected candidates are available as References. Predictions are queued in the cloud; review them when ready. Import does not approve or publish them.` }));
|
|
185
254
|
}
|
|
186
255
|
} catch {
|
|
187
256
|
if (isCurrentEmailSubmissionAction(activeReceiptId.current, requestReceiptId)) {
|
|
@@ -196,6 +265,12 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
|
|
|
196
265
|
return (
|
|
197
266
|
<article className="news-desk-doctrine-card" data-monitoring-section="email-submissions">
|
|
198
267
|
<div className="news-desk-doctrine-card__header"><div><h3>Email submissions</h3><span>Untrusted source material held privately for editor review</span></div></div>
|
|
268
|
+
{paging?.onHeldOnlyChange ? (
|
|
269
|
+
<div className="news-desk-monitoring__scope" role="group" aria-label="Filter email submissions">
|
|
270
|
+
<button type="button" aria-pressed={!paging.heldOnly} data-email-filter="all" onClick={() => paging.onHeldOnlyChange?.(false)}>All</button>
|
|
271
|
+
<button type="button" aria-pressed={paging.heldOnly === true} data-email-filter="held" onClick={() => paging.onHeldOnlyChange?.(true)}>Held for review</button>
|
|
272
|
+
</div>
|
|
273
|
+
) : null}
|
|
199
274
|
{items.length ? <ul className="news-desk-monitoring__list">
|
|
200
275
|
{items.map((item) => <li key={item.receiptId} data-email-submission={item.receiptId}>
|
|
201
276
|
<strong>{item.subject || "(no subject)"}</strong>
|
|
@@ -207,38 +282,70 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
|
|
|
207
282
|
<span>SES signals — SPF {item.verdicts.spf}, DKIM {item.verdicts.dkim}, DMARC {item.verdicts.dmarc}; spam {item.verdicts.spam}, virus {item.verdicts.virus}</span>
|
|
208
283
|
{item.warnings.length ? <span>Warnings: {item.warnings.join(", ")}</span> : null}
|
|
209
284
|
{item.errorCode ? <span>State: {item.status} ({item.errorCode})</span> : <span>State: {item.status}</span>}
|
|
210
|
-
<
|
|
285
|
+
{item.quarantine ? <span data-email-quarantine-state={item.quarantine.state}>{QUARANTINE_LABELS[item.quarantine.state]}</span> : null}
|
|
286
|
+
{onOpen ? <button type="button" onClick={() => onOpen(item.receiptId)}>Read email and review candidate links</button> : null}
|
|
211
287
|
</li>)}
|
|
212
|
-
</ul> : <p>No email submissions are available for this publication
|
|
288
|
+
</ul> : <p>{paging?.heldOnly ? "No email submissions are held for review." : "No email submissions are available for this publication."}</p>}
|
|
289
|
+
<LoadMore list="emailSubmissions" cursor={cursor} paging={paging} />
|
|
213
290
|
{detail && "submission" in detail ? (
|
|
214
291
|
<section aria-label="Email submission details" data-email-submission-details={detail.submission.receiptId}>
|
|
215
292
|
<h4>{detail.submission.subject || "Submitted email"}</h4>
|
|
216
293
|
<p>Original content is shown as inert text. Sender and message content are untrusted; no instructions are executed.</p>
|
|
217
294
|
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{detail.safeBodyText}</pre>
|
|
295
|
+
{quarantine ? <section aria-label="Held for review" data-email-quarantine={quarantine.state}>
|
|
296
|
+
<h5>{QUARANTINE_LABELS[quarantine.state]}</h5>
|
|
297
|
+
{quarantine.state === "quarantined" ? <>
|
|
298
|
+
<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>
|
|
299
|
+
<label>Why? (optional)
|
|
300
|
+
<textarea value={reason} rows={3} maxLength={500} style={{ display: "block", width: "100%" }}
|
|
301
|
+
onChange={(event) => setReasonByReceipt((current) => ({ ...current, [receiptKey]: event.target.value }))} />
|
|
302
|
+
</label>
|
|
303
|
+
<button type="button" data-email-decision="accept" disabled={busyDecision || !onDecide} onClick={() => void decide("accept")}>
|
|
304
|
+
<CheckIcon aria-hidden="true" size={16} /> Accept
|
|
305
|
+
</button>
|
|
306
|
+
<button type="button" data-email-decision="reject" disabled={busyDecision || !onDecide} onClick={() => void decide("reject")}>
|
|
307
|
+
<XIcon aria-hidden="true" size={16} /> Reject
|
|
308
|
+
</button>
|
|
309
|
+
</> : null}
|
|
310
|
+
{quarantine.decision ? <p data-email-decision-record>
|
|
311
|
+
{quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}
|
|
312
|
+
{quarantine.decision.by ? ` by ${quarantine.decision.by}` : ""}{quarantine.decision.at ? ` on ${formatUtc(quarantine.decision.at)}` : ""}
|
|
313
|
+
{quarantine.decision.reason ? `. Why: ${quarantine.decision.reason}` : "."}
|
|
314
|
+
</p> : null}
|
|
315
|
+
{quarantine.failure ? <p role="alert">The decision could not be applied ({quarantine.failure}).</p> : null}
|
|
316
|
+
{quarantine.state === "expired" ? <p>This message expired and can no longer be accepted.</p> : null}
|
|
317
|
+
{notice && !accepted ? <p role="status" data-email-decision-result>{notice}</p> : null}
|
|
318
|
+
</section> : null}
|
|
218
319
|
<h5>Candidate references ({detail.candidateLinks.length})</h5>
|
|
219
320
|
<ul>{detail.candidateLinks.map((link, index) => <li key={`${link.url}-${index}`}>
|
|
220
|
-
{
|
|
321
|
+
{!accepted
|
|
322
|
+
? <span>{link.title || "Source link"} <code>{link.url}</code></span>
|
|
323
|
+
: link.resolutionStatus === "unresolved" && !resolved[index]
|
|
221
324
|
? <span>{link.title || "Newsletter article link"} (tracking redirect not opened)</span>
|
|
222
325
|
: <a href={(resolved[index]?.url ?? link.url)} target="_blank" rel="noreferrer noopener">{resolved[index]?.title ?? (link.title || "Source link")}</a>}
|
|
223
|
-
<label>
|
|
326
|
+
{accepted ? <label>
|
|
224
327
|
<input type="checkbox" checked={selected[index] ?? false}
|
|
225
328
|
disabled={link.resolutionStatus === "unresolved" && !resolved[index]}
|
|
226
329
|
onChange={(event) => setSelectedByReceipt((current) => ({ ...current, [receiptKey]: { ...current[receiptKey], [index]: event.target.checked } }))} />
|
|
227
330
|
Add as pending Reference
|
|
228
|
-
</label>
|
|
229
|
-
{link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
331
|
+
</label> : null}
|
|
332
|
+
{accepted && link.resolutionStatus === "unresolved" && !resolved[index] ? (
|
|
230
333
|
<button type="button" disabled={busyIndex !== null || !onResolveArticleLink} onClick={() => void resolveLink(index)}>
|
|
231
334
|
{busyIndex === index ? "Resolving…" : "Resolve article redirect (may register a click)"}
|
|
232
335
|
</button>
|
|
233
336
|
) : null}
|
|
234
337
|
</li>)}</ul>
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
338
|
+
{accepted ? <>
|
|
339
|
+
<p>Adding links creates or reuses pending References. It does not approve, label, classify, or publish them.</p>
|
|
340
|
+
<button type="button" disabled={busyImport || !onImportCandidates || !detail.submission.corpusId}
|
|
341
|
+
onClick={() => void importSelected()}>
|
|
342
|
+
{busyImport ? "Adding References…" : "Add selected links to References"}
|
|
343
|
+
</button>
|
|
344
|
+
{notice ? <p role="status" data-email-import-result>{notice}</p> : null}
|
|
345
|
+
{importedReferences.length ? <ul>{importedReferences.map((reference, index) => (
|
|
346
|
+
<li key={`${reference.id}-${index}`}><Link href={getNewsroomNavHref(`/newsroom/references/${encodeURIComponent(reference.lineageId)}`, false)}>Review imported Reference {index + 1}</Link></li>
|
|
347
|
+
))}</ul> : null}
|
|
348
|
+
</> : <p>Links stay inert text, and nothing can be added to References, until this message is accepted.</p>}
|
|
242
349
|
</section>
|
|
243
350
|
) : detail && "error" in detail ? <p role="alert">You cannot read this submission.</p>
|
|
244
351
|
: detail && "state" in detail ? <p role="alert">Submission details are unavailable.</p> : null}
|
|
@@ -246,8 +353,9 @@ function EmailSubmissions({ items, detail, onOpen, onResolveArticleLink, onImpor
|
|
|
246
353
|
);
|
|
247
354
|
}
|
|
248
355
|
|
|
249
|
-
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates }: {
|
|
250
|
-
data: MonitoringOverview; demo: boolean; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
356
|
+
function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onImportCandidates, onDecide, paging }: {
|
|
357
|
+
data: MonitoringOverview; demo: boolean; paging?: MonitoringPaging; emailDetail: MonitoringEmailDetails | null; onEmailOpen: (id: string) => void;
|
|
358
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
251
359
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
252
360
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
253
361
|
}) {
|
|
@@ -259,8 +367,10 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
259
367
|
) : null}
|
|
260
368
|
|
|
261
369
|
<EmailSubmissions items={data.emailSubmissions ?? []} detail={emailDetail} onOpen={onEmailOpen}
|
|
370
|
+
cursor={data.cursors?.emailSubmissions} paging={paging}
|
|
262
371
|
onResolveArticleLink={demo ? undefined : onResolveArticleLink}
|
|
263
|
-
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
372
|
+
onImportCandidates={demo ? undefined : onImportCandidates}
|
|
373
|
+
onDecide={demo ? undefined : onDecide} />
|
|
264
374
|
|
|
265
375
|
<article className="news-desk-doctrine-card" data-monitoring-section="router">
|
|
266
376
|
<div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
|
|
@@ -297,7 +407,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
297
407
|
<strong>{feed.id}</strong>
|
|
298
408
|
<span>{feedHost(feed.sourceUrl)}</span>
|
|
299
409
|
</div>
|
|
300
|
-
<span>{feed.enabled ? "Enabled" : "Disabled"} · {everyText(feed.intervalSeconds)}
|
|
410
|
+
<span>{feed.enabled ? "Enabled" : "Disabled"} · {everyText(feed.intervalSeconds)}</span>
|
|
301
411
|
<span>Last run {formatUtc(feed.lastFinishedAt)}{feed.lastStatus ? ` (${status(feed.lastStatus)})` : ""} · next {formatUtc(feed.nextDueAt)}</span>
|
|
302
412
|
</li>
|
|
303
413
|
))}
|
|
@@ -318,6 +428,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
318
428
|
))}
|
|
319
429
|
</ul>
|
|
320
430
|
) : <p>No collector runs recorded yet.</p>}
|
|
431
|
+
<LoadMore list="runs" cursor={data.cursors?.runs} paging={paging} />
|
|
321
432
|
{data.routerRuns.length ? (
|
|
322
433
|
<ul className="news-desk-monitoring__list" data-monitoring-router-runs>
|
|
323
434
|
{data.routerRuns.map((run) => (
|
|
@@ -330,6 +441,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
330
441
|
))}
|
|
331
442
|
</ul>
|
|
332
443
|
) : null}
|
|
444
|
+
<LoadMore list="routerRuns" cursor={data.cursors?.routerRuns} paging={paging} />
|
|
333
445
|
</article>
|
|
334
446
|
|
|
335
447
|
<article className="news-desk-doctrine-card" data-monitoring-section="queue">
|
|
@@ -339,6 +451,7 @@ function Ready({ data, demo, emailDetail, onEmailOpen, onResolveArticleLink, onI
|
|
|
339
451
|
{data.items.map((item) => <QueueItem key={`${item.monitorId ?? ""}-${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
340
452
|
</ul>
|
|
341
453
|
) : <p>No items in the queue.</p>}
|
|
454
|
+
<LoadMore list="items" cursor={data.cursors?.items} paging={paging} />
|
|
342
455
|
{data.hiddenNotRelevant > 0 ? (
|
|
343
456
|
<p className="news-desk-delegation-note" data-monitoring-hidden>
|
|
344
457
|
{data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
|
|
@@ -368,7 +481,10 @@ export function MonitoringOverviewView({
|
|
|
368
481
|
onEmailOpen = () => {},
|
|
369
482
|
onResolveArticleLink,
|
|
370
483
|
onImportCandidates,
|
|
484
|
+
onDecide,
|
|
485
|
+
paging,
|
|
371
486
|
}: {
|
|
487
|
+
paging?: MonitoringPaging;
|
|
372
488
|
result: MonitoringResult | null;
|
|
373
489
|
canViewAll: boolean;
|
|
374
490
|
mode: MonitoringMode;
|
|
@@ -378,6 +494,7 @@ export function MonitoringOverviewView({
|
|
|
378
494
|
onEmailOpen?: (receiptId: string) => void;
|
|
379
495
|
onResolveArticleLink?: (receiptId: string, candidateIndex: number) => Promise<MonitoringEmailArticleResolution>;
|
|
380
496
|
onImportCandidates?: (receiptId: string, items: EmailReferenceImportItem[]) => Promise<EmailReferenceImportOutcome>;
|
|
497
|
+
onDecide?: (receiptId: string, decision: "accept" | "reject", reason: string) => Promise<EmailDecisionResult>;
|
|
381
498
|
}) {
|
|
382
499
|
let body: React.ReactNode;
|
|
383
500
|
if (result === null) {
|
|
@@ -394,7 +511,7 @@ export function MonitoringOverviewView({
|
|
|
394
511
|
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>;
|
|
395
512
|
} else {
|
|
396
513
|
body = <Ready data={result} demo={demo} emailDetail={emailDetail} onEmailOpen={onEmailOpen}
|
|
397
|
-
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} />;
|
|
514
|
+
onResolveArticleLink={onResolveArticleLink} onImportCandidates={onImportCandidates} onDecide={onDecide} paging={paging} />;
|
|
398
515
|
}
|
|
399
516
|
const scopeLine = result && "scope" in result
|
|
400
517
|
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
@@ -1,23 +1,75 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
|
+
import { useSearchParams } from "next/navigation";
|
|
4
5
|
import {
|
|
6
|
+
decideMonitoringEmailSubmission,
|
|
5
7
|
importMonitoringEmailCandidates,
|
|
6
8
|
loadMonitoringEmailSubmission,
|
|
7
9
|
loadMonitoringOverview,
|
|
10
|
+
loadMonitoringPage,
|
|
8
11
|
resolveMonitoringEmailArticleLink,
|
|
9
12
|
} from "../lib/monitoring-client";
|
|
10
13
|
import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
|
|
11
|
-
import
|
|
12
|
-
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";
|
|
22
|
+
import { EmailSubmissions, MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
13
23
|
|
|
14
24
|
const REFRESH_MILLISECONDS = 60_000;
|
|
15
25
|
|
|
16
|
-
/**
|
|
17
|
-
export function MonitoringPanel(
|
|
26
|
+
/** A receipt URL mounts only the single-submission reader, never the overview. */
|
|
27
|
+
export function MonitoringPanel(props: { isAdmin: boolean; isDemo: boolean }) {
|
|
28
|
+
const searchParams = useSearchParams();
|
|
29
|
+
if (searchParams.has("receipt")) {
|
|
30
|
+
return <MonitoringEmailReceipt key={searchParams.get("receipt")} receiptId={searchParams.get("receipt") ?? ""} isDemo={props.isDemo} />;
|
|
31
|
+
}
|
|
32
|
+
return <MonitoringOverviewPanel {...props} />;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function MonitoringEmailReceipt({ receiptId, isDemo }: { receiptId: string; isDemo: boolean }) {
|
|
36
|
+
const [detail, setDetail] = useState<MonitoringEmailDetails | null>(null);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!receiptId || isDemo) return;
|
|
39
|
+
let live = true;
|
|
40
|
+
void loadMonitoringEmailSubmission(receiptId).then(
|
|
41
|
+
(next) => { if (live) setDetail(next); },
|
|
42
|
+
() => { if (live) setDetail({ state: "unavailable" }); },
|
|
43
|
+
);
|
|
44
|
+
return () => { live = false; };
|
|
45
|
+
}, [receiptId, isDemo]);
|
|
46
|
+
if (!receiptId) return <p role="alert">A receipt is required to open a submission.</p>;
|
|
47
|
+
if (isDemo) return <p role="status">Single email submissions require an editor session.</p>;
|
|
48
|
+
return <div data-news-desk-admin-panel="monitoring">
|
|
49
|
+
{detail === null ? <p role="status">Loading email submission…</p> :
|
|
50
|
+
<EmailSubmissions items={"submission" in detail ? [detail.submission] : []} detail={detail}
|
|
51
|
+
onResolveArticleLink={resolveMonitoringEmailArticleLink} onImportCandidates={importMonitoringEmailCandidates}
|
|
52
|
+
onDecide={decideMonitoringEmailSubmission} />}
|
|
53
|
+
</div>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Overview refreshes apply only when no receipt was requested. */
|
|
57
|
+
function MonitoringOverviewPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
|
|
18
58
|
const [mode, setMode] = useState<MonitoringMode>("publication");
|
|
19
59
|
const [result, setResult] = useState<MonitoringResult | null>(null);
|
|
20
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]);
|
|
21
73
|
|
|
22
74
|
useEffect(() => {
|
|
23
75
|
if (isDemo) {
|
|
@@ -48,13 +100,51 @@ export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo:
|
|
|
48
100
|
setEmailDetail(next);
|
|
49
101
|
};
|
|
50
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
|
+
|
|
51
140
|
const resolveEmailArticleLink = (receiptId: string, candidateIndex: number) =>
|
|
52
141
|
resolveMonitoringEmailArticleLink(receiptId, candidateIndex);
|
|
53
142
|
|
|
54
143
|
const importEmailCandidates = (receiptId: string, candidates: EmailReferenceImportItem[]) =>
|
|
55
144
|
importMonitoringEmailCandidates(receiptId, candidates);
|
|
56
145
|
|
|
57
|
-
return <MonitoringOverviewView result={
|
|
146
|
+
return <MonitoringOverviewView result={shown} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo}
|
|
58
147
|
emailDetail={emailDetail} onEmailOpen={isDemo ? () => {} : openEmail} onResolveArticleLink={isDemo ? undefined : resolveEmailArticleLink}
|
|
59
|
-
onImportCandidates={importEmailCandidates}
|
|
148
|
+
onImportCandidates={importEmailCandidates} onDecide={isDemo ? undefined : decideMonitoringEmailSubmission}
|
|
149
|
+
paging={isDemo ? undefined : { onLoadMore: (list) => void loadMore(list), loadingList, heldOnly, onHeldOnlyChange: (next) => void changeHeldOnly(next) }} />;
|
|
60
150
|
}
|
|
@@ -113,7 +113,8 @@ export function NewsDeskClientProvider({
|
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
115
|
if (isAuthorizationError(error)) {
|
|
116
|
-
|
|
116
|
+
// The session already has editor rights, so the backend refusing a read is not a missing role. Say what was refused.
|
|
117
|
+
setShell((current) => resolveDashboardFailure(current, access.auth, refusedReadMessage(error)));
|
|
117
118
|
return;
|
|
118
119
|
}
|
|
119
120
|
setShell((current) => resolveDashboardFailure(
|
|
@@ -215,9 +216,14 @@ function isAuthorizationError(error: unknown): boolean {
|
|
|
215
216
|
return /not authorized|unauthorized|access denied|forbidden/i.test(message);
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
function refusedReadMessage(error: unknown): string {
|
|
220
|
+
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
221
|
+
return `The Newsroom backend refused a read for this signed-in account (${detail}). The account has editor or admin rights, so this is a backend authorization rule, not a missing role.`;
|
|
222
|
+
}
|
|
223
|
+
|
|
218
224
|
function resolvePrivateReadFailure(state: NewsDeskShellState, auth: NewsDeskShellState["auth"], error: unknown, fallback: string): NewsDeskShellState {
|
|
219
225
|
if (isUnauthenticatedError(error)) return resolveSignedOut(state, { status: "signedOut", label: "Signed out" });
|
|
220
|
-
if (isAuthorizationError(error)) return
|
|
226
|
+
if (isAuthorizationError(error)) return resolveDashboardFailure(state, auth, refusedReadMessage(error));
|
|
221
227
|
return resolveDashboardFailure(state, auth, error instanceof Error ? error.message : fallback);
|
|
222
228
|
}
|
|
223
229
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { loadReferenceReviewDetails } from "../lib/reference-review-details";
|
|
4
|
+
import { hasEditorRights } from "../lib/staff-groups";
|
|
4
5
|
|
|
5
6
|
import { isEditorialWorkAssignment } from "../lib/newsroom-assignments";
|
|
6
7
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
@@ -838,7 +839,7 @@ export async function loadEditorResolvedAccessState(): Promise<EditorAccessState
|
|
|
838
839
|
if (!refreshed.hasSession || refreshed.auth.status === "signedOut") {
|
|
839
840
|
return { status: "signedOut", isEditor: false, isAdmin: false, auth: refreshed.auth, error: null };
|
|
840
841
|
}
|
|
841
|
-
const isEditor = refreshed.groups
|
|
842
|
+
const isEditor = hasEditorRights(refreshed.groups);
|
|
842
843
|
if (!isEditor) {
|
|
843
844
|
return {
|
|
844
845
|
status: "forbidden",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { BookOpen, ClipboardList, House, MessageCircle, MessagesSquare } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
const ICONS = { overview: House, articles: ClipboardList, references: BookOpen, messages: MessagesSquare };
|
|
8
|
+
|
|
9
|
+
export function NewsroomMobileNavigation({ items, activeTab, chat, more }: {
|
|
10
|
+
items: readonly { key: string; label: string; description: string; href: string }[];
|
|
11
|
+
activeTab: string;
|
|
12
|
+
chat?: { open: boolean; onOpen: () => void };
|
|
13
|
+
more: ReactNode;
|
|
14
|
+
}) {
|
|
15
|
+
return <nav className="organizer-bottom-nav" aria-label="Primary newsroom navigation"><ul>
|
|
16
|
+
{items.map(item => {
|
|
17
|
+
if (item.key === "messages" && chat) return <li key="chat"><button className="organizer-chat" type="button" aria-label="Open private chat" aria-expanded={chat.open} aria-controls="papyrus-console-panel" onClick={chat.onOpen}><MessageCircle size={18} aria-hidden="true" /><span>Chat</span></button></li>;
|
|
18
|
+
const Icon = ICONS[item.key as keyof typeof ICONS];
|
|
19
|
+
return <li key={item.key}><Link href={item.href} title={item.description} aria-label={item.label} aria-current={activeTab === item.key ? "page" : undefined}><Icon size={18} aria-hidden="true" /><span>{item.label}</span></Link></li>;
|
|
20
|
+
})}
|
|
21
|
+
<li>{more}</li>
|
|
22
|
+
</ul></nav>;
|
|
23
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import { newsroomInboxRowClassName } from "../lib/newsroom-list-selection";
|
|
25
25
|
import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
26
26
|
import { NotificationCenter } from "./notification-center";
|
|
27
|
+
import { CyclotronRow, predictionFromDecision } from "./reference-review-card";
|
|
27
28
|
|
|
28
29
|
type NewsroomOpsOverviewProps = {
|
|
29
30
|
assignments: AssignmentRecord[];
|
|
@@ -105,9 +106,13 @@ export function NewsroomOpsOverview({
|
|
|
105
106
|
href={href}
|
|
106
107
|
key={lineageId}
|
|
107
108
|
>
|
|
108
|
-
<div className="min-w-0 flex-1">
|
|
109
|
+
<div className="min-w-0 flex-1 space-y-1">
|
|
109
110
|
<p className="truncate text-sm font-medium text-foreground">{referenceDisplayTitle(reference)}</p>
|
|
110
111
|
<p className="text-xs text-muted-foreground">Review source intake · {formatReferenceListDate(reference)}</p>
|
|
112
|
+
<div data-needs-attention-suggestion={lineageId}>
|
|
113
|
+
<CyclotronRow prediction={decisions.get(lineageId) ? predictionFromDecision(decisions.get(lineageId)!) : { state: "none" }} />
|
|
114
|
+
</div>
|
|
115
|
+
<p className="text-xs font-medium text-foreground">Open it to Accept or Reject in the review queue</p>
|
|
111
116
|
</div>
|
|
112
117
|
<Badge className="mt-0.5 shrink-0" variant="outline">Pending</Badge>
|
|
113
118
|
</Link>
|
|
@@ -8,7 +8,7 @@ import { cn } from "../lib/utils";
|
|
|
8
8
|
import { useEffect, useId, useState, type ReactNode } from "react";
|
|
9
9
|
import Link from "next/link";
|
|
10
10
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
11
|
-
import {
|
|
11
|
+
import { Menu, Search, PanelLeftClose, PanelLeftOpen, Settings, UserRound, LogIn, LogOut } from "lucide-react";
|
|
12
12
|
import { ORGANIZER_NAV_GROUPS, ORGANIZER_PRIMARY_NAV, ORGANIZER_SETTINGS_DESTINATION } from "../lib/newsroom-organizer-nav";
|
|
13
13
|
import { browserPathToInternalNewsroomPath } from "../lib/newsroom-base-path";
|
|
14
14
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
@@ -20,6 +20,8 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetTr
|
|
|
20
20
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator } from "./ui/dropdown-menu";
|
|
21
21
|
import "./newsroom-organizer.css";
|
|
22
22
|
import { NewsroomThemeFonts, useNewsroomThemeAttributes } from "./newsroom-theme";
|
|
23
|
+
import { NewsroomMobileNavigation } from "./newsroom-mobile-navigation";
|
|
24
|
+
import { usePapyrusConsole } from "./papyrus-console-shell";
|
|
23
25
|
|
|
24
26
|
export type NewsroomNavCount = {
|
|
25
27
|
count: number | null;
|
|
@@ -55,8 +57,6 @@ function OrganizerSearch({ demo }: { demo: boolean }) {
|
|
|
55
57
|
</form>;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
const ICONS = { overview: House, articles: ClipboardList, references: BookOpen, messages: MessagesSquare };
|
|
59
|
-
|
|
60
60
|
/** Navigation is a presentation layer; existing route and server authorization still own access. */
|
|
61
61
|
function Navigation({ activePath, demo, admin, surface, onNavigate }: { activePath: string; demo: boolean; admin: boolean; surface: string; onNavigate?: () => void }) {
|
|
62
62
|
const id = useId();
|
|
@@ -105,6 +105,7 @@ function Account({ placement }: { placement: "sidebar" | "header" }) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabel, children, demo = false, headerActions, pageTitle, showNavigation = true }: NewsroomOpsShellProps) {
|
|
108
|
+
const consoleState = usePapyrusConsole();
|
|
108
109
|
const pathname = usePathname();
|
|
109
110
|
const themeAttributes = useNewsroomThemeAttributes();
|
|
110
111
|
const session = useOptionalNewsDeskClient();
|
|
@@ -166,16 +167,13 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
|
|
|
166
167
|
</header>
|
|
167
168
|
<main className="organizer-main">{demo && <p className="organizer-demo-notice">Demo workspace · synthetic examples</p>}{children}</main>
|
|
168
169
|
</div>
|
|
169
|
-
{showNavigation && <
|
|
170
|
-
{
|
|
171
|
-
|
|
172
|
-
<Icon size={18} aria-hidden="true" /><span>{item.label}</span></Link></li>; })}
|
|
173
|
-
<li><Sheet open={menuOpen} onOpenChange={setMenuOpen}><SheetTrigger className="organizer-more" aria-label="More"><Menu size={18} aria-hidden="true" /><span>More</span></SheetTrigger>
|
|
170
|
+
{showNavigation && <NewsroomMobileNavigation activeTab={activeTab} items={ORGANIZER_PRIMARY_NAV.map(item => ({ ...item, href: getNewsroomNavHref(item.path, demo) }))}
|
|
171
|
+
chat={consoleState?.shouldOfferConsole ? { open: consoleState.open, onOpen: consoleState.openConsole } : undefined}
|
|
172
|
+
more={<Sheet open={menuOpen} onOpenChange={setMenuOpen}><SheetTrigger className="organizer-more" aria-label="More"><Menu size={18} aria-hidden="true" /><span>More</span></SheetTrigger>
|
|
174
173
|
<SheetContent side="bottom" className="organizer-menu" {...themeAttributes}><SheetHeader><SheetTitle>Newsroom</SheetTitle><SheetDescription>Workspaces and administration</SheetDescription></SheetHeader>
|
|
175
174
|
<Navigation activePath={activePath} demo={demo} admin={admin} surface="menu" onNavigate={() => setMenuOpen(false)} />
|
|
176
175
|
</SheetContent>
|
|
177
|
-
</Sheet
|
|
178
|
-
</ul></nav>}
|
|
176
|
+
</Sheet>} />}
|
|
179
177
|
</div>;
|
|
180
178
|
}
|
|
181
179
|
|