@anthusai/papyrus 5.42.1 → 5.43.0-next.1
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/monitoring-view/schema.js +6 -0
- package/amplify/functions/monitoring-view/schema.ts +6 -0
- package/amplify/functions/monitoring-view/wire.js +1 -0
- package/amplify/functions/monitoring-view/wire.ts +1 -0
- package/amplify/functions/proposal-actions/handler.py +137 -0
- 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 +12 -69
- 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/components/monitoring-overview-view.tsx +12 -7
- package/components/monitoring-panel.tsx +33 -3
- package/components/newsroom-mobile-navigation.tsx +23 -0
- package/components/newsroom-ops-shell.tsx +8 -10
- package/components/newsroom-organizer.css +1 -0
- package/components/newsroom-references-view.tsx +141 -257
- package/components/notification-center.tsx +1 -1
- package/components/papyrus-console-frame.tsx +66 -0
- package/components/papyrus-console-shell.tsx +12 -41
- package/components/papyrus-private-chat-panel.tsx +50 -16
- package/components/proposals-panel.tsx +110 -0
- package/components/proposals-view.tsx +93 -0
- package/components/reference-review-card.tsx +383 -0
- package/components/topic-steering-workspace.tsx +13 -2
- package/components/use-console-visibility.ts +47 -0
- package/lib/cli-login-discovery.ts +78 -0
- package/lib/monitoring-client.ts +11 -2
- package/lib/monitoring-overview-fixture.ts +2 -2
- package/lib/monitoring-overview.ts +1 -2
- package/lib/newsroom-chat-client.ts +30 -0
- package/lib/newsroom-chat-identity.ts +39 -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/prospectus-session.ts +2 -0
- package/lib/prospectus-sign-in-routes.ts +19 -2
- package/lib/prospectus-site-identity.ts +7 -0
- package/lib/relevance-decisions.ts +9 -0
- package/lib/review-requests.ts +4 -2
- package/package.json +12 -1
- package/routes.manifest.json +12 -1
- package/src/sync.mjs +11 -2
- package/src/with-papyrus.mjs +1 -1
- /package/amplify/{plugins/reader-steering/function → functions/proposal-actions}/proposals.py +0 -0
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
type ReaderMotionSetting,
|
|
36
36
|
} from "./reader-settings";
|
|
37
37
|
import { PapyrusConsoleConversation, type ConsoleConversationMessage } from "./papyrus-console-conversation";
|
|
38
|
+
import { PapyrusConsoleFrame } from "./papyrus-console-frame";
|
|
39
|
+
import { useConsoleVisibility } from "./use-console-visibility";
|
|
38
40
|
import { PapyrusPrivateChatPanel } from "./papyrus-private-chat-panel";
|
|
39
41
|
import { ModelSelector } from "./ai-elements/model-selector";
|
|
40
42
|
import { Suggestion, Suggestions } from "./ai-elements/suggestion";
|
|
@@ -149,12 +151,12 @@ const PapyrusConsoleContext = createContext<PapyrusConsoleContextValue | null>(n
|
|
|
149
151
|
export function PapyrusConsoleShell({ children, privateChatEnabled = false }: PapyrusConsoleShellProps) {
|
|
150
152
|
const pathname = usePathname();
|
|
151
153
|
const [session, setSession] = useState<ReaderSessionSnapshot | null>(null);
|
|
152
|
-
const [open, setOpen] = useState(false);
|
|
153
154
|
const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
|
|
154
155
|
const internalPathname = browserPathToInternalNewsroomPath(pathname);
|
|
155
156
|
const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
|
|
156
157
|
const canUseConsole = Boolean(session?.hasSession && session.groups.some((group) => group === "editor" || group === "admin"));
|
|
157
158
|
const shouldOfferConsole = isNewsroomPath && canUseConsole;
|
|
159
|
+
const { open, openConsole, closeConsole, toggleOpen } = useConsoleVisibility({ available: shouldOfferConsole });
|
|
158
160
|
|
|
159
161
|
useEffect(() => {
|
|
160
162
|
let active = true;
|
|
@@ -165,37 +167,11 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
|
|
|
165
167
|
.catch(() => {
|
|
166
168
|
if (active) setSession({ auth: { status: "signedOut", label: "Signed out" }, groups: [], hasSession: false });
|
|
167
169
|
});
|
|
168
|
-
try {
|
|
169
|
-
setOpen(window.localStorage.getItem("papyrus:console-open") === "true");
|
|
170
|
-
} catch {
|
|
171
|
-
setOpen(false);
|
|
172
|
-
}
|
|
173
170
|
return () => {
|
|
174
171
|
active = false;
|
|
175
172
|
};
|
|
176
173
|
}, []);
|
|
177
174
|
|
|
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
175
|
const startReferenceChat = useCallback(async (target: NewsroomKnowledgeQueryTarget) => {
|
|
200
176
|
if (!canUseConsole) {
|
|
201
177
|
throw new Error("Chat is only available to editor/admin sessions.");
|
|
@@ -358,20 +334,15 @@ export function PapyrusConsoleShell({ children, privateChatEnabled = false }: Pa
|
|
|
358
334
|
|
|
359
335
|
return (
|
|
360
336
|
<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}
|
|
337
|
+
<PapyrusConsoleFrame available={shouldOfferConsole} open={open} reserveSpace={canUseConsole && open} onClose={closeConsole} page={children}>
|
|
338
|
+
{open ? (
|
|
339
|
+
canUseConsole
|
|
340
|
+
? privateChatEnabled
|
|
341
|
+
? <PapyrusPrivateChatPanel onClose={closeConsole} />
|
|
342
|
+
: <ConsolePanel actorEmail={session?.auth.status === "signedIn" ? (session.auth.email ?? null) : null} actorLabel={session?.auth.label ?? "Papyrus editor"} onClose={closeConsole} />
|
|
343
|
+
: <ConsoleAccessPanel onClose={closeConsole} session={session} />
|
|
344
|
+
) : null}
|
|
345
|
+
</PapyrusConsoleFrame>
|
|
375
346
|
</PapyrusConsoleContext.Provider>
|
|
376
347
|
);
|
|
377
348
|
}
|
|
@@ -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,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateClient } from "aws-amplify/data";
|
|
4
|
+
import amplifyOutputs from "papyrus-amplify-outputs";
|
|
5
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { ProposalsView } from "./proposals-view";
|
|
7
|
+
import { pluginSettings, type PluginOutputs } from "../lib/plugin-settings";
|
|
8
|
+
import {
|
|
9
|
+
EDIT_PROPOSAL,
|
|
10
|
+
LIST_PROPOSALS,
|
|
11
|
+
TRANSITION_PROPOSAL,
|
|
12
|
+
proposalReceiptMessage,
|
|
13
|
+
type ProposalFilter,
|
|
14
|
+
type ProposalMove,
|
|
15
|
+
type ProposalReceipt,
|
|
16
|
+
type ProposalRow,
|
|
17
|
+
} from "../lib/proposals-admin";
|
|
18
|
+
|
|
19
|
+
type GraphResult = { data?: Record<string, { items?: ProposalRow[]; nextToken?: string | null } | ProposalReceipt | null>; errors?: { message?: string }[] };
|
|
20
|
+
const MAXIMUM_PAGES = 20;
|
|
21
|
+
|
|
22
|
+
async function loadProposals(): Promise<ProposalRow[]> {
|
|
23
|
+
const client = generateClient({ authMode: "userPool" });
|
|
24
|
+
const rows: ProposalRow[] = [];
|
|
25
|
+
let nextToken: string | null | undefined;
|
|
26
|
+
for (let page = 0; page < MAXIMUM_PAGES; page += 1) {
|
|
27
|
+
const result = (await client.graphql({ query: LIST_PROPOSALS, variables: { nextToken } })) as GraphResult;
|
|
28
|
+
if (result.errors?.length) throw new Error(result.errors[0]?.message ?? "Could not load proposals.");
|
|
29
|
+
const connection = result.data?.listProposals as { items?: ProposalRow[]; nextToken?: string | null } | undefined;
|
|
30
|
+
rows.push(...(connection?.items ?? []).filter(Boolean));
|
|
31
|
+
nextToken = connection?.nextToken;
|
|
32
|
+
if (!nextToken) break;
|
|
33
|
+
}
|
|
34
|
+
return rows;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function run(query: string, field: string, variables: Record<string, string | undefined>): Promise<ProposalReceipt> {
|
|
38
|
+
const client = generateClient({ authMode: "userPool" });
|
|
39
|
+
const result = (await client.graphql({ query, variables })) as GraphResult;
|
|
40
|
+
if (result.errors?.length) return { status: "refused", message: result.errors[0]?.message ?? "The action failed." };
|
|
41
|
+
return (result.data?.[field] as ProposalReceipt | null) ?? { status: "refused", message: "The action did not answer." };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** One button: a path of states is one `transitionProposal` call per state, stopping at the first refusal; pin and hold are `editProposal`. */
|
|
45
|
+
async function perform(proposalId: string, move: ProposalMove, reason: string): Promise<ProposalReceipt> {
|
|
46
|
+
if (move.edit) return run(EDIT_PROPOSAL, "editProposal", { proposalId, action: move.edit });
|
|
47
|
+
let receipt: ProposalReceipt = { status: "refused", message: "Nothing to do." };
|
|
48
|
+
for (const state of move.path ?? []) {
|
|
49
|
+
receipt = await run(TRANSITION_PROPOSAL, "transitionProposal", { proposalId, state, ...(reason ? { reason } : {}) });
|
|
50
|
+
if (receipt.status !== "ok") break;
|
|
51
|
+
}
|
|
52
|
+
return receipt;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Core to every site: it does not depend on the reader-steering plugin. */
|
|
56
|
+
export function ProposalsPanel() {
|
|
57
|
+
const [rows, setRows] = useState<ProposalRow[]>([]);
|
|
58
|
+
const [filter, setFilter] = useState<ProposalFilter>("open");
|
|
59
|
+
const [status, setStatus] = useState("Loading...");
|
|
60
|
+
const [busy, setBusy] = useState(false);
|
|
61
|
+
const [reason, setReason] = useState("");
|
|
62
|
+
const readers = useMemo(() => pluginSettings(amplifyOutputs as PluginOutputs, "reader-steering") !== null, []);
|
|
63
|
+
|
|
64
|
+
const refresh = useCallback(async (quiet = false) => {
|
|
65
|
+
setBusy(true);
|
|
66
|
+
try {
|
|
67
|
+
setRows(await loadProposals());
|
|
68
|
+
if (!quiet) setStatus("");
|
|
69
|
+
} catch (error) {
|
|
70
|
+
setStatus(`Error: ${error instanceof Error ? error.message : "Could not load proposals."}`);
|
|
71
|
+
} finally {
|
|
72
|
+
setBusy(false);
|
|
73
|
+
}
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
void refresh();
|
|
78
|
+
}, [refresh]);
|
|
79
|
+
|
|
80
|
+
const onMove = useCallback(
|
|
81
|
+
async (proposalId: string, move: ProposalMove) => {
|
|
82
|
+
setBusy(true);
|
|
83
|
+
setStatus("Working...");
|
|
84
|
+
try {
|
|
85
|
+
const receipt = await perform(proposalId, move, reason.trim());
|
|
86
|
+
setStatus(proposalReceiptMessage(receipt));
|
|
87
|
+
if (receipt.status === "ok") setReason("");
|
|
88
|
+
} catch (error) {
|
|
89
|
+
setStatus(`Error: ${error instanceof Error ? error.message : "The action failed."}`);
|
|
90
|
+
}
|
|
91
|
+
await refresh(true);
|
|
92
|
+
},
|
|
93
|
+
[reason, refresh],
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<ProposalsView
|
|
98
|
+
rows={rows}
|
|
99
|
+
readers={readers}
|
|
100
|
+
filter={filter}
|
|
101
|
+
busy={busy}
|
|
102
|
+
status={status}
|
|
103
|
+
reason={reason}
|
|
104
|
+
onFilter={setFilter}
|
|
105
|
+
onReason={setReason}
|
|
106
|
+
onMove={(proposalId, move) => void onMove(proposalId, move)}
|
|
107
|
+
onRefresh={() => void refresh()}
|
|
108
|
+
/>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
};
|
|
36
|
+
|
|
37
|
+
export function ProposalsView(props: ProposalsViewProps) {
|
|
38
|
+
const sorted = useMemo(() => sortProposals(props.rows), [props.rows]);
|
|
39
|
+
const counts = useMemo(() => countProposals(sorted), [sorted]);
|
|
40
|
+
const shown = filterProposals(sorted, props.filter);
|
|
41
|
+
return (
|
|
42
|
+
<section aria-labelledby="newsroom-proposals-title" data-news-desk-admin-panel="proposals">
|
|
43
|
+
<h3 id="newsroom-proposals-title">Proposals</h3>
|
|
44
|
+
<p>Story drafts and other changes that AI proposes. Editors pin, hold and decide them. Only editors and admins can see this.</p>
|
|
45
|
+
<p role="tablist" aria-label="Proposal views">
|
|
46
|
+
{FILTERS.filter((entry) => props.readers || entry.key !== "in_steering").map((entry) => (
|
|
47
|
+
<span key={entry.key}>
|
|
48
|
+
<button type="button" role="tab" aria-selected={props.filter === entry.key} onClick={() => props.onFilter(entry.key)}>
|
|
49
|
+
{entry.label} ({counts[entry.key]})
|
|
50
|
+
</button>{" "}
|
|
51
|
+
</span>
|
|
52
|
+
))}
|
|
53
|
+
<button type="button" onClick={props.onRefresh} disabled={props.busy}>Refresh</button>
|
|
54
|
+
</p>
|
|
55
|
+
<label>
|
|
56
|
+
Reason for the next move (optional){" "}
|
|
57
|
+
<input type="text" value={props.reason} maxLength={500} onChange={(event) => props.onReason(event.target.value)} />
|
|
58
|
+
</label>
|
|
59
|
+
<p role="status" aria-live="polite">{props.status}</p>
|
|
60
|
+
{shown.length === 0 ? (
|
|
61
|
+
<p>Nothing here.</p>
|
|
62
|
+
) : (
|
|
63
|
+
<ul>
|
|
64
|
+
{shown.map((row) => (
|
|
65
|
+
<li key={row.id} data-proposal-row={row.id}>
|
|
66
|
+
<article aria-label={row.title}>
|
|
67
|
+
<p>
|
|
68
|
+
<strong>{kindLabel(row.kind)}</strong>: {row.title} ({stateLabel(row.state, props.readers)}
|
|
69
|
+
{row.held ? ", on hold" : ""}) from {proposerLabel(row.proposer)} <time dateTime={row.createdAt}>{row.createdAt}</time>
|
|
70
|
+
</p>
|
|
71
|
+
{row.summary ? <p>{row.summary}</p> : null}
|
|
72
|
+
{row.body ? (
|
|
73
|
+
<details>
|
|
74
|
+
<summary>Read the draft</summary>
|
|
75
|
+
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{row.body}</pre>
|
|
76
|
+
</details>
|
|
77
|
+
) : null}
|
|
78
|
+
<p>
|
|
79
|
+
{proposalMoves(row, props.readers).map((move) => (
|
|
80
|
+
<span key={move.label}>
|
|
81
|
+
<button type="button" disabled={props.busy} onClick={() => props.onMove(row.id, move)}>{move.label}</button>{" "}
|
|
82
|
+
</span>
|
|
83
|
+
))}
|
|
84
|
+
</p>
|
|
85
|
+
</article>
|
|
86
|
+
</li>
|
|
87
|
+
))}
|
|
88
|
+
</ul>
|
|
89
|
+
)}
|
|
90
|
+
</section>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|