@anthusai/papyrus 2.0.0-next.5 → 2.0.0-next.50
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 +23 -3
- package/amplify/data/resource.ts +25 -3
- package/amplify/data/schema.js +2028 -1979
- package/amplify/data/schema.ts +72 -14
- package/amplify/functions/assignment-action/data-access.js +90 -0
- package/amplify/functions/assignment-action/data-access.ts +89 -0
- package/amplify/functions/assignment-action/handler.ts +1 -1
- package/amplify/functions/content-actions/handler.py +53 -2
- package/amplify/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/editorial-checks/handler.py +14 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -0
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.js +7 -7
- package/amplify/{plugins/editorial-checks/function → functions/editorial-checks}/resource.ts +7 -7
- package/amplify/functions/editorial-checks/schema.js +121 -0
- package/amplify/{plugins → functions}/editorial-checks/schema.ts +33 -6
- package/amplify/functions/editorial-checks/wire.js +53 -0
- package/amplify/functions/editorial-checks/wire.ts +61 -0
- package/amplify/functions/monitoring-view/handler.py +11 -0
- package/amplify/functions/monitoring-view/resource.js +24 -0
- package/amplify/functions/monitoring-view/resource.ts +34 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +18 -0
- package/amplify/functions/monitoring-view/wire.js +37 -0
- package/amplify/functions/monitoring-view/wire.ts +47 -0
- package/amplify/functions/public-forms/core.js +247 -0
- package/amplify/functions/public-forms/core.ts +310 -0
- package/amplify/functions/public-forms/handler.ts +41 -0
- package/amplify/functions/public-forms/origins.js +35 -0
- package/amplify/functions/public-forms/origins.ts +43 -0
- package/amplify/functions/public-forms/resource.js +9 -0
- package/amplify/functions/public-forms/resource.ts +10 -0
- package/amplify/functions/public-forms/wire.js +25 -0
- package/amplify/functions/public-forms/wire.ts +32 -0
- package/amplify/functions/shared/editorial-context.ts +63 -4
- package/amplify/functions/shared/python-bundle.js +20 -14
- package/amplify/functions/shared/python-bundle.ts +25 -18
- package/amplify/plugins/newsletter/config.js +195 -0
- package/amplify/plugins/newsletter/config.ts +240 -0
- package/amplify/plugins/newsletter/function/handler.py +6 -0
- package/amplify/plugins/newsletter/function/requirements.txt +5 -0
- package/amplify/plugins/newsletter/ops/handler.py +12 -0
- package/amplify/plugins/newsletter/ops/resource.js +27 -0
- package/amplify/plugins/newsletter/ops/resource.ts +37 -0
- package/amplify/plugins/newsletter/papyrus.plugin.js +260 -0
- package/amplify/plugins/newsletter/papyrus.plugin.ts +308 -0
- package/amplify/plugins/newsletter/schema.js +56 -0
- package/amplify/plugins/newsletter/schema.ts +61 -0
- package/amplify/plugins/newsletter/secure-string-parameter.js +107 -0
- package/amplify/plugins/newsletter/secure-string-parameter.ts +120 -0
- package/amplify/plugins/newsletter/sending.js +140 -0
- package/amplify/plugins/newsletter/sending.ts +186 -0
- package/amplify/plugins/reader-steering/config.js +162 -0
- package/amplify/plugins/reader-steering/config.ts +248 -0
- package/amplify/plugins/reader-steering/function/ai_moderation.py +230 -0
- package/amplify/plugins/reader-steering/function/handler.py +1363 -0
- package/amplify/plugins/reader-steering/function/moderation-prompt.md +27 -0
- package/amplify/plugins/reader-steering/function/moderation.py +316 -0
- package/amplify/plugins/reader-steering/function/resource.js +27 -0
- package/amplify/plugins/reader-steering/function/resource.ts +35 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.js +175 -0
- package/amplify/plugins/reader-steering/papyrus.plugin.ts +201 -0
- package/amplify/plugins/reader-steering/schema.js +140 -0
- package/amplify/plugins/reader-steering/schema.ts +142 -0
- package/amplify/plugins/registry.js +4 -2
- package/amplify/plugins/registry.ts +4 -2
- package/amplify/plugins/types.ts +5 -0
- package/amplify/plugins/wire.js +1 -0
- package/amplify/plugins/wire.ts +1 -0
- package/amplify/publication/redirect-and-index.js +105 -0
- package/amplify/publication/redirect-rules.js +73 -0
- package/amplify/publication/redirect-rules.ts +93 -0
- package/amplify/publication/s3-projection.js +7 -1
- package/amplify/publication/s3-projection.ts +9 -1
- package/amplify/site-backend-config.ts +17 -0
- package/amplify/site-backend.js +36 -1
- package/amplify/site-backend.ts +44 -1
- package/app/api/newsletter/confirm/route.ts +13 -0
- package/app/api/newsletter/preferences/route.ts +13 -0
- package/app/api/newsletter/subscribe/route.ts +9 -0
- package/app/api/newsletter/unsubscribe/route.ts +13 -0
- package/app/api/newsroom/curation/context/route.ts +9 -0
- package/app/api/newsroom/curation/priming/route.ts +9 -0
- package/app/api/newsroom/curation/publication/route.ts +9 -0
- package/app/api/reader/comment/delete/route.ts +9 -0
- package/app/api/reader/comment/edit/route.ts +9 -0
- package/app/api/reader/comment/route.ts +9 -0
- package/app/api/reader/handle/route.ts +9 -0
- package/app/api/reader/me/route.ts +9 -0
- package/app/api/reader/rating/route.ts +9 -0
- package/app/api/reader/report/route.ts +9 -0
- package/app/api/reader/retired/route.ts +9 -0
- package/app/api/reader/submit/route.ts +9 -0
- package/app/api/reader/vote/route.ts +9 -0
- package/bin/papyrus-infra.mjs +20 -1
- package/components/editorial-checks-admin-panel.tsx +36 -11
- package/components/moderation-panel.tsx +248 -0
- package/components/monitoring-overview-view.tsx +241 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsletter-panel.tsx +474 -0
- package/components/newsletter-sections.tsx +163 -0
- package/components/newsroom-article-editor.tsx +2 -4
- package/components/newsroom-board-view.tsx +4 -1
- package/components/newsroom-curation-bootstrap.tsx +45 -0
- package/components/newsroom-organizer.css +31 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +15 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +31 -22
- package/components/reference-cyclotron-priming.tsx +58 -19
- package/components/reference-cyclotron-suggestion.tsx +1 -1
- package/components/reference-priming.tsx +14 -10
- package/components/reference-section-suggestion.tsx +1 -1
- package/components/topic-steering-workspace.tsx +64 -20
- package/components/use-editorial-checks.ts +12 -10
- package/infra/amplify-app-shell.js +90 -16
- package/infra/amplify-app-shell.ts +102 -18
- package/infra/index.js +1 -0
- package/infra/secure-string-parameter-handler.js +41 -0
- package/infra/secure-string-parameter-handler.ts +41 -0
- package/infra/secure-string-parameter.js +79 -0
- package/infra/secure-string-parameter.ts +91 -0
- package/infra/site-config.js +85 -6
- package/infra/site-config.ts +123 -6
- package/lib/category-repository.ts +11 -9
- package/lib/curation-routes.ts +345 -0
- package/lib/editorial-checks-backend.ts +1 -1
- package/lib/editorial-checks-client.ts +19 -1
- package/lib/editorial-checks-demo.ts +2 -2
- package/lib/editorial-checks-settings.ts +3 -0
- package/lib/editorial-checks.ts +8 -11
- package/lib/editorial-preview.ts +5 -5
- package/lib/monitoring-client.ts +29 -0
- package/lib/monitoring-overview-fixture.ts +51 -0
- package/lib/monitoring-overview.ts +145 -0
- package/lib/newsletter-api.ts +391 -0
- package/lib/newsletter-client.ts +46 -0
- package/lib/newsletter-fixture.ts +184 -0
- package/lib/newsletter-model.ts +202 -0
- package/lib/newsletter-pages.ts +264 -0
- package/lib/newsroom-board.ts +27 -2
- package/lib/prospectus-session.ts +15 -14
- package/lib/prospectus-sign-in-routes.ts +54 -13
- package/lib/prospectus-site-identity.ts +24 -1
- package/lib/reader-api.ts +145 -0
- package/lib/reader-moderation.ts +220 -0
- package/lib/reader-retirement-route.ts +89 -0
- package/lib/reader-subject.md +29 -0
- package/lib/reader-subject.ts +53 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/lib/site-origin.ts +67 -0
- package/middleware.ts +14 -3
- package/package.json +42 -4
- package/routes.manifest.json +191 -1
- package/amplify/plugins/editorial-checks/function/handler.py +0 -11
- package/amplify/plugins/editorial-checks/function/requirements.txt +0 -1
- package/amplify/plugins/editorial-checks/papyrus.plugin.js +0 -50
- package/amplify/plugins/editorial-checks/papyrus.plugin.ts +0 -63
- package/amplify/plugins/editorial-checks/schema.js +0 -94
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import {
|
|
2
|
+
countsText,
|
|
3
|
+
formatInZone,
|
|
4
|
+
formatUtc,
|
|
5
|
+
issueEditorHref,
|
|
6
|
+
statusLabel,
|
|
7
|
+
statusMark,
|
|
8
|
+
type NewsletterDelivery,
|
|
9
|
+
type NewsletterEvent,
|
|
10
|
+
type NewsletterIssue,
|
|
11
|
+
type NewsletterOverview,
|
|
12
|
+
type NewsletterSubscriber,
|
|
13
|
+
} from "../lib/newsletter-model";
|
|
14
|
+
|
|
15
|
+
/** Presentation only: the panel fetches and holds state, these render what they are given, so every state is testable from fixtures. */
|
|
16
|
+
|
|
17
|
+
export function ListsCard({ overview }: { overview: NewsletterOverview }) {
|
|
18
|
+
const { totals } = overview;
|
|
19
|
+
return (
|
|
20
|
+
<article className="news-desk-doctrine-card" data-newsletter-section="lists">
|
|
21
|
+
<div className="news-desk-doctrine-card__header">
|
|
22
|
+
<div><h3>Lists</h3><span>Subscribers per list. A person on two lists counts on each.</span></div>
|
|
23
|
+
</div>
|
|
24
|
+
{overview.lists.length ? (
|
|
25
|
+
<dl className="news-desk-monitoring__usage">
|
|
26
|
+
{overview.lists.map((list) => (
|
|
27
|
+
<div key={list.id} data-newsletter-list={list.id}>
|
|
28
|
+
<dt>{list.name} ({list.kind === "roundup" ? "roundup" : list.kind === "alert" ? "alerts" : list.kind})</dt>
|
|
29
|
+
<dd>{list.confirmed}</dd>
|
|
30
|
+
<span className="news-desk-newsletter__quiet">confirmed · {list.pending} pending · {list.unsubscribed} unsubscribed</span>
|
|
31
|
+
</div>
|
|
32
|
+
))}
|
|
33
|
+
</dl>
|
|
34
|
+
) : <p>No list is configured.</p>}
|
|
35
|
+
<p className="news-desk-delegation-note" data-newsletter-totals>
|
|
36
|
+
Across the site: {totals.confirmed} confirmed, {totals.pending} waiting to confirm, {totals.unsubscribed} unsubscribed.
|
|
37
|
+
{" "}{overview.suppressed} {overview.suppressed === 1 ? "address is" : "addresses are"} suppressed after a bounce or complaint.
|
|
38
|
+
</p>
|
|
39
|
+
</article>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function SiteStatusCard({ paused, isAdmin, busy, onResume }: { paused: NewsletterOverview["paused"]; isAdmin: boolean; busy: boolean; onResume: () => void }) {
|
|
44
|
+
return (
|
|
45
|
+
<article className="news-desk-doctrine-card" data-newsletter-section="site-status">
|
|
46
|
+
<div className="news-desk-doctrine-card__header">
|
|
47
|
+
<div><h3>Site status</h3><span>Sending turns itself off when bounces or complaints run high</span></div>
|
|
48
|
+
</div>
|
|
49
|
+
{paused.paused ? (
|
|
50
|
+
<div className="category-steering-alert" role="alert" data-newsletter-paused>
|
|
51
|
+
<strong><span aria-hidden="true">■ </span>Sending is paused.</strong>{" "}
|
|
52
|
+
{paused.reason ? <>Reason: {paused.reason}. </> : null}
|
|
53
|
+
{paused.pausedAt ? <>Paused {formatUtc(paused.pausedAt)}. </> : null}
|
|
54
|
+
Issues due now are blocked until sending resumes; approve a blocked issue again afterwards.
|
|
55
|
+
<div className="news-desk-doctrine-card__actions">
|
|
56
|
+
{isAdmin ? (
|
|
57
|
+
<button type="button" disabled={busy} data-newsletter-resume onClick={onResume}>Resume sending</button>
|
|
58
|
+
) : <span>An admin can resume sending.</span>}
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
) : (
|
|
62
|
+
<p data-newsletter-running><span aria-hidden="true">✓ </span>Sending is on.{paused.resumedAt ? ` Resumed ${formatUtc(paused.resumedAt)}.` : ""}</p>
|
|
63
|
+
)}
|
|
64
|
+
</article>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** What an issue row says about the issue; the buttons beside it belong to the panel. */
|
|
69
|
+
export function IssueSummary({ issue, zone, demo }: { issue: NewsletterIssue; zone: string; demo: boolean }) {
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<header>
|
|
73
|
+
<strong>{issue.subject || "(no subject)"}</strong>
|
|
74
|
+
<span data-newsletter-status={issue.status}><span aria-hidden="true">{statusMark(issue.status)} </span>{statusLabel(issue.status)}</span>
|
|
75
|
+
</header>
|
|
76
|
+
<p>{issue.listName ?? issue.listId}{issue.kind ? ` · ${issue.kind}` : ""}</p>
|
|
77
|
+
{issue.sendAt ? (
|
|
78
|
+
<p data-newsletter-send-at>
|
|
79
|
+
{issue.status === "sent" || issue.status === "sending" ? "Scheduled for " : "Send at "}
|
|
80
|
+
<time dateTime={issue.sendAt} title={formatUtc(issue.sendAt)}>{formatInZone(issue.sendAt, zone)}</time>
|
|
81
|
+
{issue.approvedBy ? ` · approved by ${issue.approvedBy}` : ""}
|
|
82
|
+
</p>
|
|
83
|
+
) : null}
|
|
84
|
+
<p data-newsletter-counts>{countsText(issue.counts ?? {})}</p>
|
|
85
|
+
{issue.status === "blocked" ? (
|
|
86
|
+
<p className="category-steering-alert" data-newsletter-blocked>
|
|
87
|
+
Blocked: {issue.note ?? "see the issue"}. Fix the cause, then approve it again.
|
|
88
|
+
</p>
|
|
89
|
+
) : issue.note ? <p data-newsletter-note>Note: {issue.note}</p> : null}
|
|
90
|
+
{issue.editedSinceApproval ? (
|
|
91
|
+
<p className="category-steering-alert" data-newsletter-edited>
|
|
92
|
+
Edited since approval. When it comes due it will go back to draft and send nothing. Approve it again to send this version.
|
|
93
|
+
</p>
|
|
94
|
+
) : null}
|
|
95
|
+
<p className="news-desk-newsletter__links">
|
|
96
|
+
{issue.itemId ? <a href={issueEditorHref(issue.itemId, demo)} data-newsletter-editor-link>Open in the editor</a> : <span>No item</span>}
|
|
97
|
+
{issue.archiveUrl ? <> · <a href={issue.archiveUrl} target="_blank" rel="noreferrer noopener" data-newsletter-archive-link>Archive page</a></> : null}
|
|
98
|
+
</p>
|
|
99
|
+
</>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function DeliveriesCard({
|
|
104
|
+
issue, rows, events, hasMore, loading, isAdmin, onMore,
|
|
105
|
+
}: {
|
|
106
|
+
issue: NewsletterIssue; rows: NewsletterDelivery[]; events: NewsletterEvent[]; hasMore: boolean; loading: boolean; isAdmin: boolean; onMore: () => void;
|
|
107
|
+
}) {
|
|
108
|
+
return (
|
|
109
|
+
<article className="news-desk-doctrine-card" data-newsletter-section="deliveries" data-newsletter-deliveries-for={issue.issueId}>
|
|
110
|
+
<div className="news-desk-doctrine-card__header">
|
|
111
|
+
<div><h3>Delivery history</h3><span>{issue.subject} · {countsText(issue.counts ?? {})}</span></div>
|
|
112
|
+
</div>
|
|
113
|
+
{!isAdmin ? <p className="news-desk-delegation-note">Addresses are masked for editors.</p> : null}
|
|
114
|
+
{rows.length ? (
|
|
115
|
+
<table className="news-desk-newsletter__table">
|
|
116
|
+
<thead><tr><th scope="col">Address</th><th scope="col">Result</th><th scope="col">Queued (UTC)</th><th scope="col">Last event (UTC)</th></tr></thead>
|
|
117
|
+
<tbody>
|
|
118
|
+
{rows.map((row, index) => (
|
|
119
|
+
<tr key={`${row.address}-${index}`} data-newsletter-delivery={row.status}>
|
|
120
|
+
<td>{row.address}</td>
|
|
121
|
+
<td>{row.status}{row.error ? ` (${row.error})` : ""}</td>
|
|
122
|
+
<td>{formatUtc(row.at)}</td>
|
|
123
|
+
<td>{row.eventAt ? formatUtc(row.eventAt) : "none yet"}</td>
|
|
124
|
+
</tr>
|
|
125
|
+
))}
|
|
126
|
+
</tbody>
|
|
127
|
+
</table>
|
|
128
|
+
) : <p>{loading ? "Loading deliveries..." : "Nothing has been sent for this issue."}</p>}
|
|
129
|
+
{hasMore ? <div className="news-desk-doctrine-card__actions"><button type="button" disabled={loading} onClick={onMore}>Load more deliveries</button></div> : null}
|
|
130
|
+
<h4>Recent events for this issue</h4>
|
|
131
|
+
{events.length ? (
|
|
132
|
+
<ul className="news-desk-monitoring__list" data-newsletter-events>
|
|
133
|
+
{events.map((event, index) => (
|
|
134
|
+
<li key={`${event.at}-${index}`}>
|
|
135
|
+
<strong>{event.type}{event.detail ? ` (${event.detail})` : ""}</strong>
|
|
136
|
+
<span>{event.list ?? ""}</span>
|
|
137
|
+
<span>{formatUtc(event.at)}</span>
|
|
138
|
+
</li>
|
|
139
|
+
))}
|
|
140
|
+
</ul>
|
|
141
|
+
) : <p>No recent events for this issue. Events are kept for 90 days and the panel shows the latest 100 for the whole site.</p>}
|
|
142
|
+
</article>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function SubscribersTable({ rows, zone }: { rows: NewsletterSubscriber[]; zone: string }) {
|
|
147
|
+
return (
|
|
148
|
+
<table className="news-desk-newsletter__table" data-newsletter-subscribers>
|
|
149
|
+
<thead><tr><th scope="col">Address</th><th scope="col">Status</th><th scope="col">Lists</th><th scope="col">Signed up</th><th scope="col">Confirmed</th></tr></thead>
|
|
150
|
+
<tbody>
|
|
151
|
+
{rows.map((row) => (
|
|
152
|
+
<tr key={row.email} data-newsletter-subscriber={row.status}>
|
|
153
|
+
<td>{row.email}</td>
|
|
154
|
+
<td>{row.status}</td>
|
|
155
|
+
<td>{row.lists.length ? row.lists.map((entry) => `${entry.id} (${entry.status})`).join(", ") : "none"}</td>
|
|
156
|
+
<td>{formatInZone(row.createdAt, zone)}</td>
|
|
157
|
+
<td>{row.confirmedAt ? formatInZone(row.confirmedAt, zone) : "not yet"}</td>
|
|
158
|
+
</tr>
|
|
159
|
+
))}
|
|
160
|
+
</tbody>
|
|
161
|
+
</table>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -48,11 +48,9 @@ import {
|
|
|
48
48
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
49
49
|
import { SITE_BRAND } from "../lib/site-brand";
|
|
50
50
|
import { cn } from "../lib/utils";
|
|
51
|
-
import
|
|
52
|
-
import { applyReplacement, editorialChecksEnabled, inlineFindings, visibleFindings } from "../lib/editorial-checks";
|
|
51
|
+
import { applyReplacement, inlineFindings, visibleFindings } from "../lib/editorial-checks";
|
|
53
52
|
import { graphqlEditorialBackend } from "../lib/editorial-checks-client";
|
|
54
53
|
import { demoEditorialBackend } from "../lib/editorial-checks-demo";
|
|
55
|
-
import type { PluginOutputs } from "../lib/plugin-settings";
|
|
56
54
|
import type { FindingActions } from "./editorial-finding-card";
|
|
57
55
|
import { EditorialFindingsPanel } from "./editorial-findings-panel";
|
|
58
56
|
import { EditorialPreviewPopover, type PreviewPopoverTarget } from "./editorial-preview-popover";
|
|
@@ -219,7 +217,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
|
|
|
219
217
|
const frontMatterYaml = editor?.frontMatterYaml ?? null;
|
|
220
218
|
const bodyMarkus = editor?.bodyMarkus ?? null;
|
|
221
219
|
const editorialBackend = useMemo(
|
|
222
|
-
() => (demo ? demoEditorialBackend : !previewMode
|
|
220
|
+
() => (demo ? demoEditorialBackend : !previewMode ? graphqlEditorialBackend : null),
|
|
223
221
|
[demo, previewMode],
|
|
224
222
|
);
|
|
225
223
|
const editorial = useEditorialChecks({
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
6
|
-
import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
|
|
6
|
+
import { IssueCollection, IssueDetailPanel, IssueIdFormatProvider, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
|
|
7
7
|
import "./newsroom-theme";
|
|
8
8
|
import { PlusIcon } from "lucide-react";
|
|
9
9
|
import { newsroomHref } from "../lib/newsroom-base-path";
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
deskBoardConfig,
|
|
17
17
|
deskContainsAssignment,
|
|
18
18
|
deskContainsContentItem,
|
|
19
|
+
formatNativeIssueId,
|
|
19
20
|
projectAssignmentBoard,
|
|
20
21
|
projectContentItemBoard,
|
|
21
22
|
type NativeBoardIssue,
|
|
@@ -168,6 +169,7 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
|
|
|
168
169
|
</Link>
|
|
169
170
|
)}
|
|
170
171
|
</>}>
|
|
172
|
+
<IssueIdFormatProvider formatIssueId={formatNativeIssueId}>
|
|
171
173
|
<div className="organizer-native-board">
|
|
172
174
|
{error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
|
|
173
175
|
{subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
|
|
@@ -192,5 +194,6 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
|
|
|
192
194
|
</IssueDetailPanel>}
|
|
193
195
|
</div>
|
|
194
196
|
</div>
|
|
197
|
+
</IssueIdFormatProvider>
|
|
195
198
|
</NewsroomOpsShell>;
|
|
196
199
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {useEffect,useState,type ReactNode} from 'react';
|
|
4
|
+
import {loadProspectusAccountState} from '../lib/prospectus-account-client';
|
|
5
|
+
import type {CurationBootstrap} from '../lib/curation-client';
|
|
6
|
+
import {NewsroomCurationProvider} from './newsroom-curation-provider';
|
|
7
|
+
|
|
8
|
+
type Fetcher=(input:string,init?:RequestInit)=>Promise<Response>;
|
|
9
|
+
const CONTEXT_PATH='/api/newsroom/curation/context';
|
|
10
|
+
|
|
11
|
+
function validBootstrap(value:unknown):value is CurationBootstrap{
|
|
12
|
+
const body=value as Partial<CurationBootstrap>|null;
|
|
13
|
+
const scope=body?.scope;
|
|
14
|
+
return !!scope&&[scope.accountId,scope.publicationId,scope.bindingRevision].every(item=>typeof item==='string'&&item.trim()!=='')
|
|
15
|
+
&&(body!.mode==='embedded'||body!.mode==='remote')&&Array.isArray(body!.capabilities);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Ask the server for the curation bootstrap, or null. Only a signed-in person with a selected
|
|
20
|
+
* account causes a context read; every other outcome (no Prospectus, signed out, no selection,
|
|
21
|
+
* any refusal or failure) is null, so the released CMS is unchanged.
|
|
22
|
+
*/
|
|
23
|
+
export async function loadCurationBootstrap(returnPath:string,fetcher:Fetcher=fetch):Promise<CurationBootstrap|null>{
|
|
24
|
+
try{
|
|
25
|
+
const account=await loadProspectusAccountState(returnPath,fetcher);
|
|
26
|
+
if(account.status!=='signedIn'||!account.selectedAccountId)return null;
|
|
27
|
+
const response=await fetcher(CONTEXT_PATH,{credentials:'same-origin',cache:'no-store'});
|
|
28
|
+
if(!response.ok)return null;
|
|
29
|
+
const body=await response.json().catch(()=>null);
|
|
30
|
+
if(!validBootstrap(body)||body.scope.accountId!==account.selectedAccountId)return null;
|
|
31
|
+
return body;
|
|
32
|
+
}catch{return null;}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Mounts the curation provider for the References tab. Fails closed to no provider content. */
|
|
36
|
+
export function NewsroomCurationBootstrap({enabled=true,children}:{enabled?:boolean;children:ReactNode}){
|
|
37
|
+
const [bootstrap,setBootstrap]=useState<CurationBootstrap|null>(null);
|
|
38
|
+
useEffect(()=>{
|
|
39
|
+
if(!enabled)return;
|
|
40
|
+
let current=true;
|
|
41
|
+
void loadCurationBootstrap(window.location.pathname).then(value=>{if(current)setBootstrap(value);});
|
|
42
|
+
return()=>{current=false;setBootstrap(null);};
|
|
43
|
+
},[enabled]);
|
|
44
|
+
return <NewsroomCurationProvider bootstrap={bootstrap}>{children}</NewsroomCurationProvider>;
|
|
45
|
+
}
|
|
@@ -313,3 +313,34 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
313
313
|
.news-desk-columns--administration .news-desk-delegation-decision button:focus-visible{outline:2px solid var(--org-ink);outline-offset:2px}
|
|
314
314
|
@container (max-width:640px){.news-desk-columns--administration .news-desk-delegation-pending-list li{grid-template-columns:minmax(0,1fr)}.news-desk-columns--administration .news-desk-delegation-code{font-size:28px}}
|
|
315
315
|
@container (max-width:640px){.news-desk-columns--administration .news-desk-delegation-form{grid-template-columns:minmax(0,1fr)}.news-desk-columns--administration .news-desk-delegation-list li{grid-template-columns:minmax(0,1fr)}}
|
|
316
|
+
.news-desk-columns--administration .news-desk-monitoring__scope{display:flex;flex-wrap:wrap;gap:8px}
|
|
317
|
+
.news-desk-columns--administration .news-desk-monitoring__scope button{min-height:36px;padding:4px 12px;border:1px solid var(--org-line);background:transparent;color:var(--administration-strong);font:600 12px/1.2 var(--kb-theme-ui-font);letter-spacing:.05em;text-transform:uppercase;cursor:pointer}
|
|
318
|
+
.news-desk-columns--administration .news-desk-monitoring__scope button[aria-pressed=true]{border-color:var(--org-ink);border-width:2px;text-decoration:underline;text-underline-offset:4px}
|
|
319
|
+
.news-desk-columns--administration .news-desk-monitoring__list,.news-desk-columns--administration .news-desk-monitoring__queue,.news-desk-columns--administration .news-desk-monitoring__relevance{list-style:none;margin:0;padding:0;display:grid}
|
|
320
|
+
.news-desk-columns--administration .news-desk-monitoring__list li{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) minmax(0,2fr);gap:4px 16px;align-items:baseline;padding:10px 0;border-top:1px solid var(--org-line);min-width:0;overflow-wrap:anywhere}
|
|
321
|
+
.news-desk-columns--administration .news-desk-monitoring__list li>div{display:grid;gap:2px}
|
|
322
|
+
.news-desk-columns--administration .news-desk-monitoring__queue>li{display:grid;gap:6px;padding:12px 0;border-top:1px solid var(--org-line);min-width:0}
|
|
323
|
+
.news-desk-columns--administration .news-desk-monitoring__queue>li>div{display:grid;gap:2px}
|
|
324
|
+
.news-desk-columns--administration .news-desk-monitoring__queue>li>div>span,.news-desk-columns--administration .news-desk-monitoring__routing{margin:0;font:400 12px/1.4 var(--kb-theme-ui-font);color:var(--administration-quiet)}
|
|
325
|
+
.news-desk-columns--administration .news-desk-monitoring__queue li[data-routing=error]{padding-left:12px;border-left:3px solid var(--org-ink)}
|
|
326
|
+
.news-desk-columns--administration .news-desk-monitoring__relevance{gap:4px;padding-left:12px;border-left:1px solid var(--org-line)}
|
|
327
|
+
.news-desk-columns--administration .news-desk-monitoring__relevance li{display:flex;flex-wrap:wrap;gap:4px 12px;font:400 13px/1.4 var(--kb-theme-ui-font)}
|
|
328
|
+
.news-desk-columns--administration .news-desk-monitoring__relevance li[data-delivery=failed] span:last-child{font-weight:700}
|
|
329
|
+
.news-desk-columns--administration .news-desk-monitoring__usage{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px 24px;margin:0}
|
|
330
|
+
.news-desk-columns--administration .news-desk-monitoring__usage dt{font:600 11px/1.4 var(--kb-theme-ui-font);letter-spacing:.07em;text-transform:uppercase;color:var(--administration-quiet)}
|
|
331
|
+
.news-desk-columns--administration .news-desk-monitoring__usage dd{margin:0;font:700 26px/1.1 var(--kb-theme-title-font);font-variant-numeric:tabular-nums}
|
|
332
|
+
@container (max-width:640px){.news-desk-columns--administration .news-desk-monitoring__list li{grid-template-columns:minmax(0,1fr)}}
|
|
333
|
+
|
|
334
|
+
/* Newsletter panel (components/newsletter-panel.tsx): reuses the administration card, row and button styles above. */
|
|
335
|
+
.news-desk-columns--administration .news-desk-newsletter__quiet{font:400 12px/1.4 var(--kb-theme-ui-font);color:var(--administration-quiet)}
|
|
336
|
+
.news-desk-columns--administration .news-desk-monitoring__usage>div{display:grid;gap:4px;align-content:start}
|
|
337
|
+
.news-desk-columns--administration .news-desk-newsletter__links{display:flex;flex-wrap:wrap;gap:4px 8px}
|
|
338
|
+
.news-desk-columns--administration .news-desk-newsletter__table{width:100%;border-collapse:collapse;font:400 13px/1.4 var(--kb-theme-ui-font)}
|
|
339
|
+
.news-desk-columns--administration .news-desk-newsletter__table td:first-child{word-break:keep-all;overflow-wrap:break-word}
|
|
340
|
+
.news-desk-columns--administration .news-desk-newsletter__table th{text-align:left;padding:6px 8px;border-bottom:2px solid var(--administration-strong);font:600 11px/1.4 var(--kb-theme-ui-font);letter-spacing:.07em;text-transform:uppercase;color:var(--administration-quiet)}
|
|
341
|
+
.news-desk-columns--administration .news-desk-newsletter__table td{padding:8px;border-bottom:1px solid var(--org-line);vertical-align:top}
|
|
342
|
+
.news-desk-columns--administration .news-desk-newsletter__drawer{grid-column:1/-1;display:grid;gap:12px;padding:12px 0 0;border-top:1px dashed var(--org-line);min-width:0}
|
|
343
|
+
.news-desk-columns--administration .news-desk-newsletter__frame{width:100%;height:480px;border:1px solid var(--org-line);background:#fff}
|
|
344
|
+
.news-desk-columns--administration .news-desk-newsletter__text{margin:0;padding:12px;max-height:280px;overflow:auto;border:1px solid var(--org-line);font:400 12px/1.5 ui-monospace,monospace;white-space:pre-wrap;overflow-wrap:anywhere}
|
|
345
|
+
.news-desk-columns--administration .news-desk-newsletter__filters{display:flex;flex-wrap:wrap;gap:12px 24px}
|
|
346
|
+
.news-desk-columns--administration .news-desk-newsletter__filters .news-desk-doctrine-card__field{min-width:180px}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import type { PrimingPanelProps } from "cyclotron/components/priming-panel";
|
|
4
|
-
import {
|
|
5
|
-
import { ReferenceCurationControls } from "./reference-curation-controls";
|
|
4
|
+
import { ReferenceAddEntry } from "./reference-add-entry";
|
|
5
|
+
import { ReferenceCurationControls, useReferenceImport } from "./reference-curation-controls";
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
ArchiveIcon,
|
|
@@ -63,7 +63,7 @@ import type { SectionSuggestion, SiteSectionAgreement } from "../lib/site-sectio
|
|
|
63
63
|
/** What an editor adds to an accept: the Section the reference belongs in, which becomes a training label. */
|
|
64
64
|
export type ReferenceReviewOptions = { sectionId?: string | null };
|
|
65
65
|
import type { DelegatedCuration, ShadowAgreement } from "../lib/cyclotron-suggestions";
|
|
66
|
-
import {
|
|
66
|
+
import type { PrimingFormInput } from "./reference-cyclotron-priming";
|
|
67
67
|
import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
|
|
68
68
|
import type { PrimingRequestView } from "../lib/relevance-status";
|
|
69
69
|
import type { CyclotronStatus } from "cyclotron/cyclotron-status";
|
|
@@ -485,6 +485,8 @@ export function NewsroomReferencesView({
|
|
|
485
485
|
references,
|
|
486
486
|
}: NewsroomReferencesViewProps) {
|
|
487
487
|
const pathname = usePathname();
|
|
488
|
+
const sessionImport = useReferenceImport(disabled);
|
|
489
|
+
const [importedCount, setImportedCount] = useState(0);
|
|
488
490
|
const isMobileDetail = useMediaQuery("(max-width: 767px)");
|
|
489
491
|
const pathnameLineageId = useMemo(
|
|
490
492
|
() => parseReferenceLineageIdFromNewsroomPathname(pathname),
|
|
@@ -590,17 +592,15 @@ export function NewsroomReferencesView({
|
|
|
590
592
|
</p>
|
|
591
593
|
</div>
|
|
592
594
|
<ReferenceLiveAgreement agreement={shadowAgreement} />
|
|
593
|
-
<
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
</div>
|
|
603
|
-
) : null}
|
|
595
|
+
<ReferenceAddEntry
|
|
596
|
+
importOptions={disabled ? null : priming ?? sessionImport?.panel}
|
|
597
|
+
key={sessionImport?.sessionKey ?? "no-session"}
|
|
598
|
+
onImported={() => setImportedCount((count) => count + 1)}
|
|
599
|
+
url={cyclotronPriming && !disabled
|
|
600
|
+
? { labels: cyclotronPriming.labels, onRequest: cyclotronPriming.onRequest, requests: cyclotronPriming.requests }
|
|
601
|
+
: null}
|
|
602
|
+
/>
|
|
603
|
+
<ReferenceCurationControls disabled={disabled} refreshSignal={importedCount} />
|
|
604
604
|
{cyclotronStatus ? (
|
|
605
605
|
<div className="shrink-0">
|
|
606
606
|
<ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
|
|
@@ -5,6 +5,9 @@ import {ReviewControl,type ReviewControlReview} from 'cyclotron/components/revie
|
|
|
5
5
|
import {CyclotronStatusView} from 'cyclotron/components/cyclotron-status';
|
|
6
6
|
import {assertPublicationScope,publicationScopeKey,publicationReviewPayload,type PublicationStatus,type PublicationHistory,type PublicationHistoryRow,type PublicationReviewPayload} from '../lib/publication-cyclotron';
|
|
7
7
|
|
|
8
|
+
/** Steering is offered only when the Cyclotron contract says so; absent means unknown, so off. */
|
|
9
|
+
export function steeringAvailable(status:PublicationStatus):boolean{return status.features?.steering===true;}
|
|
10
|
+
|
|
8
11
|
function requestId(){
|
|
9
12
|
const bytes=crypto.getRandomValues(new Uint8Array(16));
|
|
10
13
|
return Array.from(bytes,value=>value.toString(16).padStart(2,'0')).join('');
|
|
@@ -28,28 +31,30 @@ function PublicationControls({mode,status,history,onReview,onSteer}:PublicationP
|
|
|
28
31
|
const signature=JSON.stringify(review);const previous=requests.current[key];
|
|
29
32
|
const id=previous?.signature===signature?previous.id:requestId();requests.current[key]={signature,id};
|
|
30
33
|
const payload=publicationReviewPayload(status,row,classifier,review,id);
|
|
31
|
-
if(!onReview)throw new Error('
|
|
34
|
+
if(!onReview)throw new Error('Reviewing is not turned on here.');
|
|
32
35
|
setBusy(key);setMessage(null);await onReview(payload);
|
|
33
|
-
if(current.current===context){setMessage({context,key,error:false,text:'
|
|
34
|
-
}catch(error){if(current.current===context)setMessage({context,key,error:true,text:error instanceof Error?error.message:'
|
|
36
|
+
if(current.current===context){setMessage({context,key,error:false,text:'Your choice was saved.'});setEditing(old=>({...old,[key]:false}));}
|
|
37
|
+
}catch(error){if(current.current===context)setMessage({context,key,error:true,text:error instanceof Error?error.message:'Your review may not have been saved; check before trying again.'});}
|
|
35
38
|
finally{if(current.current===context)setBusy(null);}
|
|
36
39
|
}
|
|
37
|
-
return <section data-publication-scope={context} aria-label="
|
|
38
|
-
<header><h2 className="text-lg font-semibold">
|
|
40
|
+
return <section data-publication-scope={context} aria-label="Suggestions and reviews" className="space-y-4">
|
|
41
|
+
<header><h2 className="text-lg font-semibold">Suggestions · {status.publicationId}</h2><p className="text-sm text-muted-foreground">{mode==='embedded'?'Suggestions and reviews are kept by this site.':'Suggestions and reviews are kept by the shared review service.'}</p></header>
|
|
39
42
|
{status.outputs.map(output=><section key={`${context}:${output.id}`} aria-label={output.question} className="space-y-3 rounded-md border p-3">
|
|
40
43
|
<CyclotronStatusView status={status.classifiers[output.id]} classifierName={output.question} variant="card"/>
|
|
41
|
-
{onSteer
|
|
44
|
+
{onSteer?(steeringAvailable(status)
|
|
45
|
+
?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>Test a rule change</button>
|
|
46
|
+
:<div><button type="button" disabled aria-describedby={`steer-unavailable:${output.id}`}>Test a rule change · Not available yet</button><p id={`steer-unavailable:${output.id}`} className="text-sm text-muted-foreground">Rule changes cannot be tested on this service yet.</p></div>):null}
|
|
42
47
|
{history.history.map(row=>{
|
|
43
48
|
const prediction=row.decision.classifiers[output.id];if(!prediction)return null;
|
|
44
49
|
const key=`${context}:${row.decision.decisionId}:${output.id}:${row.context?.contextId??'legacy'}`;const saved=row.reviews[output.id];const historical=Boolean(row.historicalContext||row.provenanceUnavailable||row.feedbackReconciliationRequired);
|
|
45
50
|
return <article key={key} className="space-y-2 rounded-md border p-3">
|
|
46
|
-
{historical?<p className="text-sm">
|
|
51
|
+
{historical?<p className="text-sm">An earlier copy of this source · read-only</p>:null}{row.feedbackReconciliationRequired?<p role="status">Earlier feedback changed; check this item before reviewing again.</p>:null}<h3 className="font-medium">{String(row.item.title??row.decision.itemId)}</h3>
|
|
47
52
|
<p className="whitespace-pre-wrap text-sm">{String(row.item.abstract??row.item.text??'')}</p>
|
|
48
|
-
{!saved||editing[key]?<p className="text-sm" role="status">{historical?'This
|
|
53
|
+
{!saved||editing[key]?<p className="text-sm" role="status">{historical?'This earlier copy is read-only.':!onReview?'Reviewing is not available right now.':busy===key?'Saving your review… your choice is kept.':'Choose an option to enable Submit review. An explanation is optional.'}</p>:null}
|
|
49
54
|
<ReviewControl key={`${key}:${Boolean(editing[key])}`} itemId={`${row.decision.itemId}:${Array.from(new TextEncoder().encode(context+':'+output.id+':'+(row.context?.contextId??'legacy')),byte=>byte.toString(16).padStart(2,'0')).join('')}`} decision={{decisionId:row.decision.decisionId,...prediction,classes:output.classes}} question={output.question} mode="labels" positiveLabel={output.positiveLabel??undefined} disabled={Boolean(busy)||!onReview||historical} value={saved&&!editing[key]?{label:saved.label,reasonCode:saved.reasonCode,explanation:saved.explanation}:undefined} onReview={review=>void submit(row,output.id,{...review,itemId:row.decision.itemId})}/>
|
|
50
55
|
{message?.context===context&&message.key===key?<p role={message.error?'alert':'status'} className="text-sm">{message.text}</p>:null}
|
|
51
|
-
{saved&&onReview&&!historical&&!editing[key]?<button type="button" disabled={Boolean(busy)} onClick={()=>setEditing(old=>({...old,[key]:true}))}>
|
|
52
|
-
<details><summary>
|
|
56
|
+
{saved&&onReview&&!historical&&!editing[key]?<button type="button" disabled={Boolean(busy)} onClick={()=>setEditing(old=>({...old,[key]:true}))}>Change my choice</button>:null}
|
|
57
|
+
<details><summary>Where this suggestion came from</summary><pre className="max-h-64 overflow-auto whitespace-pre-wrap text-xs">{JSON.stringify({itemId:row.decision.itemId,source:row.item.source_uri,decisionId:row.decision.decisionId,prediction,bindingRevision:row.context?.binding.values.revision??status.bindingRevision,sourceRef:row.context?.sourceRef,contextId:row.context?.contextId},null,2)}</pre></details>
|
|
53
58
|
</article>;
|
|
54
59
|
})}
|
|
55
60
|
</section>)}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import type { PrimingPanelProps } from "cyclotron/components/priming-panel";
|
|
5
|
+
import { ReferenceCyclotronPriming, type PrimingFormInput } from "./reference-cyclotron-priming";
|
|
6
|
+
import { ReferencePriming } from "./reference-priming";
|
|
7
|
+
import type { PrimingRequestView } from "../lib/relevance-status";
|
|
8
|
+
import { Button } from "./ui/button";
|
|
9
|
+
|
|
10
|
+
export type ReferenceUrlOption = {
|
|
11
|
+
labels: readonly string[];
|
|
12
|
+
requests: PrimingRequestView[];
|
|
13
|
+
onRequest: (input: PrimingFormInput) => Promise<void> | void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type Option = "url" | "file";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The one place an editor adds a reference. "Paste a URL" is always offered when
|
|
20
|
+
* the host can take one. "Import a file" appears only when a curation session
|
|
21
|
+
* (or the host) supplies the import transport, so without one it never renders.
|
|
22
|
+
*/
|
|
23
|
+
export function ReferenceAddEntry({
|
|
24
|
+
url,
|
|
25
|
+
importOptions,
|
|
26
|
+
onImported,
|
|
27
|
+
}: {
|
|
28
|
+
url?: ReferenceUrlOption | null;
|
|
29
|
+
importOptions?: PrimingPanelProps | null;
|
|
30
|
+
/** Called after an import is saved, so the host can reload what depends on it. */
|
|
31
|
+
onImported?: () => void;
|
|
32
|
+
}) {
|
|
33
|
+
const [chosen, setChosen] = useState<Option>("url");
|
|
34
|
+
const [fileOpened, setFileOpened] = useState(false);
|
|
35
|
+
const [saved, setSaved] = useState("");
|
|
36
|
+
if (!url && !importOptions) return null;
|
|
37
|
+
|
|
38
|
+
const option: Option = importOptions && (!url || chosen === "file") ? "file" : "url";
|
|
39
|
+
const choose = (next: Option) => {
|
|
40
|
+
setChosen(next);
|
|
41
|
+
if (next === "file") setFileOpened(true);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<section aria-label="Add a reference" className="shrink-0 space-y-3" data-add-reference>
|
|
46
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
47
|
+
<h3 className="font-semibold">Add a reference</h3>
|
|
48
|
+
{url && importOptions ? (
|
|
49
|
+
<div aria-label="How to add a reference" className="flex gap-2" role="group">
|
|
50
|
+
<Button aria-pressed={option === "url"} onClick={() => choose("url")} size="sm" type="button" variant={option === "url" ? "secondary" : "outline"}>Paste a URL</Button>
|
|
51
|
+
<Button aria-pressed={option === "file"} onClick={() => choose("file")} size="sm" type="button" variant={option === "file" ? "secondary" : "outline"}>Import a file</Button>
|
|
52
|
+
</div>
|
|
53
|
+
) : null}
|
|
54
|
+
</div>
|
|
55
|
+
{url ? (
|
|
56
|
+
<div hidden={option !== "url"}>
|
|
57
|
+
<ReferenceCyclotronPriming labels={url.labels} onRequestPriming={url.onRequest} requests={url.requests} />
|
|
58
|
+
</div>
|
|
59
|
+
) : null}
|
|
60
|
+
{importOptions && (fileOpened || !url) ? (
|
|
61
|
+
<div className="max-h-[55dvh] space-y-3 overflow-y-auto pr-1" hidden={option !== "file"}>
|
|
62
|
+
<ReferencePriming
|
|
63
|
+
{...importOptions}
|
|
64
|
+
onAccepted={(draft) => {
|
|
65
|
+
setSaved(`Added ${draft.rows.length} reference${draft.rows.length === 1 ? "" : "s"}.`);
|
|
66
|
+
importOptions.onAccepted?.(draft);
|
|
67
|
+
onImported?.();
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
{saved ? <p role="status">{saved}</p> : null}
|
|
71
|
+
</div>
|
|
72
|
+
) : null}
|
|
73
|
+
</section>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -3,21 +3,31 @@
|
|
|
3
3
|
import {useCallback,useEffect,useRef,useState} from 'react';
|
|
4
4
|
import {useNewsroomCuration} from './newsroom-curation-provider';
|
|
5
5
|
import {PublicationCyclotronPanel} from './publication-cyclotron-panel';
|
|
6
|
-
import {
|
|
6
|
+
import type {PrimingPanelProps} from 'cyclotron/components/priming-panel';
|
|
7
7
|
import {Button} from './ui/button';
|
|
8
8
|
import {publicationSteeringPayload,type PublicationHistory,type PublicationStatus} from '../lib/publication-cyclotron';
|
|
9
9
|
import type {CurationSession} from '../lib/curation-client';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** `refreshSignal` changes when references were just added, so the panel reloads by itself. */
|
|
12
|
+
export function ReferenceCurationControls({disabled=false,refreshSignal=0}:{disabled?:boolean;refreshSignal?:number}){
|
|
12
13
|
const session=useNewsroomCuration();
|
|
13
|
-
return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled}/>:null;
|
|
14
|
+
return session&&!disabled?<Controls key={session.key} session={session} disabled={disabled} refreshSignal={refreshSignal}/>:null;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
/**
|
|
18
|
+
* The import option of the Add a reference area, or null. Fail-closed: it exists
|
|
19
|
+
* only when a curation session supplies priming and the person may review.
|
|
20
|
+
*/
|
|
21
|
+
export function useReferenceImport(disabled=false):{panel:PrimingPanelProps;sessionKey:string}|null{
|
|
22
|
+
const session=useNewsroomCuration();
|
|
23
|
+
if(!session||disabled||!session.bootstrap.priming||!session.bootstrap.capabilities.includes('review'))return null;
|
|
24
|
+
return {sessionKey:session.key,panel:{client:session.priming,listId:session.bootstrap.priming.listId,classifiers:session.bootstrap.priming.classifiers}};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function Controls({session,disabled,refreshSignal}:{session:CurationSession;disabled:boolean;refreshSignal:number}){
|
|
17
28
|
const [snapshot,setSnapshot]=useState<{status:PublicationStatus;history:PublicationHistory}|null>(null);
|
|
18
29
|
const [error,setError]=useState('');
|
|
19
30
|
const [loading,setLoading]=useState(true);
|
|
20
|
-
const [primingMessage,setPrimingMessage]=useState('');
|
|
21
31
|
const [steering,setSteering]=useState<string|null>(null);
|
|
22
32
|
const [rubric,setRubric]=useState('');
|
|
23
33
|
const [explanation,setExplanation]=useState('');
|
|
@@ -30,14 +40,16 @@ function Controls({session,disabled}:{session:CurationSession;disabled:boolean})
|
|
|
30
40
|
const refresh=useCallback(async()=>{
|
|
31
41
|
setLoading(true);setError('');
|
|
32
42
|
try{setSnapshot(await session.snapshot());}
|
|
33
|
-
catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'
|
|
43
|
+
catch(failure){setSnapshot(null);setError(failure instanceof Error?failure.message:'Suggestions are unavailable right now.');}
|
|
34
44
|
finally{setLoading(false);}
|
|
35
45
|
},[session]);
|
|
36
46
|
useEffect(()=>{
|
|
37
47
|
let active=true;
|
|
38
|
-
void session.snapshot().then(value=>{if(active){setSnapshot(value);setError('');}}).catch(failure=>{if(active){setSnapshot(null);setError(failure instanceof Error?failure.message:'
|
|
48
|
+
void session.snapshot().then(value=>{if(active){setSnapshot(value);setError('');}}).catch(failure=>{if(active){setSnapshot(null);setError(failure instanceof Error?failure.message:'Suggestions are unavailable right now.');}}).finally(()=>{if(active)setLoading(false);});
|
|
39
49
|
return()=>{active=false;};
|
|
40
50
|
},[session]);
|
|
51
|
+
const lastSignal=useRef(refreshSignal);
|
|
52
|
+
useEffect(()=>{if(lastSignal.current!==refreshSignal){lastSignal.current=refreshSignal;void refresh();}},[refreshSignal,refresh]);
|
|
41
53
|
async function steer(action:'evaluate'|'promote'){
|
|
42
54
|
if(!snapshot||!steering||!session.bootstrap.bindingRef)return;
|
|
43
55
|
setBusy(true);setSteeringMessage('');
|
|
@@ -49,29 +61,26 @@ function Controls({session,disabled}:{session:CurationSession;disabled:boolean})
|
|
|
49
61
|
const receipt=await session.publication.steer(payload);
|
|
50
62
|
const result=(receipt.steering as {result?:{trial_fingerprint?:string;improved?:boolean;promoted?:boolean}}|undefined)?.result;
|
|
51
63
|
if(action==='evaluate')setTrial(result?.trial_fingerprint?{fingerprint:result.trial_fingerprint,improved:result.improved===true}:null);
|
|
52
|
-
setSteeringMessage(action==='promote'?'
|
|
64
|
+
setSteeringMessage(action==='promote'?'The tested change is now in use.':result?.improved?'Test finished: the change improved the results.':'Test finished: no improvement was found.');
|
|
53
65
|
await refresh();
|
|
54
|
-
}catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'
|
|
66
|
+
}catch(failure){setSteeringMessage(failure instanceof Error?failure.message:'The result is unknown; check before trying again.');}
|
|
55
67
|
finally{setBusy(false);}
|
|
56
68
|
}
|
|
57
|
-
return <section aria-label="
|
|
58
|
-
<div className="flex items-center justify-between gap-3"><h3 className="font-semibold">
|
|
59
|
-
<p className="text-xs text-muted-foreground">{session.bootstrap.mode==='remote'?'
|
|
60
|
-
{loading?<p role="status">Loading
|
|
69
|
+
return <section aria-label="Suggestions for this publication" className="max-h-[55dvh] min-h-0 shrink-0 space-y-3 overflow-y-auto rounded-md border p-3" data-curation-mode={session.bootstrap.mode}>
|
|
70
|
+
<div className="flex items-center justify-between gap-3"><h3 className="font-semibold">Suggestions for this publication</h3><Button variant="outline" size="sm" disabled={loading||busy} onClick={()=>void refresh()}>Refresh</Button></div>
|
|
71
|
+
<p className="text-xs text-muted-foreground">{session.bootstrap.mode==='remote'?'Shared review service':'This site'} · {session.bootstrap.scope.publicationId}</p>
|
|
72
|
+
{loading?<p role="status">Loading suggestions…</p>:null}
|
|
61
73
|
{error?<p role="alert">{error}</p>:null}
|
|
62
74
|
{snapshot?<PublicationCyclotronPanel mode={session.bootstrap.mode} status={snapshot.status} history={snapshot.history}
|
|
63
75
|
onReview={canReview?async payload=>{await session.publication.review(payload);await refresh();}:undefined}
|
|
64
|
-
onSteer={canSteer&&
|
|
65
|
-
{steering?<form aria-label="
|
|
66
|
-
<h4 className="font-semibold">
|
|
67
|
-
<p className="text-sm">A
|
|
68
|
-
<label className="block text-sm">Proposed
|
|
76
|
+
onSteer={canSteer&&session.bootstrap.bindingRef?classifier=>{setSteering(classifier);setRubric('');setExplanation('');setTrial(null);setSteeringMessage('');}:undefined}/>:null}
|
|
77
|
+
{steering?<form aria-label="Rule change" className="space-y-3 rounded-md border p-3" onSubmit={event=>{event.preventDefault();void steer('evaluate');}}>
|
|
78
|
+
<h4 className="font-semibold">Test a rule change</h4>
|
|
79
|
+
<p className="text-sm">A change is tested before it is adopted. Opening this form does not change any existing suggestion.</p>
|
|
80
|
+
<label className="block text-sm">Proposed rule<textarea className="mt-1 block min-h-24 w-full rounded border bg-background p-2" required value={rubric} onChange={event=>{setRubric(event.target.value);setTrial(null);}}/></label>
|
|
69
81
|
<label className="block text-sm">Why make this change?<textarea className="mt-1 block min-h-20 w-full rounded border bg-background p-2" required value={explanation} onChange={event=>setExplanation(event.target.value)}/></label>
|
|
70
|
-
<div className="flex flex-wrap gap-2"><Button type="submit" disabled={busy||!canSteer||!rubric.trim()||!explanation.trim()}>
|
|
82
|
+
<div className="flex flex-wrap gap-2"><Button type="submit" disabled={busy||!canSteer||!rubric.trim()||!explanation.trim()}>Test change</Button><Button type="button" variant="outline" disabled={busy||!canSteer||!trial?.improved||!explanation.trim()} onClick={()=>void steer('promote')}>Adopt tested change</Button><Button type="button" variant="ghost" disabled={busy} onClick={()=>setSteering(null)}>Close</Button></div>
|
|
71
83
|
{steeringMessage?<p role="status">{steeringMessage}</p>:null}
|
|
72
84
|
</form>:null}
|
|
73
|
-
{session.bootstrap.priming&&canReview?<ReferencePriming client={session.priming} listId={session.bootstrap.priming.listId} classifiers={session.bootstrap.priming.classifiers}
|
|
74
|
-
onAccepted={draft=>{setPrimingMessage(`Accepted ${draft.rows.length} example item${draft.rows.length===1?'':'s'} for this pinned publication. Operation remains available without priming.`);void refresh();}}/>:null}
|
|
75
|
-
{primingMessage?<p role="status">{primingMessage}</p>:null}
|
|
76
85
|
</section>;
|
|
77
86
|
}
|