@anthusai/papyrus 5.45.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/bin/papyrus-app.mjs +12 -1
- package/components/news-desk-client-provider.tsx +2 -2
- package/components/newsroom-ops-overview.tsx +63 -60
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/newsroom-organizer.css +1 -2
- package/components/newsroom-references-view.tsx +119 -7
- 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 +93 -60
- package/components/reference-review-card.tsx +21 -66
- package/components/references-prefetch.ts +5 -0
- package/components/review-rating-grid.tsx +27 -14
- package/components/topic-steering-workspace.tsx +12 -2
- package/components/use-reference-ratings.ts +132 -0
- package/lib/category-repository.ts +2 -0
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +20 -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 +37 -0
- package/lib/reference-feed-ranking.ts +44 -0
- package/lib/review-drafts.ts +8 -3
- package/lib/site-brand.ts +2 -0
- package/package.json +7 -1
- package/routes.manifest.json +1 -1
|
@@ -13,6 +13,7 @@ import { useEffect, useRef, useState, type ReactNode, type PointerEvent as React
|
|
|
13
13
|
import { Button } from "./ui/button";
|
|
14
14
|
import { cn } from "../lib/utils";
|
|
15
15
|
import { RatingGrid } from "./review-rating-grid";
|
|
16
|
+
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
16
17
|
import { REFERENCE_REJECTION_REASON_CODES, type ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
17
18
|
|
|
18
19
|
export type ReviewChoice = "accept" | "reject";
|
|
@@ -141,8 +142,11 @@ export function ReviewCard({
|
|
|
141
142
|
summary,
|
|
142
143
|
title,
|
|
143
144
|
titleHref,
|
|
145
|
+
ratings,
|
|
144
146
|
}: {
|
|
145
147
|
children?: ReactNode;
|
|
148
|
+
/** A shared rating store, so the grid card and this card stay in step. Optional: the card works alone without it. */
|
|
149
|
+
ratings?: RatingController;
|
|
146
150
|
/** The saved human answer, or null while the item is undecided. */
|
|
147
151
|
decided: ReviewChoice | null;
|
|
148
152
|
/** When set, the controls explain in words why they cannot change anything. */
|
|
@@ -157,70 +161,21 @@ export function ReviewCard({
|
|
|
157
161
|
title: string;
|
|
158
162
|
titleHref?: string | null;
|
|
159
163
|
}) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}, [initialNote]);
|
|
176
|
-
useEffect(() => {
|
|
177
|
-
if (!reasonTouched.current) setReason(initialReason);
|
|
178
|
-
}, [initialReason]);
|
|
179
|
-
|
|
180
|
-
const chosen = draft ?? decided;
|
|
181
|
-
|
|
182
|
-
async function save(choice: ReviewChoice, nextReason: ReferenceRejectionReasonCode | null, nextNote: string) {
|
|
183
|
-
if (disabledReason || inFlight.current) return;
|
|
184
|
-
if (choice === "reject" && !nextReason) return;
|
|
185
|
-
inFlight.current = true;
|
|
186
|
-
setSaving(true);
|
|
187
|
-
setSaved(false);
|
|
188
|
-
setError(null);
|
|
189
|
-
try {
|
|
190
|
-
await onDecide(choice, { note: nextNote.trim(), reasonCode: choice === "reject" ? nextReason : null });
|
|
191
|
-
savedNote.current = nextNote.trim();
|
|
192
|
-
noteTouched.current = false;
|
|
193
|
-
reasonTouched.current = false;
|
|
194
|
-
setDraft(null);
|
|
195
|
-
setSaved(true);
|
|
196
|
-
} catch (failure) {
|
|
197
|
-
setDraft(null);
|
|
198
|
-
setError(failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry.");
|
|
199
|
-
} finally {
|
|
200
|
-
inFlight.current = false;
|
|
201
|
-
setSaving(false);
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function choose(choice: ReviewChoice) {
|
|
206
|
-
if (disabledReason) return;
|
|
207
|
-
if (chosen === choice && draft === null) return; // the selected answer never unselects itself
|
|
208
|
-
setError(null);
|
|
209
|
-
setDraft(choice);
|
|
210
|
-
void save(choice, reason, note);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function chooseReason(code: ReferenceRejectionReasonCode) {
|
|
214
|
-
if (disabledReason) return;
|
|
215
|
-
setReason(code);
|
|
216
|
-
reasonTouched.current = true;
|
|
217
|
-
if (chosen === "reject") void save("reject", code, note);
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function commitNote() {
|
|
221
|
-
if (!decided || disabledReason || note.trim() === savedNote.current.trim()) return;
|
|
222
|
-
void save(decided, reason, note);
|
|
223
|
-
}
|
|
164
|
+
// The rating state lives in a store. Given one (`ratings`), the grid card and this card share it, so a vote or a reason typed in
|
|
165
|
+
// either shows in both at once; without one the card keeps a private store, exactly as before.
|
|
166
|
+
const local = useRatingStore({
|
|
167
|
+
save: (_id, choice, detail) => onDecide(choice, detail),
|
|
168
|
+
saved: () => decided,
|
|
169
|
+
seed: () => ({ note: initialNote, reasonCode: initialReason }),
|
|
170
|
+
});
|
|
171
|
+
const store = ratings ?? local;
|
|
172
|
+
const item = store.get(itemId);
|
|
173
|
+
const note = item.note ?? initialNote;
|
|
174
|
+
const reason = item.reasonCode !== undefined ? item.reasonCode : initialReason;
|
|
175
|
+
const chosen = item.pending ?? decided;
|
|
176
|
+
const choose = (choice: ReviewChoice) => { if (!disabledReason) store.choose(itemId, choice); };
|
|
177
|
+
const chooseReason = (code: ReferenceRejectionReasonCode) => { if (!disabledReason) store.setReason(itemId, code); };
|
|
178
|
+
const commitNote = () => { if (!disabledReason) store.commitNote(itemId); };
|
|
224
179
|
|
|
225
180
|
// Desktop shortcuts: A accepts, R rejects. Never while typing.
|
|
226
181
|
useEffect(() => {
|
|
@@ -253,7 +208,7 @@ export function ReviewCard({
|
|
|
253
208
|
decided={decided}
|
|
254
209
|
disabled={Boolean(disabledReason)}
|
|
255
210
|
disabledReason={disabledReason}
|
|
256
|
-
feedback={
|
|
211
|
+
feedback={item.feedback}
|
|
257
212
|
onChoose={choose}
|
|
258
213
|
prediction={prediction}
|
|
259
214
|
size="large"
|
|
@@ -266,7 +221,7 @@ export function ReviewCard({
|
|
|
266
221
|
aria-label="Why? (optional)"
|
|
267
222
|
className="mt-1 block min-h-24 w-full rounded-md border bg-background p-3 text-base font-normal"
|
|
268
223
|
onBlur={commitNote}
|
|
269
|
-
onChange={(event) =>
|
|
224
|
+
onChange={(event) => store.setNote(itemId, event.target.value)}
|
|
270
225
|
placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
|
|
271
226
|
value={note}
|
|
272
227
|
/>
|
|
@@ -45,3 +45,8 @@ export const loadMessagesWithHeadStart = () => take("messages");
|
|
|
45
45
|
export function discardReferencesHeadStart() {
|
|
46
46
|
started.clear();
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/** The pages that need the References straight away: the feed itself and the home page, whose Notification Center shows References cards. */
|
|
50
|
+
export function wantsReferencesHeadStart(pathname: string): boolean {
|
|
51
|
+
return /\/newsroom\/references(\/|$)/.test(pathname) || /\/newsroom\/?$/.test(pathname);
|
|
52
|
+
}
|
|
@@ -42,8 +42,10 @@ export function RatingGrid({
|
|
|
42
42
|
disabled = false,
|
|
43
43
|
disabledReason = null,
|
|
44
44
|
feedback = { phase: "idle" },
|
|
45
|
+
note = null,
|
|
45
46
|
onChoose,
|
|
46
47
|
prediction,
|
|
48
|
+
showPrediction = true,
|
|
47
49
|
size = "compact",
|
|
48
50
|
surface = "footer",
|
|
49
51
|
}: {
|
|
@@ -54,8 +56,12 @@ export function RatingGrid({
|
|
|
54
56
|
disabled?: boolean;
|
|
55
57
|
disabledReason?: string | null;
|
|
56
58
|
feedback?: RatingFeedback;
|
|
59
|
+
/** A short line shown in the status row beside the Saved tick (for example the reason). It never adds a row. */
|
|
60
|
+
note?: string | null;
|
|
57
61
|
onChoose: (choice: ReviewChoice) => void;
|
|
58
62
|
prediction: CardPrediction;
|
|
63
|
+
/** False for a card with nothing to predict (an AI draft): the Cyclotron row and the "You" label are left out and only the two buttons remain. */
|
|
64
|
+
showPrediction?: boolean;
|
|
59
65
|
size?: "compact" | "large";
|
|
60
66
|
/** The tone the grid sits on, so the bar's centre notch matches it. */
|
|
61
67
|
surface?: "footer" | "card";
|
|
@@ -70,8 +76,15 @@ export function RatingGrid({
|
|
|
70
76
|
const label = size === "large" ? "text-sm" : "text-xs";
|
|
71
77
|
|
|
72
78
|
return (
|
|
73
|
-
<div
|
|
74
|
-
|
|
79
|
+
<div
|
|
80
|
+
className={cn(
|
|
81
|
+
"grid gap-x-2 gap-y-2.5",
|
|
82
|
+
showPrediction ? "grid-cols-[4.5rem_minmax(0,1fr)_minmax(0,1fr)]" : "grid-cols-2",
|
|
83
|
+
showPrediction && size === "large" && "grid-cols-[5rem_minmax(0,1fr)_minmax(0,1fr)]",
|
|
84
|
+
)}
|
|
85
|
+
data-rating-grid
|
|
86
|
+
>
|
|
87
|
+
{showPrediction ? <div className="col-span-3 grid grid-cols-subgrid items-stretch gap-y-2" data-review-row="cyclotron">
|
|
75
88
|
<span className={cn("self-center font-semibold", label)}>Cyclotron</span>
|
|
76
89
|
{(["accept", "reject"] as const).map((choice) => {
|
|
77
90
|
const predicted = ready?.choice === choice;
|
|
@@ -118,16 +131,15 @@ export function RatingGrid({
|
|
|
118
131
|
) : null}
|
|
119
132
|
<span aria-hidden="true" className={cn("absolute inset-y-0 left-1/2 w-1 -translate-x-1/2", surface === "footer" ? "bg-[var(--feed-footer)]" : "bg-[var(--feed-card)]")} />
|
|
120
133
|
</div>
|
|
121
|
-
{prediction
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
)}
|
|
134
|
+
{/* The line is always there, so a prediction arriving later never changes the card's height. */}
|
|
135
|
+
<small className="min-h-4 text-center text-xs text-muted-foreground" data-cyclotron-state={prediction.state === "ready" ? undefined : prediction.state}>
|
|
136
|
+
{prediction.state === "ready" ? "\u00a0" : prediction.state === "loading" ? "Loading…" : prediction.state === "error" ? "Prediction unavailable" : "No prediction"}
|
|
137
|
+
</small>
|
|
126
138
|
</div>
|
|
127
|
-
</div>
|
|
139
|
+
</div> : null}
|
|
128
140
|
|
|
129
|
-
<div className="
|
|
130
|
-
<span className={cn("self-center font-semibold", label)}>You</span>
|
|
141
|
+
<div className={cn("grid grid-cols-subgrid items-stretch", showPrediction ? "col-span-3" : "col-span-2")} data-review-row="you">
|
|
142
|
+
{showPrediction ? <span className={cn("self-center font-semibold", label)}>You</span> : null}
|
|
131
143
|
{(["accept", "reject"] as const).map((choice) => {
|
|
132
144
|
const on = answer === choice;
|
|
133
145
|
const Icon = choice === "accept" ? CheckIcon : XIcon;
|
|
@@ -139,7 +151,7 @@ export function RatingGrid({
|
|
|
139
151
|
"inline-flex items-center justify-center gap-1.5 rounded-[16px] px-2 font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
|
140
152
|
cell,
|
|
141
153
|
on ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)] text-foreground hover:bg-[var(--feed-control)]/70",
|
|
142
|
-
disabled && "opacity-60",
|
|
154
|
+
disabled && !on && "opacity-60",
|
|
143
155
|
)}
|
|
144
156
|
data-review-choice={choice}
|
|
145
157
|
key={choice}
|
|
@@ -153,12 +165,13 @@ export function RatingGrid({
|
|
|
153
165
|
})}
|
|
154
166
|
</div>
|
|
155
167
|
|
|
156
|
-
<div className="
|
|
168
|
+
<div className={cn("flex min-h-5 flex-wrap items-center gap-x-3 text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-feed-saved role="status">
|
|
169
|
+
{note ? <span data-feed-reason>{note}</span> : null}
|
|
157
170
|
{feedback.phase === "saving" ? <span className="inline-flex items-center gap-1"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Saving…</span> : null}
|
|
158
171
|
{feedback.phase === "saved" ? <span className="inline-flex items-center gap-1" data-review-saved><CheckIcon aria-hidden="true" className="size-3.5" />Saved</span> : null}
|
|
159
172
|
</div>
|
|
160
|
-
{disabledReason ? <p className="
|
|
161
|
-
{feedback.phase === "error" ? <p className="col-span-3
|
|
173
|
+
{disabledReason ? <p className={cn("text-xs text-muted-foreground", showPrediction ? "col-span-3" : "col-span-2")} data-review-disabled-reason>{disabledReason}</p> : null}
|
|
174
|
+
{feedback.phase === "error" ? <p className={cn("text-xs text-destructive", showPrediction ? "col-span-3" : "col-span-2")} role="alert">{feedback.message}</p> : null}
|
|
162
175
|
</div>
|
|
163
176
|
);
|
|
164
177
|
}
|
|
@@ -94,6 +94,7 @@ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
|
94
94
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
95
95
|
import type { ReaderAuthSnapshot } from "./reader-auth-state";
|
|
96
96
|
import { NewsDeskAccessGate } from "./newsroom-access-gate";
|
|
97
|
+
import { correctReferenceCapture } from "../lib/content-actions-client";
|
|
97
98
|
import { loadMessagesWithHeadStart, loadReferencesWithHeadStart, loadRelationsWithHeadStart } from "./references-prefetch";
|
|
98
99
|
import { loadFeedSnapshot, saveFeedSnapshot, type FeedSnapshot } from "../lib/reference-feed-cache";
|
|
99
100
|
import { referencePreviewsByLineage } from "../lib/reference-preview";
|
|
@@ -1332,7 +1333,7 @@ function NewsDeskDashboard({
|
|
|
1332
1333
|
refreshNewsroomSections,
|
|
1333
1334
|
]);
|
|
1334
1335
|
|
|
1335
|
-
const needsReferenceReviewData = activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
|
|
1336
|
+
const needsReferenceReviewData = activeTab === "overview" || activeTab === "references" || activeTab === "assignments" || activeTab === "messages";
|
|
1336
1337
|
const selectedReviewLineageId = pathname ? pathnameReferenceLineageId ?? "" : initialSelection.reference ?? "";
|
|
1337
1338
|
const referenceReviewData = useReferenceReviewData({
|
|
1338
1339
|
enabled: canLoadPrivateCorpus && needsReferenceReviewData && !(activeTab === "references" && selectedReviewLineageId),
|
|
@@ -1363,7 +1364,7 @@ function NewsDeskDashboard({
|
|
|
1363
1364
|
const predictionError = selectedReviewIsCurrent ? selectedReviewRead.error : referenceReviewData.error;
|
|
1364
1365
|
useEffect(() => {
|
|
1365
1366
|
// Keep a display copy for the next visit once everything the feed shows has been read.
|
|
1366
|
-
if (dashboard.isDemo || activeTab !== "references" || !hasHydratedReferences || predictionState !== "ready" || !references.length) return;
|
|
1367
|
+
if (dashboard.isDemo || (activeTab !== "references" && activeTab !== "overview") || !hasHydratedReferences || predictionState !== "ready" || !references.length) return;
|
|
1367
1368
|
const timer = window.setTimeout(() => {
|
|
1368
1369
|
saveFeedSnapshot(SITE_BRAND.id, {
|
|
1369
1370
|
references: selectCanonicalReferenceRecords(references),
|
|
@@ -3103,9 +3104,17 @@ function NewsDeskDashboard({
|
|
|
3103
3104
|
assignments={assignments}
|
|
3104
3105
|
decisions={relevanceDecisions}
|
|
3105
3106
|
demo={Boolean(dashboard.isDemo)}
|
|
3107
|
+
disabledReason={controlsDisabled ? "Editor access is required to change this reference." : null}
|
|
3106
3108
|
humanCuratedReferenceLineageIds={humanCuratedReferenceLineageIds}
|
|
3109
|
+
loading={!dashboard.isDemo && !hasHydratedReferences}
|
|
3107
3110
|
newsroomSections={newsroomSections}
|
|
3111
|
+
notificationFixture={dashboard.notificationFixture ?? null}
|
|
3112
|
+
onReview={(reference, action, options) => runReferenceCurationAction(reference, action, undefined, options?.reasonCode,
|
|
3113
|
+
options?.decisionRelationId ? { decisionRelationId: options.decisionRelationId, shareable: options.shareable === true } : undefined)}
|
|
3114
|
+
predictionState={referenceReviewData.state}
|
|
3115
|
+
referenceAttachments={referenceAttachments}
|
|
3108
3116
|
references={references}
|
|
3117
|
+
savedCopy={savedFeedCopy}
|
|
3109
3118
|
/>
|
|
3110
3119
|
) : null}
|
|
3111
3120
|
{activeTab === "search" ? (
|
|
@@ -3205,6 +3214,7 @@ function NewsDeskDashboard({
|
|
|
3205
3214
|
referenceAttachments={referenceAttachments}
|
|
3206
3215
|
references={references}
|
|
3207
3216
|
loading={!dashboard.isDemo && !hasHydratedReferences}
|
|
3217
|
+
onCorrectCapture={dashboard.isDemo ? undefined : (input) => correctReferenceCapture(input)}
|
|
3208
3218
|
savedCopy={savedFeedCopy}
|
|
3209
3219
|
/>
|
|
3210
3220
|
</NewsroomCurationBootstrap>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import type { ReferenceRejectionReasonCode } from "../lib/reference-policy";
|
|
5
|
+
import type { ReviewChoice } from "./reference-review-card";
|
|
6
|
+
import type { RatingFeedback } from "./review-rating-grid";
|
|
7
|
+
|
|
8
|
+
/** How long after the last keystroke a typed reason is saved on its own. */
|
|
9
|
+
export const NOTE_AUTOSAVE_MS = 1200;
|
|
10
|
+
|
|
11
|
+
export type ItemRating = {
|
|
12
|
+
/** The reason as typed, else as saved. In the store itself it stays undefined until touched, so an untouched field follows the record. */
|
|
13
|
+
note?: string;
|
|
14
|
+
reasonCode?: ReferenceRejectionReasonCode | null;
|
|
15
|
+
/** A vote that is waiting for a reason (Reject needs one) or is still saving. */
|
|
16
|
+
pending: ReviewChoice | null;
|
|
17
|
+
/** True while a Reject is waiting for its reason. */
|
|
18
|
+
askReason: boolean;
|
|
19
|
+
feedback: RatingFeedback;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const EMPTY: ItemRating = { pending: null, askReason: false, feedback: { phase: "idle" } };
|
|
23
|
+
|
|
24
|
+
export type RatingSave = (id: string, choice: ReviewChoice, detail: { note: string; reasonCode: ReferenceRejectionReasonCode | null }) => Promise<void> | void;
|
|
25
|
+
|
|
26
|
+
export type RatingController = {
|
|
27
|
+
get: (id: string) => ItemRating;
|
|
28
|
+
choose: (id: string, choice: ReviewChoice) => void;
|
|
29
|
+
setReason: (id: string, code: ReferenceRejectionReasonCode) => void;
|
|
30
|
+
setNote: (id: string, text: string) => void;
|
|
31
|
+
/** Save a typed reason now (on blur). */
|
|
32
|
+
commitNote: (id: string) => void;
|
|
33
|
+
cancelReason: (id: string) => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One store for the rating state of every item, shared by the grid card and the detail pane so a vote or a reason typed in either
|
|
38
|
+
* shows in both at once. `saved(id)` is the saved human answer; `seed(id)` the saved note and reason code.
|
|
39
|
+
* vote, then reason -> the reason is saved onto the existing decision (on blur, or after a short pause)
|
|
40
|
+
* reason, then vote -> the vote carries the reason
|
|
41
|
+
* A Reject needs a reason code (the backend refuses one without), so it waits as `pending` until one is chosen; a typed reason is
|
|
42
|
+
* kept in the store until it is saved and is never dropped by a failed save.
|
|
43
|
+
*/
|
|
44
|
+
export function useRatingStore({ save, saved, seed }: {
|
|
45
|
+
save: RatingSave;
|
|
46
|
+
saved: (id: string) => ReviewChoice | null;
|
|
47
|
+
seed: (id: string) => { note: string; reasonCode: ReferenceRejectionReasonCode | null };
|
|
48
|
+
}): RatingController {
|
|
49
|
+
const [items, setItems] = useState<Record<string, ItemRating>>({});
|
|
50
|
+
const itemsRef = useRef(items);
|
|
51
|
+
const latest = useRef({ save, saved, seed });
|
|
52
|
+
latest.current = { save, saved, seed };
|
|
53
|
+
const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
|
54
|
+
const inFlight = useRef<Set<string>>(new Set());
|
|
55
|
+
const dirty = useRef<Set<string>>(new Set());
|
|
56
|
+
|
|
57
|
+
const patch = useCallback((id: string, change: Partial<ItemRating>) => {
|
|
58
|
+
const next = { ...itemsRef.current, [id]: { ...(itemsRef.current[id] ?? EMPTY), ...change } };
|
|
59
|
+
itemsRef.current = next;
|
|
60
|
+
setItems(next);
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
// What a reader of the store sees: the touched value, else what is saved. (A saved reason therefore shows on the card too.)
|
|
64
|
+
const get = useCallback((id: string): ItemRating => {
|
|
65
|
+
const item = items[id] ?? EMPTY;
|
|
66
|
+
const seeded = latest.current.seed(id);
|
|
67
|
+
return { ...item, note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
68
|
+
}, [items]);
|
|
69
|
+
|
|
70
|
+
const values = (id: string) => {
|
|
71
|
+
const item = itemsRef.current[id] ?? EMPTY;
|
|
72
|
+
const seeded = latest.current.seed(id);
|
|
73
|
+
return { note: item.note ?? seeded.note, reasonCode: item.reasonCode !== undefined ? item.reasonCode : seeded.reasonCode };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const commit = useCallback(async (id: string, choice: ReviewChoice) => {
|
|
77
|
+
const { note, reasonCode } = values(id);
|
|
78
|
+
if (choice === "reject" && !reasonCode) { patch(id, { pending: "reject", askReason: true }); return; }
|
|
79
|
+
if (inFlight.current.has(id)) { dirty.current.add(id); return; }
|
|
80
|
+
inFlight.current.add(id);
|
|
81
|
+
dirty.current.delete(id);
|
|
82
|
+
const started = itemsRef.current[id] ?? EMPTY;
|
|
83
|
+
patch(id, { pending: choice, askReason: false, feedback: { phase: "saving" } });
|
|
84
|
+
try {
|
|
85
|
+
await latest.current.save(id, choice, { note: note.trim(), reasonCode: choice === "reject" ? reasonCode : null });
|
|
86
|
+
// What was saved now follows the record again (another editor's update shows); anything typed meanwhile is kept.
|
|
87
|
+
const now = itemsRef.current[id] ?? EMPTY;
|
|
88
|
+
patch(id, { pending: null, feedback: { phase: "saved" }, ...(now.note === started.note ? { note: undefined } : {}), ...(now.reasonCode === started.reasonCode ? { reasonCode: undefined } : {}) });
|
|
89
|
+
} catch (failure) {
|
|
90
|
+
// The typed reason stays in the store; only the vote is undone.
|
|
91
|
+
patch(id, { pending: null, feedback: { phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." } });
|
|
92
|
+
} finally {
|
|
93
|
+
inFlight.current.delete(id);
|
|
94
|
+
}
|
|
95
|
+
if (dirty.current.has(id)) { dirty.current.delete(id); const again = itemsRef.current[id]?.pending ?? latest.current.saved(id); if (again) void commit(id, again); }
|
|
96
|
+
}, [patch]);
|
|
97
|
+
|
|
98
|
+
const effective = (id: string): ReviewChoice | null => itemsRef.current[id]?.pending ?? latest.current.saved(id);
|
|
99
|
+
|
|
100
|
+
const choose = useCallback((id: string, choice: ReviewChoice) => {
|
|
101
|
+
if (effective(id) === choice && !itemsRef.current[id]?.askReason) return; // the chosen answer never unselects itself
|
|
102
|
+
void commit(id, choice);
|
|
103
|
+
}, [commit]);
|
|
104
|
+
|
|
105
|
+
const setReason = useCallback((id: string, code: ReferenceRejectionReasonCode) => {
|
|
106
|
+
patch(id, { reasonCode: code });
|
|
107
|
+
// Vote then reason: onto the existing (or waiting) decision. Reason then vote: stored, and the vote carries it.
|
|
108
|
+
if (effective(id) === "reject") void commit(id, "reject");
|
|
109
|
+
}, [commit, patch]);
|
|
110
|
+
|
|
111
|
+
const setNote = useCallback((id: string, text: string) => {
|
|
112
|
+
patch(id, { note: text, feedback: { phase: "idle" } });
|
|
113
|
+
dirty.current.add(`note:${id}`);
|
|
114
|
+
clearTimeout(timers.current[id]);
|
|
115
|
+
timers.current[id] = setTimeout(() => {
|
|
116
|
+
const choice = effective(id);
|
|
117
|
+
if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
|
|
118
|
+
}, NOTE_AUTOSAVE_MS);
|
|
119
|
+
}, [commit, patch]);
|
|
120
|
+
|
|
121
|
+
const commitNote = useCallback((id: string) => {
|
|
122
|
+
clearTimeout(timers.current[id]);
|
|
123
|
+
const choice = effective(id);
|
|
124
|
+
if (choice && dirty.current.delete(`note:${id}`)) void commit(id, choice);
|
|
125
|
+
}, [commit]);
|
|
126
|
+
|
|
127
|
+
const cancelReason = useCallback((id: string) => patch(id, { pending: null, askReason: false }), [patch]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => () => { for (const timer of Object.values(timers.current)) clearTimeout(timer); }, []);
|
|
130
|
+
|
|
131
|
+
return { get, choose, setReason, setNote, commitNote, cancelReason };
|
|
132
|
+
}
|
|
@@ -658,6 +658,8 @@ export type CategorySteeringDashboard = {
|
|
|
658
658
|
isDemo?: boolean;
|
|
659
659
|
/** Local preview only: the demo newsroom is showing a real export (scripts/export-reference-feed-fixture.py), so it must not say "synthetic". */
|
|
660
660
|
realDataPreview?: boolean;
|
|
661
|
+
/** Local preview only: review requests and their drafts from scripts/export-notification-center-fixture.py. */
|
|
662
|
+
notificationFixture?: { requests: import("./review-requests").ReviewRequest[]; drafts: import("./review-drafts").DraftRow[] } | null;
|
|
661
663
|
isPublicSkeleton?: boolean;
|
|
662
664
|
summaryStatus?: "loading" | "missing" | "ready";
|
|
663
665
|
summary?: NewsroomSummaryRecord | null;
|
|
@@ -30,6 +30,18 @@ export type SaveItemDraftResult = { ok: true; item: SavedItemSummary };
|
|
|
30
30
|
export type PublishItemResult = { ok: true; changed: boolean; publishedId: string; versionNumber: number | null };
|
|
31
31
|
export type UnpublishItemResult = { ok: true; changed: boolean };
|
|
32
32
|
|
|
33
|
+
export type CaptureAssessment = "complete" | "incomplete" | "incorrect";
|
|
34
|
+
export type CorrectReferenceCaptureInput = {
|
|
35
|
+
referenceId: string;
|
|
36
|
+
/** The full corrected or pasted text. */
|
|
37
|
+
text?: string;
|
|
38
|
+
grobid?: Record<string, unknown>;
|
|
39
|
+
/** Required with text; alone it is just a label on the capture. */
|
|
40
|
+
assessment?: CaptureAssessment;
|
|
41
|
+
note?: string;
|
|
42
|
+
};
|
|
43
|
+
export type CorrectReferenceCaptureResult = { ok: true; referenceId: string; status: string; edits: unknown[]; label: unknown; summaryStale: boolean };
|
|
44
|
+
|
|
33
45
|
export class ContentActionFailure extends Error {
|
|
34
46
|
readonly errors: ContentActionError[];
|
|
35
47
|
|
|
@@ -48,6 +60,7 @@ export type ContentActionsTransport = {
|
|
|
48
60
|
saveItemDraft: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
49
61
|
publishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
50
62
|
unpublishItem: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
63
|
+
correctReferenceCapture: (args: { input: string }, options: { authMode: "userPool" }) => Promise<GraphqlEnvelope>;
|
|
51
64
|
};
|
|
52
65
|
};
|
|
53
66
|
|
|
@@ -80,6 +93,9 @@ export function createContentActionsClient(transport: ContentActionsTransport) {
|
|
|
80
93
|
async publishItem(id: string): Promise<PublishItemResult> {
|
|
81
94
|
return parseActionResponse(await transport.mutations.publishItem({ input: JSON.stringify({ id }) }, USER_POOL));
|
|
82
95
|
},
|
|
96
|
+
async correctReferenceCapture(input: CorrectReferenceCaptureInput): Promise<CorrectReferenceCaptureResult> {
|
|
97
|
+
return parseActionResponse(await transport.mutations.correctReferenceCapture({ input: JSON.stringify(input) }, USER_POOL));
|
|
98
|
+
},
|
|
83
99
|
async unpublishItem(id: string): Promise<UnpublishItemResult> {
|
|
84
100
|
return parseActionResponse(await transport.mutations.unpublishItem({ input: JSON.stringify({ id }) }, USER_POOL));
|
|
85
101
|
},
|
|
@@ -99,3 +115,5 @@ export const deriveMarkus = (input: DeriveMarkusInput) => contentActionsClient()
|
|
|
99
115
|
export const saveItemDraft = (input: SaveItemDraftInput) => contentActionsClient().saveItemDraft(input);
|
|
100
116
|
export const publishItem = (id: string) => contentActionsClient().publishItem(id);
|
|
101
117
|
export const unpublishItem = (id: string) => contentActionsClient().unpublishItem(id);
|
|
118
|
+
|
|
119
|
+
export const correctReferenceCapture = (input: CorrectReferenceCaptureInput) => contentActionsClient().correctReferenceCapture(input);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { normalizeReviewRow } from "./review-requests";
|
|
3
4
|
import type { CategorySteeringDashboard, ReferenceAttachmentRecord, ReferenceRecord, SemanticRelationRecord } from "./category-repository";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -29,11 +30,30 @@ export function loadReferenceFeedFixture(env: NodeJS.ProcessEnv = process.env):
|
|
|
29
30
|
}
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
export type NotificationCenterFixture = NonNullable<CategorySteeringDashboard["notificationFixture"]>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Local preview only: when PAPYRUS_NOTIFICATION_FIXTURE names a JSON file written by scripts/export-notification-center-fixture.py,
|
|
37
|
+
* the demo Notification Center shows those review requests and their drafts instead of the invented sample.
|
|
38
|
+
*/
|
|
39
|
+
export function loadNotificationCenterFixture(env: NodeJS.ProcessEnv = process.env): NotificationCenterFixture | null {
|
|
40
|
+
const configured = env.PAPYRUS_NOTIFICATION_FIXTURE?.trim();
|
|
41
|
+
if (!configured || env.NEXT_PUBLIC_PAPYRUS_LOCAL_PREVIEW !== "1") return null;
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(fs.readFileSync(path.resolve(process.cwd(), configured), "utf8"));
|
|
44
|
+
if (parsed?.schema !== "papyrus-notification-center-fixture/1" || !Array.isArray(parsed.requests)) return null;
|
|
45
|
+
return { requests: parsed.requests.map(normalizeReviewRow), drafts: Array.isArray(parsed.drafts) ? parsed.drafts : [] };
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
export function withReferenceFeedFixture(dashboard: CategorySteeringDashboard): CategorySteeringDashboard {
|
|
33
52
|
const fixture = loadReferenceFeedFixture();
|
|
34
53
|
if (!fixture) return dashboard;
|
|
35
54
|
return {
|
|
36
55
|
...dashboard,
|
|
56
|
+
notificationFixture: loadNotificationCenterFixture(),
|
|
37
57
|
references: fixture.references,
|
|
38
58
|
referenceAttachments: fixture.referenceAttachments,
|
|
39
59
|
semanticRelations: fixture.semanticRelations,
|
package/lib/newsroom-nav.ts
CHANGED
|
@@ -21,7 +21,7 @@ export type NewsroomNavItem = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
|
|
24
|
-
{ id: "overview", label: "
|
|
24
|
+
{ id: "overview", label: "Needs me", detail: "Newsroom home", href: newsroomHref() },
|
|
25
25
|
{ id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles") },
|
|
26
26
|
{ id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments") },
|
|
27
27
|
{ id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references") },
|
|
@@ -8,7 +8,7 @@ export type OrganizerDestination = {
|
|
|
8
8
|
};
|
|
9
9
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
10
10
|
{ title: "Editorial work", items: [
|
|
11
|
-
{ key: "overview", label: "
|
|
11
|
+
{ key: "overview", label: "Needs me", description: "See what needs you: drafts, references and reviews.", path: "/newsroom", primary: true },
|
|
12
12
|
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
13
13
|
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
14
14
|
{ key: "drafts", label: "Drafts", description: "Read the story drafts that AI proposes, then accept or reject them.", path: "/newsroom/administration/proposals" },
|