@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
|
@@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
|
|
|
6
6
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
7
7
|
import { CheckIcon, UserIcon } from "lucide-react";
|
|
8
8
|
import { Md5 } from "@smithy/md5-js";
|
|
9
|
+
import { hasEditorRights } from "../lib/staff-groups";
|
|
9
10
|
import {
|
|
10
11
|
createConsoleMessage,
|
|
11
12
|
createConsoleThread,
|
|
@@ -35,6 +36,8 @@ import {
|
|
|
35
36
|
type ReaderMotionSetting,
|
|
36
37
|
} from "./reader-settings";
|
|
37
38
|
import { PapyrusConsoleConversation, type ConsoleConversationMessage } from "./papyrus-console-conversation";
|
|
39
|
+
import { PapyrusConsoleFrame } from "./papyrus-console-frame";
|
|
40
|
+
import { useConsoleVisibility } from "./use-console-visibility";
|
|
38
41
|
import { PapyrusPrivateChatPanel } from "./papyrus-private-chat-panel";
|
|
39
42
|
import { ModelSelector } from "./ai-elements/model-selector";
|
|
40
43
|
import { Suggestion, Suggestions } from "./ai-elements/suggestion";
|
|
@@ -149,12 +152,12 @@ const PapyrusConsoleContext = createContext<PapyrusConsoleContextValue | null>(n
|
|
|
149
152
|
export function PapyrusConsoleShell({ children, privateChatEnabled = false }: PapyrusConsoleShellProps) {
|
|
150
153
|
const pathname = usePathname();
|
|
151
154
|
const [session, setSession] = useState<ReaderSessionSnapshot | null>(null);
|
|
152
|
-
const [open, setOpen] = useState(false);
|
|
153
155
|
const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
|
|
154
156
|
const internalPathname = browserPathToInternalNewsroomPath(pathname);
|
|
155
157
|
const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
|
|
156
|
-
const canUseConsole = Boolean(session?.hasSession && session.groups
|
|
158
|
+
const canUseConsole = Boolean(session?.hasSession && hasEditorRights(session.groups));
|
|
157
159
|
const shouldOfferConsole = isNewsroomPath && canUseConsole;
|
|
160
|
+
const { open, openConsole, closeConsole, toggleOpen } = useConsoleVisibility({ available: shouldOfferConsole });
|
|
158
161
|
|
|
159
162
|
useEffect(() => {
|
|
160
163
|
let active = true;
|
|
@@ -165,37 +168,11 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
|
|
|
165
168
|
.catch(() => {
|
|
166
169
|
if (active) setSession({ auth: { status: "signedOut", label: "Signed out" }, groups: [], hasSession: false });
|
|
167
170
|
});
|
|
168
|
-
try {
|
|
169
|
-
setOpen(window.localStorage.getItem("papyrus:console-open") === "true");
|
|
170
|
-
} catch {
|
|
171
|
-
setOpen(false);
|
|
172
|
-
}
|
|
173
171
|
return () => {
|
|
174
172
|
active = false;
|
|
175
173
|
};
|
|
176
174
|
}, []);
|
|
177
175
|
|
|
178
|
-
const toggleOpen = useCallback(() => {
|
|
179
|
-
setOpen((current) => {
|
|
180
|
-
const next = !current;
|
|
181
|
-
try {
|
|
182
|
-
window.localStorage.setItem("papyrus:console-open", String(next));
|
|
183
|
-
} catch {
|
|
184
|
-
// Ignore storage failures.
|
|
185
|
-
}
|
|
186
|
-
return next;
|
|
187
|
-
});
|
|
188
|
-
}, []);
|
|
189
|
-
|
|
190
|
-
const openConsole = useCallback(() => {
|
|
191
|
-
setOpen(true);
|
|
192
|
-
try {
|
|
193
|
-
window.localStorage.setItem("papyrus:console-open", "true");
|
|
194
|
-
} catch {
|
|
195
|
-
// Ignore storage failures.
|
|
196
|
-
}
|
|
197
|
-
}, []);
|
|
198
|
-
|
|
199
176
|
const startReferenceChat = useCallback(async (target: NewsroomKnowledgeQueryTarget) => {
|
|
200
177
|
if (!canUseConsole) {
|
|
201
178
|
throw new Error("Chat is only available to editor/admin sessions.");
|
|
@@ -358,20 +335,15 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
|
|
|
358
335
|
|
|
359
336
|
return (
|
|
360
337
|
<PapyrusConsoleContext.Provider value={consoleContext}>
|
|
361
|
-
<
|
|
362
|
-
{
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
: <ConsolePanel actorEmail={session?.auth.status === "signedIn" ? (session.auth.email ?? null) : null} actorLabel={session?.auth.label ?? "Papyrus editor"} onClose={toggleOpen} />
|
|
371
|
-
: <ConsoleAccessPanel onClose={toggleOpen} session={session} />
|
|
372
|
-
) : null}
|
|
373
|
-
</aside>
|
|
374
|
-
) : null}
|
|
338
|
+
<PapyrusConsoleFrame available={shouldOfferConsole} open={open} reserveSpace={canUseConsole && open} onClose={closeConsole} page={children}>
|
|
339
|
+
{open ? (
|
|
340
|
+
canUseConsole
|
|
341
|
+
? privateChatEnabled
|
|
342
|
+
? <PapyrusPrivateChatPanel onClose={closeConsole} />
|
|
343
|
+
: <ConsolePanel actorEmail={session?.auth.status === "signedIn" ? (session.auth.email ?? null) : null} actorLabel={session?.auth.label ?? "Papyrus editor"} onClose={closeConsole} />
|
|
344
|
+
: <ConsoleAccessPanel onClose={closeConsole} session={session} />
|
|
345
|
+
) : null}
|
|
346
|
+
</PapyrusConsoleFrame>
|
|
375
347
|
</PapyrusConsoleContext.Provider>
|
|
376
348
|
);
|
|
377
349
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Hub } from "aws-amplify/utils";
|
|
3
4
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
5
|
import { PapyrusConsoleConversation, type ConsoleConversationMessage } from "./papyrus-console-conversation";
|
|
5
6
|
import {
|
|
@@ -31,6 +32,7 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
31
32
|
const [loading, setLoading] = useState(true);
|
|
32
33
|
const [sending, setSending] = useState(false);
|
|
33
34
|
const [error, setError] = useState<string | null>(null);
|
|
35
|
+
const identityGeneration = useRef(0);
|
|
34
36
|
const retryAttempt = useRef<{ text: string; key: string } | null>(null);
|
|
35
37
|
const isWaitingForReply = messages.length > 0 && messages[messages.length - 1].author_kind === "human" &&
|
|
36
38
|
messages[messages.length - 1].addressed_to_bot_id !== null;
|
|
@@ -46,38 +48,67 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
46
48
|
}];
|
|
47
49
|
}, [isWaitingForReply, messages]);
|
|
48
50
|
|
|
49
|
-
const
|
|
51
|
+
const handleFailure = useCallback(async (nextError: unknown) => {
|
|
52
|
+
const failure = failureMessage(nextError);
|
|
53
|
+
setError(failure);
|
|
54
|
+
if (["sign_in_required", "chat_account_access_denied"].includes(failure)) {
|
|
55
|
+
++identityGeneration.current;
|
|
56
|
+
setChannel(null);
|
|
57
|
+
setMessages([]);
|
|
58
|
+
retryAttempt.current = null;
|
|
59
|
+
setSending(false);
|
|
60
|
+
setLoading(false);
|
|
61
|
+
}
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
const refreshMessages = useCallback(async (channelId: string, generation: number) => {
|
|
50
65
|
const next = await listPapyrusPrivateChatMessages(channelId);
|
|
51
|
-
setMessages(next);
|
|
66
|
+
if (generation === identityGeneration.current) setMessages(next);
|
|
52
67
|
}, []);
|
|
53
68
|
|
|
54
69
|
const openConversation = useCallback(async () => {
|
|
70
|
+
const generation = ++identityGeneration.current;
|
|
55
71
|
setLoading(true);
|
|
56
72
|
setError(null);
|
|
73
|
+
setChannel(null);
|
|
74
|
+
setMessages([]);
|
|
75
|
+
retryAttempt.current = null;
|
|
76
|
+
setSending(false);
|
|
57
77
|
try {
|
|
58
78
|
const nextChannel = await openPapyrusPrivateChat();
|
|
79
|
+
if (generation !== identityGeneration.current) return;
|
|
59
80
|
setChannel(nextChannel);
|
|
60
|
-
await refreshMessages(nextChannel.channel_id);
|
|
81
|
+
await refreshMessages(nextChannel.channel_id, generation);
|
|
61
82
|
} catch (nextError) {
|
|
62
|
-
|
|
83
|
+
if (generation === identityGeneration.current) await handleFailure(nextError);
|
|
63
84
|
} finally {
|
|
64
|
-
setLoading(false);
|
|
85
|
+
if (generation === identityGeneration.current) setLoading(false);
|
|
65
86
|
}
|
|
66
|
-
}, [refreshMessages]);
|
|
87
|
+
}, [handleFailure, refreshMessages]);
|
|
67
88
|
|
|
68
89
|
useEffect(() => {
|
|
69
90
|
void openConversation();
|
|
91
|
+
return () => { identityGeneration.current += 1; };
|
|
70
92
|
}, [openConversation]);
|
|
71
93
|
|
|
94
|
+
useEffect(() => Hub.listen("auth", ({ payload }) => {
|
|
95
|
+
if (payload.event === "tokenRefresh") {
|
|
96
|
+
void openConversation();
|
|
97
|
+
} else if (["signedOut", "signedIn", "tokenRefresh_failure"].includes(payload.event)) {
|
|
98
|
+
void handleFailure(new Error("sign_in_required"));
|
|
99
|
+
}
|
|
100
|
+
}), [handleFailure, openConversation]);
|
|
101
|
+
|
|
72
102
|
useEffect(() => {
|
|
73
103
|
if (!channel || !isWaitingForReply) return;
|
|
74
104
|
let active = true;
|
|
105
|
+
const generation = identityGeneration.current;
|
|
75
106
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
76
107
|
const poll = async () => {
|
|
77
108
|
try {
|
|
78
|
-
await refreshMessages(channel.channel_id);
|
|
109
|
+
await refreshMessages(channel.channel_id, generation);
|
|
79
110
|
} catch (nextError) {
|
|
80
|
-
if (active)
|
|
111
|
+
if (active && generation === identityGeneration.current) await handleFailure(nextError);
|
|
81
112
|
return;
|
|
82
113
|
}
|
|
83
114
|
if (active) timer = setTimeout(poll, REFRESH_MS);
|
|
@@ -87,10 +118,11 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
87
118
|
active = false;
|
|
88
119
|
if (timer) clearTimeout(timer);
|
|
89
120
|
};
|
|
90
|
-
}, [channel, isWaitingForReply, refreshMessages]);
|
|
121
|
+
}, [channel, handleFailure, isWaitingForReply, refreshMessages]);
|
|
91
122
|
|
|
92
123
|
const sendMessage = useCallback(async (text: string) => {
|
|
93
124
|
if (!channel || !text.trim() || sending) return;
|
|
125
|
+
const generation = identityGeneration.current;
|
|
94
126
|
const attempt = retryAttempt.current?.text === text
|
|
95
127
|
? retryAttempt.current
|
|
96
128
|
: { text, key: globalThis.crypto.randomUUID() };
|
|
@@ -99,27 +131,29 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
99
131
|
setError(null);
|
|
100
132
|
try {
|
|
101
133
|
const result = await sendPapyrusPrivateChatMessage(channel.channel_id, text, attempt.key);
|
|
134
|
+
if (generation !== identityGeneration.current) return;
|
|
102
135
|
setMessages((current) => current.some((message) => message.message_id === result.message.message_id)
|
|
103
136
|
? current
|
|
104
137
|
: [...current, result.message]);
|
|
105
138
|
retryAttempt.current = null;
|
|
106
139
|
} catch (nextError) {
|
|
107
|
-
|
|
140
|
+
if (generation === identityGeneration.current) await handleFailure(nextError);
|
|
108
141
|
} finally {
|
|
109
|
-
setSending(false);
|
|
142
|
+
if (generation === identityGeneration.current) setSending(false);
|
|
110
143
|
}
|
|
111
|
-
}, [channel, sending]);
|
|
144
|
+
}, [channel, handleFailure, sending]);
|
|
112
145
|
|
|
113
146
|
return <div className="papyrus-console__panel">
|
|
114
147
|
<header className="papyrus-console__header">
|
|
115
148
|
<p className="papyrus-console__header-label">Private chat</p>
|
|
116
149
|
<button className="papyrus-console__close" onClick={onClose} type="button" aria-label="Close console">×</button>
|
|
117
150
|
</header>
|
|
118
|
-
{channel ? <p className="papyrus-console__empty" role="status">Private conversation for
|
|
119
|
-
{!channel && !loading ? <div className="papyrus-
|
|
151
|
+
{channel ? <p className="papyrus-console__empty" role="status">Private conversation for this newsroom.</p> : null}
|
|
152
|
+
{!channel && !loading ? <div className="papyrus-console__access" role="alert">
|
|
120
153
|
<strong>Private chat could not be opened.</strong>
|
|
121
|
-
<span>{error}</span>
|
|
154
|
+
<span>{error === "sign_in_required" ? "Your newsroom session has expired. Return to the newsroom and sign in from the account menu, then retry." : error === "chat_account_access_denied" ? "Your access to this conversation has changed. Retry to check your current access." : error}</span>
|
|
122
155
|
<button className="papyrus-console__new-session" onClick={() => void openConversation()} type="button">Retry</button>
|
|
156
|
+
<button className="papyrus-console__new-session" onClick={onClose} type="button">Back to newsroom</button>
|
|
123
157
|
</div> : <PapyrusConsoleConversation
|
|
124
158
|
messages={conversationMessages}
|
|
125
159
|
isSending={sending || loading || !channel}
|
|
@@ -127,7 +161,7 @@ export function PapyrusPrivateChatPanel({ onClose }: { onClose: () => void }) {
|
|
|
127
161
|
onSendMessage={sendMessage}
|
|
128
162
|
error={error}
|
|
129
163
|
emptyState={loading ? <p className="papyrus-console__empty">Opening private conversation…</p> : (
|
|
130
|
-
<p className="papyrus-console__empty">This conversation is private to
|
|
164
|
+
<p className="papyrus-console__empty">This conversation is private to you in this newsroom.</p>
|
|
131
165
|
)}
|
|
132
166
|
/>}
|
|
133
167
|
</div>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
6
|
+
import { ProposalsView } from "./proposals-view";
|
|
7
|
+
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
8
|
+
import {
|
|
9
|
+
LIST_PROPOSALS,
|
|
10
|
+
filterProposals,
|
|
11
|
+
proposalReceiptMessage,
|
|
12
|
+
type ProposalFilter,
|
|
13
|
+
type ProposalMove,
|
|
14
|
+
type ProposalRow,
|
|
15
|
+
} from "../lib/proposals-admin";
|
|
16
|
+
import { performProposalMove } from "../lib/proposals-client";
|
|
17
|
+
|
|
18
|
+
type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } | null>; errors?: { message?: string }[] };
|
|
19
|
+
const MAXIMUM_PAGES = 20;
|
|
20
|
+
|
|
21
|
+
async function loadProposals(): Promise<ProposalRow[]> {
|
|
22
|
+
const client = generateClient({ authMode: "userPool" });
|
|
23
|
+
const rows: ProposalRow[] = [];
|
|
24
|
+
let nextToken: string | null | undefined;
|
|
25
|
+
for (let page = 0; page < MAXIMUM_PAGES; page += 1) {
|
|
26
|
+
const result = (await client.graphql({ query: LIST_PROPOSALS, variables: { nextToken } })) as GraphResult;
|
|
27
|
+
if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load proposals.");
|
|
28
|
+
const connection = result.data?.listProposals as { items?: ProposalRow[]; nextToken?: string | null } | undefined;
|
|
29
|
+
rows.push(...(connection?.items ?? []).filter(Boolean));
|
|
30
|
+
nextToken = connection?.nextToken;
|
|
31
|
+
if (!nextToken) break;
|
|
32
|
+
}
|
|
33
|
+
return rows;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Core to every site: it does not depend on the reader-steering plugin. */
|
|
37
|
+
export function ProposalsPanel() {
|
|
38
|
+
const [rows, setRows] = useState<ProposalRow[]>([]);
|
|
39
|
+
const [filter, setFilter] = useState<ProposalFilter>("open");
|
|
40
|
+
const [status, setStatus] = useState("Loading...");
|
|
41
|
+
const [busy, setBusy] = useState(false);
|
|
42
|
+
const [reason, setReason] = useState("");
|
|
43
|
+
const [focusId, setFocusId] = useState<string | null>(null);
|
|
44
|
+
const focused = useRef(false);
|
|
45
|
+
const readers = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null, []);
|
|
46
|
+
|
|
47
|
+
const refresh = useCallback(async (quiet = false) => {
|
|
48
|
+
setBusy(true);
|
|
49
|
+
try {
|
|
50
|
+
setRows(await loadProposals());
|
|
51
|
+
if (!quiet) setStatus("");
|
|
52
|
+
} catch (error) {
|
|
53
|
+
setStatus(`Error: ${error instanceof Error ? error.message : "Could not load proposals."}`);
|
|
54
|
+
} finally {
|
|
55
|
+
setBusy(false);
|
|
56
|
+
}
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
void refresh();
|
|
61
|
+
}, [refresh]);
|
|
62
|
+
|
|
63
|
+
// Sent here by a link such as `?proposal=<id>`: show the view that holds it and scroll to it.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
setFocusId(new URLSearchParams(window.location.search).get("proposal"));
|
|
66
|
+
}, []);
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!focusId || focused.current) return;
|
|
69
|
+
const row = rows.find((candidate) => candidate.id === focusId);
|
|
70
|
+
if (!row) return;
|
|
71
|
+
focused.current = true;
|
|
72
|
+
setFilter(filterProposals([row], "open").length ? "open" : "decided");
|
|
73
|
+
window.requestAnimationFrame(() => document.getElementById(`proposal-${focusId}`)?.scrollIntoView({ block: "start" }));
|
|
74
|
+
}, [focusId, rows]);
|
|
75
|
+
|
|
76
|
+
const onMove = useCallback(
|
|
77
|
+
async (proposalId: string, move: ProposalMove) => {
|
|
78
|
+
setBusy(true);
|
|
79
|
+
setStatus("Working...");
|
|
80
|
+
try {
|
|
81
|
+
const receipt = await performProposalMove(proposalId, move, reason.trim());
|
|
82
|
+
setStatus(proposalReceiptMessage(receipt));
|
|
83
|
+
if (receipt.status === "ok") setReason("");
|
|
84
|
+
} catch (error) {
|
|
85
|
+
setStatus(`Error: ${error instanceof Error ? error.message : "The action failed."}`);
|
|
86
|
+
}
|
|
87
|
+
await refresh(true);
|
|
88
|
+
},
|
|
89
|
+
[reason, refresh],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<ProposalsView
|
|
94
|
+
rows={rows}
|
|
95
|
+
readers={readers}
|
|
96
|
+
filter={filter}
|
|
97
|
+
busy={busy}
|
|
98
|
+
status={status}
|
|
99
|
+
reason={reason}
|
|
100
|
+
onFilter={setFilter}
|
|
101
|
+
onReason={setReason}
|
|
102
|
+
onMove={(proposalId, move) => void onMove(proposalId, move)}
|
|
103
|
+
onRefresh={() => void refresh()}
|
|
104
|
+
focusId={focusId}
|
|
105
|
+
/>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import {
|
|
3
|
+
countProposals,
|
|
4
|
+
filterProposals,
|
|
5
|
+
kindLabel,
|
|
6
|
+
proposalMoves,
|
|
7
|
+
proposerLabel,
|
|
8
|
+
sortProposals,
|
|
9
|
+
stateLabel,
|
|
10
|
+
type ProposalFilter,
|
|
11
|
+
type ProposalMove,
|
|
12
|
+
type ProposalRow,
|
|
13
|
+
} from "../lib/proposals-admin";
|
|
14
|
+
|
|
15
|
+
const FILTERS: Array<{ key: ProposalFilter; label: string }> = [
|
|
16
|
+
{ key: "open", label: "Open" },
|
|
17
|
+
{ key: "draft", label: "Drafts" },
|
|
18
|
+
{ key: "in_steering", label: "In steering" },
|
|
19
|
+
{ key: "editor_review", label: "Editor review" },
|
|
20
|
+
{ key: "decided", label: "Decided" },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export type ProposalsViewProps = {
|
|
24
|
+
rows: ProposalRow[];
|
|
25
|
+
/** True when the site has the reader-steering plugin; without it nothing reader-facing is shown. */
|
|
26
|
+
readers: boolean;
|
|
27
|
+
filter: ProposalFilter;
|
|
28
|
+
busy: boolean;
|
|
29
|
+
status: string;
|
|
30
|
+
reason: string;
|
|
31
|
+
onFilter: (filter: ProposalFilter) => void;
|
|
32
|
+
onReason: (reason: string) => void;
|
|
33
|
+
onMove: (proposalId: string, move: ProposalMove) => void;
|
|
34
|
+
onRefresh: () => void;
|
|
35
|
+
/** A proposal the editor was sent to (from the Notification Center): its draft opens and it is marked. */
|
|
36
|
+
focusId?: string | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export function ProposalsView(props: ProposalsViewProps) {
|
|
40
|
+
const sorted = useMemo(() => sortProposals(props.rows), [props.rows]);
|
|
41
|
+
const counts = useMemo(() => countProposals(sorted), [sorted]);
|
|
42
|
+
const shown = filterProposals(sorted, props.filter);
|
|
43
|
+
return (
|
|
44
|
+
<section aria-labelledby="newsroom-proposals-title" data-news-desk-admin-panel="proposals">
|
|
45
|
+
<h3 id="newsroom-proposals-title">Proposals</h3>
|
|
46
|
+
<p>Story drafts and other changes that AI proposes. Editors pin, hold and decide them. Only editors and admins can see this.</p>
|
|
47
|
+
<p role="tablist" aria-label="Proposal views">
|
|
48
|
+
{FILTERS.filter((entry) => props.readers || entry.key !== "in_steering").map((entry) => (
|
|
49
|
+
<span key={entry.key}>
|
|
50
|
+
<button type="button" role="tab" aria-selected={props.filter === entry.key} onClick={() => props.onFilter(entry.key)}>
|
|
51
|
+
{entry.label} ({counts[entry.key]})
|
|
52
|
+
</button>{" "}
|
|
53
|
+
</span>
|
|
54
|
+
))}
|
|
55
|
+
<button type="button" onClick={props.onRefresh} disabled={props.busy}>Refresh</button>
|
|
56
|
+
</p>
|
|
57
|
+
<label>
|
|
58
|
+
Reason for the next move (optional){" "}
|
|
59
|
+
<input type="text" value={props.reason} maxLength={500} onChange={(event) => props.onReason(event.target.value)} />
|
|
60
|
+
</label>
|
|
61
|
+
<p role="status" aria-live="polite">{props.status}</p>
|
|
62
|
+
{shown.length === 0 ? (
|
|
63
|
+
<p>Nothing here.</p>
|
|
64
|
+
) : (
|
|
65
|
+
<ul>
|
|
66
|
+
{shown.map((row) => (
|
|
67
|
+
<li key={row.id} id={`proposal-${row.id}`} data-proposal-row={row.id} data-proposal-focused={props.focusId === row.id || undefined}>
|
|
68
|
+
<article aria-label={row.title}>
|
|
69
|
+
<p>
|
|
70
|
+
<strong>{kindLabel(row.kind)}</strong>: {row.title} ({stateLabel(row.state, props.readers)}
|
|
71
|
+
{row.held ? ", on hold" : ""}) from {proposerLabel(row.proposer)} <time dateTime={row.createdAt}>{row.createdAt}</time>
|
|
72
|
+
</p>
|
|
73
|
+
{row.summary ? <p>{row.summary}</p> : null}
|
|
74
|
+
{row.body ? (
|
|
75
|
+
<details open={props.focusId === row.id || undefined}>
|
|
76
|
+
<summary>Read the draft</summary>
|
|
77
|
+
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.body}</pre>
|
|
78
|
+
</details>
|
|
79
|
+
) : null}
|
|
80
|
+
<p>
|
|
81
|
+
{proposalMoves(row, props.readers).map((move) => (
|
|
82
|
+
<span key={move.label}>
|
|
83
|
+
<button type="button" disabled={props.busy} onClick={() => props.onMove(row.id, move)}>{move.label}</button>{" "}
|
|
84
|
+
</span>
|
|
85
|
+
))}
|
|
86
|
+
</p>
|
|
87
|
+
</article>
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
)}
|
|
92
|
+
</section>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|