@anthusai/papyrus 2.0.0-next.12 → 2.0.0-next.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +8 -0
- package/amplify/data/schema.ts +9 -0
- package/amplify/functions/content-actions/handler.py +15 -0
- package/amplify/functions/editorial-checks/requirements.txt +1 -1
- package/amplify/functions/editorial-checks/resource.js +3 -3
- package/amplify/functions/editorial-checks/resource.ts +3 -3
- package/amplify/functions/editorial-checks/schema.js +2 -2
- package/amplify/functions/editorial-checks/schema.ts +2 -2
- 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/publication/redirect-and-index.js +80 -0
- package/amplify/publication/redirect-rules.js +56 -0
- package/amplify/publication/redirect-rules.ts +73 -0
- package/amplify/publication/s3-projection.js +5 -1
- package/amplify/publication/s3-projection.ts +7 -1
- package/amplify/site-backend.js +9 -0
- package/amplify/site-backend.ts +9 -0
- package/components/editorial-checks-admin-panel.tsx +5 -5
- package/components/monitoring-overview-view.tsx +240 -0
- package/components/monitoring-panel.tsx +40 -0
- package/components/newsroom-organizer.css +17 -0
- package/components/newsroom-references-view.tsx +14 -14
- package/components/publication-cyclotron-panel.tsx +10 -10
- package/components/reference-add-entry.tsx +75 -0
- package/components/reference-curation-controls.tsx +30 -21
- package/components/reference-cyclotron-priming.tsx +4 -4
- 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 +13 -2
- package/lib/category-repository.ts +11 -9
- package/lib/editorial-checks-demo.ts +1 -1
- package/lib/editorial-checks.ts +2 -2
- 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 +144 -0
- package/lib/relevance-status.ts +2 -2
- package/lib/site-brand-tenant.ts +11 -6
- package/lib/site-brand.ts +6 -3
- package/package.json +8 -2
- package/routes.manifest.json +1 -1
|
@@ -10,11 +10,13 @@ import * as route53 from "aws-cdk-lib/aws-route53";
|
|
|
10
10
|
import * as s3 from "aws-cdk-lib/aws-s3";
|
|
11
11
|
import * as sqs from "aws-cdk-lib/aws-sqs";
|
|
12
12
|
import type { Construct } from "constructs";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
13
14
|
import { dirname, resolve } from "node:path";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
16
|
import { assertValidPublicationConfig, type PapyrusPublicationConfig } from "../site-backend-config";
|
|
16
17
|
import { addRenditionsFunction, type RenditionsFunction } from "./renditions-function";
|
|
17
18
|
import { RENDITIONS_FUNCTION_NAME_ENV, type RenditionsPlan } from "./renditions-settings";
|
|
19
|
+
import { compileRedirectFunctionSource, readEdgeConfig } from "./redirect-rules";
|
|
18
20
|
import { addInvalidationPathAlarm } from "./invalidation-alarm";
|
|
19
21
|
import { distributionIdFromArn, s3ProjectionEnvironment, s3ProjectionPolicyStatements } from "./s3-projection-settings";
|
|
20
22
|
|
|
@@ -112,7 +114,11 @@ export function addS3ProjectionPublication(
|
|
|
112
114
|
const keyValueStore = new cloudfront.KeyValueStore(scope, "RedirectsKeyValueStore");
|
|
113
115
|
const redirectFunction = new cloudfront.Function(scope, "RedirectAndIndexFunction", {
|
|
114
116
|
runtime: cloudfront.FunctionRuntime.JS_2_0,
|
|
115
|
-
code: cloudfront.FunctionCode.
|
|
117
|
+
code: cloudfront.FunctionCode.fromInline(
|
|
118
|
+
compileRedirectFunctionSource(readFileSync(redirectFunctionSourcePath, "utf8"), readEdgeConfig(props.staticDir).rules, {
|
|
119
|
+
negotiateMarkdown: readEdgeConfig(props.staticDir).negotiateMarkdown,
|
|
120
|
+
}),
|
|
121
|
+
),
|
|
116
122
|
keyValueStore,
|
|
117
123
|
});
|
|
118
124
|
const deadLetterQueue = new sqs.Queue(scope, "ProjectionDeadLetterQueue", {
|
package/amplify/site-backend.js
CHANGED
|
@@ -21,6 +21,8 @@ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRe
|
|
|
21
21
|
import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client.js";
|
|
22
22
|
import { defineSiteData } from "./data/resource.js";
|
|
23
23
|
import { editorialChecks } from "./functions/editorial-checks/resource.js";
|
|
24
|
+
import { monitoringView } from "./functions/monitoring-view/resource.js";
|
|
25
|
+
import { wireMonitoringView } from "./functions/monitoring-view/wire.js";
|
|
24
26
|
import { wireEditorialChecks } from "./functions/editorial-checks/wire.js";
|
|
25
27
|
import { resolvePlugins } from "./plugins/registry.js";
|
|
26
28
|
import { wirePlugins } from "./plugins/wire.js";
|
|
@@ -95,6 +97,7 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
95
97
|
data: defineSiteData(pluginDefinitions),
|
|
96
98
|
knowledgeQuery,
|
|
97
99
|
manageUserRole,
|
|
100
|
+
monitoringView,
|
|
98
101
|
modelAttachmentUpload,
|
|
99
102
|
newsroomSummary,
|
|
100
103
|
procedureAction,
|
|
@@ -313,6 +316,11 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
313
316
|
grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda);
|
|
314
317
|
const contentActionsLambda = backend.contentActions.resources.lambda;
|
|
315
318
|
grantNewsroomReadWriteDelete(contentActionsLambda);
|
|
319
|
+
if ((activeBrand?.corpusKey ?? "").trim() !== "") {
|
|
320
|
+
// A deploy creates the publication's canonical corpus from the brand when the backend has none.
|
|
321
|
+
contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_CORPUS_KEY", (activeBrand?.corpusKey ?? "").trim());
|
|
322
|
+
contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_NAME", (activeBrand?.publicationName ?? activeBrand?.corpusKey ?? "").trim());
|
|
323
|
+
}
|
|
316
324
|
// AppSync validates the token; pending intake additionally binds its actor
|
|
317
325
|
// and publication to this deployment's pool and existing API endpoint.
|
|
318
326
|
contentActionsLambda.addEnvironment("PAPYRUS_USER_POOL_ISSUER", `https://cognito-idp.${Stack.of(contentActionsLambda).region}.amazonaws.com/${backend.auth.resources.userPool.userPoolId}`);
|
|
@@ -593,6 +601,7 @@ export function defineSiteBackend(papyrusSite) {
|
|
|
593
601
|
},
|
|
594
602
|
});
|
|
595
603
|
wireEditorialChecks(backend);
|
|
604
|
+
wireMonitoringView(backend);
|
|
596
605
|
wirePlugins(backend, plugins, contentActionsLambda);
|
|
597
606
|
return backend;
|
|
598
607
|
}
|
package/amplify/site-backend.ts
CHANGED
|
@@ -28,6 +28,8 @@ import { applyCognitoDomainPrefix, authConfigFromEnv, defineSiteAuth, siteAuthRe
|
|
|
28
28
|
import { PROSPECTUS_SIGN_IN_CALLBACK_PATH, addProspectusGoogleOnlyClient } from "./auth/prospectus-google-client";
|
|
29
29
|
import { defineSiteData } from "./data/resource";
|
|
30
30
|
import { editorialChecks } from "./functions/editorial-checks/resource";
|
|
31
|
+
import { monitoringView } from "./functions/monitoring-view/resource";
|
|
32
|
+
import { wireMonitoringView } from "./functions/monitoring-view/wire";
|
|
31
33
|
import { wireEditorialChecks } from "./functions/editorial-checks/wire";
|
|
32
34
|
import { resolvePlugins } from "./plugins/registry";
|
|
33
35
|
import { wirePlugins } from "./plugins/wire";
|
|
@@ -116,6 +118,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
116
118
|
data: defineSiteData(pluginDefinitions),
|
|
117
119
|
knowledgeQuery,
|
|
118
120
|
manageUserRole,
|
|
121
|
+
monitoringView,
|
|
119
122
|
modelAttachmentUpload,
|
|
120
123
|
newsroomSummary,
|
|
121
124
|
procedureAction,
|
|
@@ -399,6 +402,11 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
399
402
|
grantNewsroomReadWrite(backend.newsroomSummary.resources.lambda as LambdaFunction);
|
|
400
403
|
const contentActionsLambda = backend.contentActions.resources.lambda as LambdaFunction;
|
|
401
404
|
grantNewsroomReadWriteDelete(contentActionsLambda);
|
|
405
|
+
if ((activeBrand?.corpusKey ?? "").trim() !== "") {
|
|
406
|
+
// A deploy creates the publication's canonical corpus from the brand when the backend has none.
|
|
407
|
+
contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_CORPUS_KEY", (activeBrand?.corpusKey ?? "").trim());
|
|
408
|
+
contentActionsLambda.addEnvironment("PAPYRUS_PUBLICATION_NAME", (activeBrand?.publicationName ?? activeBrand?.corpusKey ?? "").trim());
|
|
409
|
+
}
|
|
402
410
|
// AppSync validates the token; pending intake additionally binds its actor
|
|
403
411
|
// and publication to this deployment's pool and existing API endpoint.
|
|
404
412
|
contentActionsLambda.addEnvironment(
|
|
@@ -736,6 +744,7 @@ export function defineSiteBackend(papyrusSite: PapyrusSite) {
|
|
|
736
744
|
},
|
|
737
745
|
});
|
|
738
746
|
wireEditorialChecks(backend);
|
|
747
|
+
wireMonitoringView(backend);
|
|
739
748
|
wirePlugins(backend, plugins, contentActionsLambda);
|
|
740
749
|
|
|
741
750
|
return backend;
|
|
@@ -67,8 +67,8 @@ function lines(text: string): string[] {
|
|
|
67
67
|
return text.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/** The policy as
|
|
71
|
-
function
|
|
70
|
+
/** The policy as Humanus sees it (its key is the policy id). Throws on invalid detector JSON. */
|
|
71
|
+
function humanusPolicy(draft: Draft): Record<string, unknown> {
|
|
72
72
|
const detectors = JSON.parse(draft.detectorsText || "[]") as unknown;
|
|
73
73
|
if (!Array.isArray(detectors)) throw new Error("Detectors must be a JSON list.");
|
|
74
74
|
const violates = lines(draft.violatesText);
|
|
@@ -133,7 +133,7 @@ export function EditorialChecksAdminPanel() {
|
|
|
133
133
|
const key = draft.record.policyKey.trim();
|
|
134
134
|
if (!POLICY_KEY.test(key)) throw new Error("The key must be lowercase letters, digits, dots, hyphens or underscores.");
|
|
135
135
|
if (!draft.record.title.trim() || !draft.record.guidance.trim()) throw new Error("A policy needs a title and guidance.");
|
|
136
|
-
const policy =
|
|
136
|
+
const policy = humanusPolicy(draft);
|
|
137
137
|
const record: EditorialPolicyRecord = {
|
|
138
138
|
...draft.record,
|
|
139
139
|
id: draft.isNew ? `${draft.record.scope}#${key}` : draft.record.id,
|
|
@@ -252,7 +252,7 @@ export function EditorialChecksAdminPanel() {
|
|
|
252
252
|
<label>Examples that break it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, violatesText: event.target.value })} rows={3} value={draft.violatesText} /></label>
|
|
253
253
|
<label>Examples that follow it (one per line) <Textarea onChange={(event) => setDraft({ ...draft, compliesText: event.target.value })} rows={3} value={draft.compliesText} /></label>
|
|
254
254
|
<label>
|
|
255
|
-
Detectors (JSON; see <code>
|
|
255
|
+
Detectors (JSON; see <code>humanus schema policy-pack-v1</code>)
|
|
256
256
|
<Textarea className="font-mono text-xs" onChange={(event) => setDraft({ ...draft, detectorsText: event.target.value })} rows={10} value={draft.detectorsText} />
|
|
257
257
|
</label>
|
|
258
258
|
<div className="flex flex-wrap gap-2">
|
|
@@ -293,7 +293,7 @@ export function EditorialChecksAdminPanel() {
|
|
|
293
293
|
<Button
|
|
294
294
|
disabled={working !== null || !tryText.trim()}
|
|
295
295
|
onClick={() => run("try", async () => {
|
|
296
|
-
setTryResult(await tryPolicy(
|
|
296
|
+
setTryResult(await tryPolicy(humanusPolicy(draft), tryText));
|
|
297
297
|
})}
|
|
298
298
|
size="sm"
|
|
299
299
|
type="button"
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import {
|
|
3
|
+
confidenceText,
|
|
4
|
+
describeDelivery,
|
|
5
|
+
describeRelevance,
|
|
6
|
+
everyText,
|
|
7
|
+
formatUtc,
|
|
8
|
+
referenceHref,
|
|
9
|
+
routingLabel,
|
|
10
|
+
usageText,
|
|
11
|
+
type MonitoringItem,
|
|
12
|
+
type MonitoringOverview,
|
|
13
|
+
type MonitoringRelevance,
|
|
14
|
+
type MonitoringResult,
|
|
15
|
+
} from "../lib/monitoring-overview";
|
|
16
|
+
|
|
17
|
+
export type MonitoringMode = "publication" | "all";
|
|
18
|
+
|
|
19
|
+
const STATUS_WORDS: Record<string, string> = { complete: "Complete", partial: "Partial", stopped: "Stopped", failed: "Failed", error: "Error" };
|
|
20
|
+
const status = (value: string) => STATUS_WORDS[value] ?? value;
|
|
21
|
+
const STOP_WORDS: Record<string, string> = {
|
|
22
|
+
request_ceiling_reached: "stopped at the request ceiling",
|
|
23
|
+
standing_capacity_reached: "stopped: the standing run is full",
|
|
24
|
+
provider_key_unavailable: "stopped: the Cyclotron has no model key",
|
|
25
|
+
run_busy: "stopped: the Cyclotron run was busy",
|
|
26
|
+
time_budget: "stopped for time",
|
|
27
|
+
};
|
|
28
|
+
const MARKS: Record<string, string> = { delivered: "✓", failed: "✕", pending: "…", not_applicable: "–" };
|
|
29
|
+
|
|
30
|
+
function feedHost(url: string | null): string {
|
|
31
|
+
try {
|
|
32
|
+
return url ? new URL(url).host : "unknown source";
|
|
33
|
+
} catch {
|
|
34
|
+
return "unknown source";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function Message({ title, children, kind }: { title: string; children?: React.ReactNode; kind: string }) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="category-steering-empty" role={kind === "loading" ? "status" : "alert"} data-monitoring-state={kind}>
|
|
41
|
+
<strong>{title}</strong>
|
|
42
|
+
{children ? <p>{children}</p> : null}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function RelevanceLine({ entry, linkable, demo }: { entry: MonitoringRelevance; linkable: boolean; demo: boolean }) {
|
|
48
|
+
const lineage = entry.delivery.referenceLineageId ?? entry.delivery.referenceId ?? null;
|
|
49
|
+
const delivery = describeDelivery(entry);
|
|
50
|
+
return (
|
|
51
|
+
<li data-relevance={entry.relevant === null ? "unknown" : entry.relevant ? "yes" : "no"} data-delivery={entry.delivery.state ?? "none"}>
|
|
52
|
+
<strong>{entry.publicationId}</strong>
|
|
53
|
+
<span>{describeRelevance(entry)}{entry.relevant !== null ? `, ${confidenceText(entry.confidence)}` : ""}</span>
|
|
54
|
+
{delivery ? (
|
|
55
|
+
<span>
|
|
56
|
+
<span aria-hidden="true">{MARKS[entry.delivery.state ?? ""] ?? ""} </span>
|
|
57
|
+
{delivery}
|
|
58
|
+
{entry.delivery.state === "delivered" && lineage && linkable ? (
|
|
59
|
+
<> <Link href={referenceHref(lineage, demo)} data-monitoring-reference-link>Open in References</Link></>
|
|
60
|
+
) : null}
|
|
61
|
+
{entry.delivery.state === "failed" && entry.delivery.error ? <> ({entry.delivery.error})</> : null}
|
|
62
|
+
</span>
|
|
63
|
+
) : null}
|
|
64
|
+
</li>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function QueueItem({ item, ownId, demo }: { item: MonitoringItem; ownId: string | null; demo: boolean }) {
|
|
69
|
+
return (
|
|
70
|
+
<li data-monitoring-item={item.sourceId} data-routing={item.routing}>
|
|
71
|
+
<div>
|
|
72
|
+
<strong>{item.url ? <a href={item.url} target="_blank" rel="noreferrer noopener">{item.title}</a> : item.title}</strong>
|
|
73
|
+
<span>
|
|
74
|
+
{formatUtc(item.observedAt)}
|
|
75
|
+
{item.discussionUrl ? <> · <a href={item.discussionUrl} target="_blank" rel="noreferrer noopener">Hacker News thread</a></> : null}
|
|
76
|
+
</span>
|
|
77
|
+
</div>
|
|
78
|
+
<p className="news-desk-monitoring__routing">
|
|
79
|
+
{routingLabel(item.routing)}
|
|
80
|
+
{item.routingNote ? <>: {item.routingNote}</> : null}
|
|
81
|
+
</p>
|
|
82
|
+
{item.relevance.length ? (
|
|
83
|
+
<ul className="news-desk-monitoring__relevance">
|
|
84
|
+
{item.relevance.map((entry) => <RelevanceLine key={entry.publicationId} entry={entry} linkable={entry.publicationId === ownId} demo={demo} />)}
|
|
85
|
+
</ul>
|
|
86
|
+
) : null}
|
|
87
|
+
</li>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Ready({ data, demo }: { data: MonitoringOverview; demo: boolean }) {
|
|
92
|
+
const ceilings = data.usage?.ceilings ?? null;
|
|
93
|
+
const router = data.router;
|
|
94
|
+
return (
|
|
95
|
+
<>
|
|
96
|
+
{data.notice === "publication_not_routed" ? (
|
|
97
|
+
<p className="news-desk-delegation-note" data-monitoring-notice>This publication is not one of the router's outputs, so no relevance is shown for it here.</p>
|
|
98
|
+
) : null}
|
|
99
|
+
|
|
100
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="router">
|
|
101
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Router</h3><span>Sends collected items to the shared Cyclotron and delivers relevant ones to References</span></div></div>
|
|
102
|
+
{router ? (
|
|
103
|
+
<p data-monitoring-router>
|
|
104
|
+
<strong>{router.mode === "live" ? "Live" : "Dry run"}</strong>
|
|
105
|
+
{router.mode === "live" ? " · decisions are made and delivered" : " · requests are checked but nothing is sent"}
|
|
106
|
+
{" · "}{router.feedback ? "Feedback on" : "Feedback off"}
|
|
107
|
+
{router.enabled ? "" : " · Paused"}
|
|
108
|
+
</p>
|
|
109
|
+
) : <p>The router has not been configured.</p>}
|
|
110
|
+
</article>
|
|
111
|
+
|
|
112
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="usage">
|
|
113
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Usage</h3><span>Decisions the router recorded, against the shared Cyclotron's ceilings (UTC days)</span></div></div>
|
|
114
|
+
{data.usage ? (
|
|
115
|
+
<>
|
|
116
|
+
<dl className="news-desk-monitoring__usage">
|
|
117
|
+
<div><dt>Decisions today ({data.usage.day})</dt><dd>{usageText(data.usage.decisionsToday, ceilings?.decisionsPerDay)}</dd></div>
|
|
118
|
+
<div><dt>Decisions in total</dt><dd>{usageText(data.usage.decisionsTotal, ceilings?.decisionsTotal)}</dd></div>
|
|
119
|
+
{ceilings?.items !== undefined ? <div><dt>Items judged (ceiling)</dt><dd>{usageText(data.usage.decisionsTotal, ceilings.items)}</dd></div> : null}
|
|
120
|
+
</dl>
|
|
121
|
+
{ceilings === null ? <p className="news-desk-delegation-note">No ceilings are configured here, so only usage is shown.</p> : null}
|
|
122
|
+
</>
|
|
123
|
+
) : <p>No usage recorded yet.</p>}
|
|
124
|
+
</article>
|
|
125
|
+
|
|
126
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="feeds">
|
|
127
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Feeds watched</h3><span>What the collector reads</span></div></div>
|
|
128
|
+
{data.feeds.length ? (
|
|
129
|
+
<ul className="news-desk-monitoring__list">
|
|
130
|
+
{data.feeds.map((feed) => (
|
|
131
|
+
<li key={feed.id} data-monitoring-feed={feed.id}>
|
|
132
|
+
<div>
|
|
133
|
+
<strong>{feed.id}</strong>
|
|
134
|
+
<span>{feedHost(feed.sourceUrl)}</span>
|
|
135
|
+
</div>
|
|
136
|
+
<span>{feed.enabled ? "Enabled" : "Disabled"} · {everyText(feed.intervalSeconds)}{feed.limit ? ` · up to ${feed.limit} items` : ""}</span>
|
|
137
|
+
<span>Last run {formatUtc(feed.lastFinishedAt)}{feed.lastStatus ? ` (${status(feed.lastStatus)})` : ""} · next {formatUtc(feed.nextDueAt)}</span>
|
|
138
|
+
</li>
|
|
139
|
+
))}
|
|
140
|
+
</ul>
|
|
141
|
+
) : <p>No feed is configured.</p>}
|
|
142
|
+
</article>
|
|
143
|
+
|
|
144
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="runs">
|
|
145
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Recent runs</h3><span>Collector receipts, newest first</span></div></div>
|
|
146
|
+
{data.runs.length ? (
|
|
147
|
+
<ul className="news-desk-monitoring__list">
|
|
148
|
+
{data.runs.map((run) => (
|
|
149
|
+
<li key={run.runId} data-monitoring-run={run.runId}>
|
|
150
|
+
<strong>{formatUtc(run.startedAt)}</strong>
|
|
151
|
+
<span>{status(run.status)}</span>
|
|
152
|
+
<span>{run.new} new · {run.revised} revised · {run.unchanged} unchanged · {run.errorCount} {run.errorCount === 1 ? "error" : "errors"}{run.errors.length ? ` (${run.errors.join("; ")})` : ""}</span>
|
|
153
|
+
</li>
|
|
154
|
+
))}
|
|
155
|
+
</ul>
|
|
156
|
+
) : <p>No collector runs recorded yet.</p>}
|
|
157
|
+
{data.routerRuns.length ? (
|
|
158
|
+
<ul className="news-desk-monitoring__list" data-monitoring-router-runs>
|
|
159
|
+
{data.routerRuns.map((run) => (
|
|
160
|
+
<li key={run.runId} data-monitoring-router-run={run.runId}>
|
|
161
|
+
<strong>{formatUtc(run.startedAt)}</strong>
|
|
162
|
+
<span>Router, {run.mode === "live" ? "live" : "dry run"}: {status(run.status)}{run.stoppedBy ? `, ${STOP_WORDS[run.stoppedBy] ?? run.stoppedBy}` : ""}</span>
|
|
163
|
+
<span>{run.counts.examined ?? 0} examined · {run.counts.decided ?? 0} judged · {run.counts.delivered ?? 0} delivered · {run.counts.deliveryFailed ?? 0} {(run.counts.deliveryFailed ?? 0) === 1 ? "delivery failure" : "delivery failures"} · {run.errorCount} {run.errorCount === 1 ? "error" : "errors"}</span>
|
|
164
|
+
</li>
|
|
165
|
+
))}
|
|
166
|
+
</ul>
|
|
167
|
+
) : null}
|
|
168
|
+
</article>
|
|
169
|
+
|
|
170
|
+
<article className="news-desk-doctrine-card" data-monitoring-section="queue">
|
|
171
|
+
<div className="news-desk-doctrine-card__header"><div><h3>Collected queue</h3><span>Newest first, with what the shared Cyclotron decided</span></div></div>
|
|
172
|
+
{data.items.length ? (
|
|
173
|
+
<ul className="news-desk-monitoring__queue">
|
|
174
|
+
{data.items.map((item) => <QueueItem key={`${item.sourceId}-${item.revision}`} item={item} ownId={data.scope.publicationId} demo={demo} />)}
|
|
175
|
+
</ul>
|
|
176
|
+
) : <p>No items in the queue.</p>}
|
|
177
|
+
{data.hiddenNotRelevant > 0 ? (
|
|
178
|
+
<p className="news-desk-delegation-note" data-monitoring-hidden>
|
|
179
|
+
{data.hiddenNotRelevant} {data.hiddenNotRelevant === 1 ? "item" : "items"} judged not relevant to this publication {data.hiddenNotRelevant === 1 ? "is" : "are"} not listed.
|
|
180
|
+
</p>
|
|
181
|
+
) : null}
|
|
182
|
+
</article>
|
|
183
|
+
</>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Presentation only: the panel fetches, this renders any result, so every state is testable from fixtures. */
|
|
188
|
+
export function MonitoringOverviewView({
|
|
189
|
+
result,
|
|
190
|
+
canViewAll,
|
|
191
|
+
mode,
|
|
192
|
+
onModeChange,
|
|
193
|
+
demo = false,
|
|
194
|
+
}: {
|
|
195
|
+
result: MonitoringResult | null;
|
|
196
|
+
canViewAll: boolean;
|
|
197
|
+
mode: MonitoringMode;
|
|
198
|
+
onModeChange: (mode: MonitoringMode) => void;
|
|
199
|
+
demo?: boolean;
|
|
200
|
+
}) {
|
|
201
|
+
let body: React.ReactNode;
|
|
202
|
+
if (result === null) {
|
|
203
|
+
body = <Message kind="loading" title="Loading monitoring" />;
|
|
204
|
+
} else if ("error" in result) {
|
|
205
|
+
body = result.error === "forbidden"
|
|
206
|
+
? <Message kind="forbidden" title="Monitoring is for editors and admins">Your account does not have access to this page. Ask an admin for the editor role.</Message>
|
|
207
|
+
: <Message kind="error" title="Monitoring could not be read">The request was not understood. Reload the page.</Message>;
|
|
208
|
+
} else if (result.state === "not_configured") {
|
|
209
|
+
body = <Message kind="not-configured" title="Monitoring is not set up for this publication">The monitoring pipeline is not connected to this site's account, so there is nothing to show.</Message>;
|
|
210
|
+
} else if (result.state === "unavailable") {
|
|
211
|
+
body = <Message kind="unavailable" title="Monitoring could not be read right now">The pipeline is not affected. Try again in a minute.</Message>;
|
|
212
|
+
} else if (result.state === "empty") {
|
|
213
|
+
body = <Message kind="empty" title="Nothing has been collected yet">No feed is configured and no run has been recorded. Items appear here after the collector's first run.</Message>;
|
|
214
|
+
} else {
|
|
215
|
+
body = <Ready data={result} demo={demo} />;
|
|
216
|
+
}
|
|
217
|
+
const scopeLine = result && "scope" in result
|
|
218
|
+
? (result.scope.mode === "all" ? "Showing the whole shared pipeline, every publication." : `Showing what is relevant to ${result.scope.publicationId ?? "this publication"}.`)
|
|
219
|
+
: null;
|
|
220
|
+
return (
|
|
221
|
+
<div data-news-desk-admin-panel="monitoring">
|
|
222
|
+
<article className="news-desk-doctrine-card">
|
|
223
|
+
<div className="news-desk-doctrine-card__header">
|
|
224
|
+
<div>
|
|
225
|
+
<h3>Monitoring</h3>
|
|
226
|
+
<span>The automated research pipeline: collection, judging and delivery to References. Read only.</span>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
{canViewAll ? (
|
|
230
|
+
<div className="news-desk-monitoring__scope" role="group" aria-label="Scope">
|
|
231
|
+
<button type="button" aria-pressed={mode === "publication"} data-monitoring-scope="publication" onClick={() => onModeChange("publication")}>This publication</button>
|
|
232
|
+
<button type="button" aria-pressed={mode === "all"} data-monitoring-scope="all" onClick={() => onModeChange("all")}>Whole shared pipeline</button>
|
|
233
|
+
</div>
|
|
234
|
+
) : null}
|
|
235
|
+
{scopeLine ? <p className="news-desk-delegation-note" data-monitoring-scope-line>{scopeLine} {result && "asOf" in result ? `As of ${formatUtc(result.asOf)}.` : ""}</p> : null}
|
|
236
|
+
</article>
|
|
237
|
+
{body}
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { loadMonitoringOverview } from "../lib/monitoring-client";
|
|
5
|
+
import { FIXTURE_ALL_PIPELINE_OVERVIEW, FIXTURE_MONITORING_OVERVIEW } from "../lib/monitoring-overview-fixture";
|
|
6
|
+
import type { MonitoringResult } from "../lib/monitoring-overview";
|
|
7
|
+
import { MonitoringOverviewView, type MonitoringMode } from "./monitoring-overview-view";
|
|
8
|
+
|
|
9
|
+
const REFRESH_MILLISECONDS = 60_000;
|
|
10
|
+
|
|
11
|
+
/** Administration, Monitoring: loads the overview, refreshes it each minute, and lets an admin widen it to the whole pipeline. */
|
|
12
|
+
export function MonitoringPanel({ isAdmin, isDemo }: { isAdmin: boolean; isDemo: boolean }) {
|
|
13
|
+
const [mode, setMode] = useState<MonitoringMode>("publication");
|
|
14
|
+
const [result, setResult] = useState<MonitoringResult | null>(null);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (isDemo) {
|
|
18
|
+
setResult(mode === "all" ? FIXTURE_ALL_PIPELINE_OVERVIEW : FIXTURE_MONITORING_OVERVIEW);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
let live = true;
|
|
22
|
+
const refresh = async () => {
|
|
23
|
+
try {
|
|
24
|
+
const next = await loadMonitoringOverview(mode);
|
|
25
|
+
if (live) setResult(next);
|
|
26
|
+
} catch {
|
|
27
|
+
if (live) setResult({ state: "unavailable" });
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
setResult(null);
|
|
31
|
+
void refresh();
|
|
32
|
+
const timer = window.setInterval(() => void refresh(), REFRESH_MILLISECONDS);
|
|
33
|
+
return () => {
|
|
34
|
+
live = false;
|
|
35
|
+
window.clearInterval(timer);
|
|
36
|
+
};
|
|
37
|
+
}, [mode, isDemo]);
|
|
38
|
+
|
|
39
|
+
return <MonitoringOverviewView result={result} canViewAll={isAdmin} mode={mode} onModeChange={setMode} demo={isDemo} />;
|
|
40
|
+
}
|
|
@@ -313,3 +313,20 @@ 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)}}
|
|
@@ -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} />
|
|
@@ -28,28 +28,28 @@ function PublicationControls({mode,status,history,onReview,onSteer}:PublicationP
|
|
|
28
28
|
const signature=JSON.stringify(review);const previous=requests.current[key];
|
|
29
29
|
const id=previous?.signature===signature?previous.id:requestId();requests.current[key]={signature,id};
|
|
30
30
|
const payload=publicationReviewPayload(status,row,classifier,review,id);
|
|
31
|
-
if(!onReview)throw new Error('
|
|
31
|
+
if(!onReview)throw new Error('Reviewing is not turned on here.');
|
|
32
32
|
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:'
|
|
33
|
+
if(current.current===context){setMessage({context,key,error:false,text:'Your choice was saved.'});setEditing(old=>({...old,[key]:false}));}
|
|
34
|
+
}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
35
|
finally{if(current.current===context)setBusy(null);}
|
|
36
36
|
}
|
|
37
|
-
return <section data-publication-scope={context} aria-label="
|
|
38
|
-
<header><h2 className="text-lg font-semibold">
|
|
37
|
+
return <section data-publication-scope={context} aria-label="Suggestions and reviews" className="space-y-4">
|
|
38
|
+
<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
39
|
{status.outputs.map(output=><section key={`${context}:${output.id}`} aria-label={output.question} className="space-y-3 rounded-md border p-3">
|
|
40
40
|
<CyclotronStatusView status={status.classifiers[output.id]} classifierName={output.question} variant="card"/>
|
|
41
|
-
{onSteer?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>
|
|
41
|
+
{onSteer?<button type="button" disabled={Boolean(busy)} onClick={()=>onSteer(output.id)}>Test a rule change</button>:null}
|
|
42
42
|
{history.history.map(row=>{
|
|
43
43
|
const prediction=row.decision.classifiers[output.id];if(!prediction)return null;
|
|
44
44
|
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
45
|
return <article key={key} className="space-y-2 rounded-md border p-3">
|
|
46
|
-
{historical?<p className="text-sm">
|
|
46
|
+
{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
47
|
<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
|
|
48
|
+
{!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
49
|
<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
50
|
{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>
|
|
51
|
+
{saved&&onReview&&!historical&&!editing[key]?<button type="button" disabled={Boolean(busy)} onClick={()=>setEditing(old=>({...old,[key]:true}))}>Change my choice</button>:null}
|
|
52
|
+
<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
53
|
</article>;
|
|
54
54
|
})}
|
|
55
55
|
</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
|
+
}
|