@anthusai/papyrus 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +64 -0
- package/amplify/data/resource.ts +70 -0
- package/amplify/data/schema.js +64 -0
- package/amplify/data/schema.ts +70 -0
- package/amplify/functions/category-action/handler.ts +91 -9
- package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
- package/amplify/functions/shared/curation-delegation.ts +50 -2
- package/amplify/functions/shared/reference-curation-metadata.ts +31 -0
- package/app/api/auth/prospectus/accounts/route.ts +9 -0
- package/components/administration-delegations-panel.tsx +52 -100
- package/components/curation-delegation-client.ts +46 -23
- package/components/news-desk-taxonomy-client.ts +12 -0
- package/components/newsroom-board-view.tsx +3 -5
- package/components/newsroom-newsprint-board.css +5 -54
- package/components/newsroom-organizer.css +21 -0
- package/components/newsroom-references-view.tsx +47 -5
- package/components/prospectus-account-chooser.tsx +34 -1
- package/components/reference-section-suggestion.tsx +104 -0
- package/components/topic-steering-workspace.tsx +39 -1
- package/lib/curation-delegation-helpers.ts +35 -25
- package/lib/local-preview-section-fixtures.ts +72 -0
- package/lib/prospectus-account-client.ts +26 -0
- package/lib/prospectus-sign-in-routes.ts +45 -0
- package/lib/prospectus-site-identity.ts +13 -1
- package/lib/site-section-suggestions.ts +120 -0
- package/package.json +6 -3
- package/routes.manifest.json +12 -1
- package/components/board-card-excerpts.tsx +0 -61
|
@@ -5,8 +5,8 @@ import Link from "next/link";
|
|
|
5
5
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
6
6
|
import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
|
|
7
7
|
import "@kanbus/ui/styles/kanban.css";
|
|
8
|
+
import "@kanbus/ui/styles/newsprint.css";
|
|
8
9
|
import "./newsroom-newsprint-board.css";
|
|
9
|
-
import { BoardCardExcerpts } from "./board-card-excerpts";
|
|
10
10
|
import { PlusIcon } from "lucide-react";
|
|
11
11
|
import { newsroomHref } from "../lib/newsroom-base-path";
|
|
12
12
|
import {
|
|
@@ -81,7 +81,6 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
|
|
|
81
81
|
const [supportingRecordsError, setSupportingRecordsError] = useState<string | null>(null);
|
|
82
82
|
const generation = useRef(0);
|
|
83
83
|
const boardBodyRef = useRef<HTMLDivElement | null>(null);
|
|
84
|
-
const [boardBodyElement, setBoardBodyElement] = useState<HTMLDivElement | null>(null);
|
|
85
84
|
const [typeFilter, setTypeFilter] = useState<string | null>("all");
|
|
86
85
|
const showsContentItems = subject !== "assignment";
|
|
87
86
|
const contentItemColumnKeyPrefix = `${contentItemBoardType()}/`;
|
|
@@ -173,11 +172,10 @@ export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardS
|
|
|
173
172
|
</Link>
|
|
174
173
|
)}
|
|
175
174
|
</>}>
|
|
176
|
-
<div className="organizer-native-board papyrus-newsprint-board" data-color-mode={theme}>
|
|
175
|
+
<div className="organizer-native-board papyrus-newsprint-board" data-kanbus-theme="newsprint" data-color-mode={theme}>
|
|
177
176
|
{error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
|
|
178
177
|
{subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
|
|
179
|
-
<div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={
|
|
180
|
-
<BoardCardExcerpts excerpts={issues.flatMap(item => item.kind === "contentItem" && item.excerpt ? [{ issueId: item.id, text: item.excerpt }] : [])} scope={boardBodyElement} />
|
|
178
|
+
<div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
|
|
181
179
|
<IssueCollection issues={issues} config={config} query={{ type: subject === "desk" ? issueTypeFromFilter(typeFilter) : boardType }} view={view}
|
|
182
180
|
motion={lowMotion ? STATIC_MOTION : undefined}
|
|
183
181
|
collapsedColumns={columnState.collapsedColumns} onToggleCollapse={columnState.toggleColumn} initialScroll="start"
|
|
@@ -1,56 +1,7 @@
|
|
|
1
1
|
.papyrus-newsprint-board {
|
|
2
|
-
--
|
|
3
|
-
--
|
|
4
|
-
--
|
|
5
|
-
--
|
|
6
|
-
--
|
|
7
|
-
--text-foreground: var(--org-ink);
|
|
8
|
-
--text-muted: var(--org-muted);
|
|
9
|
-
--text-selected: var(--org-ink);
|
|
10
|
-
--font-body: Newsroom UI, system-ui, sans-serif;
|
|
11
|
-
--kb-column-width: 245px;
|
|
12
|
-
--kb-column-radius: 0;
|
|
13
|
-
--kb-card-radius: 0;
|
|
14
|
-
--kb-card-border-width: 0;
|
|
15
|
-
--kb-card-shadow: none;
|
|
16
|
-
--kb-card-selected-shadow: none;
|
|
17
|
-
--kb-board-gap: 18px;
|
|
18
|
-
color: var(--org-ink);
|
|
19
|
-
background: transparent;
|
|
2
|
+
--kb-newsprint-paper: var(--org-paper);
|
|
3
|
+
--kb-newsprint-ink: var(--org-ink);
|
|
4
|
+
--kb-newsprint-line: var(--org-line);
|
|
5
|
+
--kb-newsprint-display-font: Newsroom Display, Georgia, serif;
|
|
6
|
+
--kb-newsprint-body-font: Newsroom UI, system-ui, sans-serif;
|
|
20
7
|
}
|
|
21
|
-
.papyrus-newsprint-board .kb-grid { height: 100%; min-height: 0; scrollbar-width: thin; }
|
|
22
|
-
.papyrus-newsprint-board .kb-column { border-left: 1px solid var(--org-line); padding-left: 10px; }
|
|
23
|
-
.papyrus-newsprint-board .kb-column:first-child { border-left: 0; padding-left: 0; }
|
|
24
|
-
.papyrus-newsprint-board .kb-column-header {
|
|
25
|
-
border-bottom: 2px solid var(--org-ink);
|
|
26
|
-
padding: 5px 0;
|
|
27
|
-
font: 700 12px Newsroom Display, Georgia, serif;
|
|
28
|
-
text-transform: uppercase;
|
|
29
|
-
letter-spacing: .6px;
|
|
30
|
-
min-height: 32px;
|
|
31
|
-
}
|
|
32
|
-
.papyrus-newsprint-board .issue-card {
|
|
33
|
-
--issue-accent: var(--org-paper) !important;
|
|
34
|
-
--issue-accent-muted: var(--muted) !important;
|
|
35
|
-
--issue-accent-foreground: var(--org-ink) !important;
|
|
36
|
-
--issue-priority-bg: var(--org-paper) !important;
|
|
37
|
-
background: transparent !important;
|
|
38
|
-
border-radius: 0;
|
|
39
|
-
border: 0 !important;
|
|
40
|
-
box-shadow: none !important;
|
|
41
|
-
}
|
|
42
|
-
.papyrus-newsprint-board .issue-card + .issue-card { border-top: 1px solid var(--org-line) !important; }
|
|
43
|
-
.papyrus-newsprint-board .issue-accent-bar { border-bottom: 0; background: var(--org-paper) !important; color: var(--org-ink) !important; }
|
|
44
|
-
.papyrus-newsprint-board .issue-accent-id { font: 400 9px Newsroom UI, system-ui, sans-serif; letter-spacing: .8px; white-space: nowrap; color: var(--org-ink) !important; }
|
|
45
|
-
.papyrus-newsprint-board .issue-accent-priority { border-radius: 0; color: var(--org-muted) !important; background: transparent !important; font-size: 8px; }
|
|
46
|
-
.papyrus-newsprint-board .issue-card h3 { font: 700 19px/1.2 Newsroom Display, Georgia, serif !important; letter-spacing: -.2px; }
|
|
47
|
-
.papyrus-newsprint-board .issue-card[data-selected=true] { outline: 2px solid var(--org-ink); outline-offset: -2px; }
|
|
48
|
-
.papyrus-newsprint-board .issue-card[data-selected=true] h3 { color: var(--org-ink) !important; }
|
|
49
|
-
.papyrus-newsprint-board .kb-issue-detail { background: var(--org-paper); border: 1px solid var(--org-line); border-radius: 0; box-shadow: none; }
|
|
50
|
-
.papyrus-newsprint-board .kb-issue-detail h2 { font: 700 28px/1.2 Newsroom Display, Georgia, serif; border-bottom: 3px double var(--org-ink); padding-bottom: 12px; }
|
|
51
|
-
.papyrus-newsprint-board .issue-card .issue-accent-bar { height: auto !important; margin: 0 !important; width: 100% !important; padding: 0 !important; min-height: 0; }
|
|
52
|
-
.papyrus-newsprint-board .issue-card .issue-card-content { padding-top: 2px !important; gap: 4px; }
|
|
53
|
-
.papyrus-newsprint-board .issue-card h3 { margin: 0; }
|
|
54
|
-
.board-card-excerpt p { margin: 0; font-size: 13px; line-height: 1.4; color: var(--org-ink); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
|
|
55
|
-
.board-card-excerpt[data-expanded="true"] p { display: block; -webkit-line-clamp: unset; }
|
|
56
|
-
.board-card-excerpt button { background: none; border: 0; padding: 0; font: inherit; font-size: 12px; text-decoration: underline; text-underline-offset: 3px; color: var(--org-ink); cursor: pointer; }
|
|
@@ -274,6 +274,14 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
274
274
|
.organizer-account-chooser li button small,.organizer-account-chooser-note{font-size:12px;color:var(--org-ink);margin:0}
|
|
275
275
|
.organizer-account-chooser-heading{margin:0;font:600 11px/1.4 Newsroom UI,system-ui,sans-serif;letter-spacing:.07em;text-transform:uppercase}
|
|
276
276
|
.organizer-account-chooser-link{color:var(--org-ink);text-decoration:underline;text-underline-offset:3px}
|
|
277
|
+
.organizer-account-create-start,.organizer-account-create button{background:none;border:1px solid var(--org-ink);border-radius:0;color:var(--org-ink);font:600 12px/1.4 Newsroom UI,system-ui,sans-serif;padding:4px 10px;cursor:pointer;text-underline-offset:3px}
|
|
278
|
+
.organizer-account-create-start:hover,.organizer-account-create button:hover{text-decoration:underline}
|
|
279
|
+
.organizer-account-create-start:focus-visible,.organizer-account-create button:focus-visible,.organizer-account-create input:focus-visible{outline:2px solid var(--org-ink);outline-offset:1px}
|
|
280
|
+
.organizer-account-create button:disabled{cursor:default;text-decoration:line-through}
|
|
281
|
+
.organizer-account-create{display:grid;gap:6px}
|
|
282
|
+
.organizer-account-create label{display:grid;gap:2px;font:600 11px/1.4 Newsroom UI,system-ui,sans-serif;letter-spacing:.07em;text-transform:uppercase}
|
|
283
|
+
.organizer-account-create input{background:transparent;border:0;border-bottom:2px solid var(--org-ink);border-radius:0;color:var(--org-ink);font:400 14px/1.4 Newsroom UI,system-ui,sans-serif;padding:4px 0;text-transform:none;letter-spacing:0}
|
|
284
|
+
.organizer-account-create span{display:flex;gap:8px}
|
|
277
285
|
[data-reference-cyclotron-status] .cyclotron-status[data-variant]{border-radius:0;background:transparent;border-top:1px solid var(--org-ink);border-bottom:1px solid var(--org-ink);padding-left:0;padding-right:0}
|
|
278
286
|
[data-reference-cyclotron-status] :is(.cyclotron-status-note,.cyclotron-status-definition,.cyclotron-status-header p,.cyclotron-status-override){color:var(--org-ink)}
|
|
279
287
|
[data-reference-cyclotron-status] .cyclotron-status[data-variant="strip"]>span+span::before{color:var(--org-ink)}
|
|
@@ -295,4 +303,17 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
295
303
|
.news-desk-columns--administration .news-desk-delegation-list li[data-delegation-live=true] .news-desk-delegation-state{font-weight:700;text-decoration:underline;text-underline-offset:4px}
|
|
296
304
|
.news-desk-columns--administration .news-desk-delegation-state{font:600 12px/1.4 Newsroom UI,system-ui,sans-serif;color:var(--administration-strong)}
|
|
297
305
|
.news-desk-columns--administration .news-desk-delegation-confirm{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
306
|
+
.news-desk-columns--administration .news-desk-delegation-pending-list{list-style:none;margin:0;padding:0;display:grid}
|
|
307
|
+
.news-desk-columns--administration .news-desk-delegation-pending-list li{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:12px 24px;align-items:center;padding:16px 0 16px 12px;border-top:1px solid var(--org-line);border-left:3px solid var(--org-ink)}
|
|
308
|
+
.news-desk-columns--administration .news-desk-delegation-pending-list li>div{display:grid;gap:2px}
|
|
309
|
+
.news-desk-columns--administration .news-desk-delegation-pending-list li>div>strong{font:700 18px/1.2 Newsroom Display,Georgia,serif}
|
|
310
|
+
.news-desk-columns--administration .news-desk-delegation-pending-list li>div>span{font:400 13px/1.4 Newsroom UI,system-ui,sans-serif}
|
|
311
|
+
.news-desk-columns--administration .news-desk-delegation-countdown{font-weight:600!important;font-variant-numeric:tabular-nums}
|
|
312
|
+
.news-desk-columns--administration .news-desk-delegation-code{font:700 34px/1.1 ui-monospace,Menlo,monospace;letter-spacing:.14em;color:var(--org-ink);padding:6px 0;border-top:2px solid var(--org-ink);border-bottom:2px solid var(--org-ink)}
|
|
313
|
+
.news-desk-columns--administration .news-desk-delegation-decision{display:flex;gap:8px}
|
|
314
|
+
.news-desk-columns--administration .news-desk-delegation-decision button{min-height:40px;padding:4px 18px;border:1px solid var(--org-ink);border-radius:0;background:transparent;color:var(--org-ink);font:700 12px/1.2 Newsroom UI,system-ui,sans-serif;letter-spacing:.07em;text-transform:uppercase;cursor:pointer}
|
|
315
|
+
.news-desk-columns--administration .news-desk-delegation-decision button:hover:not(:disabled){text-decoration:underline;text-underline-offset:4px}
|
|
316
|
+
.news-desk-columns--administration .news-desk-delegation-decision button:disabled{cursor:default;text-decoration:line-through}
|
|
317
|
+
.news-desk-columns--administration .news-desk-delegation-decision button:focus-visible{outline:2px solid var(--org-ink);outline-offset:2px}
|
|
318
|
+
@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}}
|
|
298
319
|
@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)}}
|
|
@@ -57,6 +57,11 @@ import {
|
|
|
57
57
|
ReferenceSuggestionRowLine,
|
|
58
58
|
} from "./reference-cyclotron-suggestion";
|
|
59
59
|
import { ReferenceLiveAgreement } from "./reference-live-agreement";
|
|
60
|
+
import { SectionPicker, SectionSuggestionBlock, type SectionChoice } from "./reference-section-suggestion";
|
|
61
|
+
import type { SectionSuggestion, SiteSectionAgreement } from "../lib/site-section-suggestions";
|
|
62
|
+
|
|
63
|
+
/** What an editor adds to an accept: the Section the reference belongs in, which becomes a training label. */
|
|
64
|
+
export type ReferenceReviewOptions = { sectionId?: string | null };
|
|
60
65
|
import type { DelegatedCuration, ShadowAgreement } from "../lib/cyclotron-suggestions";
|
|
61
66
|
import { ReferenceCyclotronPriming, type PrimingFormInput } from "./reference-cyclotron-priming";
|
|
62
67
|
import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
|
|
@@ -91,9 +96,15 @@ type NewsroomReferencesViewProps = {
|
|
|
91
96
|
/** The live-agreement payload; the strip is hidden without it. */
|
|
92
97
|
shadowAgreement?: ShadowAgreement | null;
|
|
93
98
|
disabled?: boolean;
|
|
99
|
+
/** The enabled newsroom Sections an accepted reference can be placed in. */
|
|
100
|
+
sections?: SectionChoice[];
|
|
101
|
+
/** The site cyclotron's suggested Section per reference lineage. Advisory. */
|
|
102
|
+
sectionSuggestions?: Map<string, SectionSuggestion>;
|
|
103
|
+
/** How often the Section suggestions matched later editor decisions. */
|
|
104
|
+
sectionAgreement?: SiteSectionAgreement | null;
|
|
94
105
|
onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
|
|
95
106
|
initialReferenceLineageId?: string | null;
|
|
96
|
-
onReview: (reference: ReferenceRecord, action: ReferenceCurationAction) => void;
|
|
107
|
+
onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => void;
|
|
97
108
|
referenceAttachments?: ReferenceAttachmentRecord[];
|
|
98
109
|
references: ReferenceRecord[];
|
|
99
110
|
};
|
|
@@ -243,6 +254,9 @@ function ReferenceDetailPanel({
|
|
|
243
254
|
previewAttachments,
|
|
244
255
|
reference,
|
|
245
256
|
onClose,
|
|
257
|
+
sectionAgreement = null,
|
|
258
|
+
sectionSuggestion,
|
|
259
|
+
sections = [],
|
|
246
260
|
}: {
|
|
247
261
|
countsAsTrainingLabel?: boolean;
|
|
248
262
|
delegated?: DelegatedCuration;
|
|
@@ -250,12 +264,17 @@ function ReferenceDetailPanel({
|
|
|
250
264
|
demo?: boolean;
|
|
251
265
|
disabled?: boolean;
|
|
252
266
|
onRelevanceReview?: (curation: RelevanceCuration) => void;
|
|
253
|
-
onReview: (action: ReferenceCurationAction) => void;
|
|
267
|
+
onReview: (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => void;
|
|
268
|
+
sectionAgreement?: SiteSectionAgreement | null;
|
|
269
|
+
sectionSuggestion?: SectionSuggestion;
|
|
270
|
+
sections?: SectionChoice[];
|
|
254
271
|
previewAttachments: ReferenceAttachmentRecord[];
|
|
255
272
|
reference: ReferenceRecord;
|
|
256
273
|
onClose?: () => void;
|
|
257
274
|
}) {
|
|
258
275
|
const lineageId = referenceLineageId(reference);
|
|
276
|
+
const [chosenSectionId, setChosenSectionId] = useState<string | null>(null);
|
|
277
|
+
useEffect(() => { setChosenSectionId(null); }, [lineageId]);
|
|
259
278
|
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
260
279
|
const url = referenceUrl(reference);
|
|
261
280
|
const acceptDisabled = disabled || status === "accepted";
|
|
@@ -307,6 +326,20 @@ function ReferenceDetailPanel({
|
|
|
307
326
|
) : null}
|
|
308
327
|
</div>
|
|
309
328
|
) : null}
|
|
329
|
+
{status === "pending" && sectionSuggestion ? (
|
|
330
|
+
<SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} />
|
|
331
|
+
) : null}
|
|
332
|
+
{status === "pending" && sections.length ? (
|
|
333
|
+
<SectionPicker
|
|
334
|
+
agreement={sectionAgreement}
|
|
335
|
+
chosenSectionId={chosenSectionId}
|
|
336
|
+
disabled={disabled}
|
|
337
|
+
onChoose={setChosenSectionId}
|
|
338
|
+
referenceLineageId={lineageId}
|
|
339
|
+
sections={sections}
|
|
340
|
+
suggestedSectionId={sectionSuggestion?.sectionId ?? null}
|
|
341
|
+
/>
|
|
342
|
+
) : null}
|
|
310
343
|
{delegated ? <DelegatedCurationNote delegated={delegated} referenceLineageId={lineageId} /> : countsAsTrainingLabel ? <CountsAsTrainingLabelNote referenceLineageId={lineageId} /> : null}
|
|
311
344
|
|
|
312
345
|
<div
|
|
@@ -319,7 +352,7 @@ function ReferenceDetailPanel({
|
|
|
319
352
|
aria-pressed={status === "accepted"}
|
|
320
353
|
data-news-desk-reference-accept
|
|
321
354
|
disabled={acceptDisabled}
|
|
322
|
-
onClick={() => onReview("accept")}
|
|
355
|
+
onClick={() => onReview("accept", chosenSectionId ? { sectionId: chosenSectionId } : undefined)}
|
|
323
356
|
size="sm"
|
|
324
357
|
type="button"
|
|
325
358
|
variant={status === "accepted" ? "default" : "outline"}
|
|
@@ -443,6 +476,9 @@ export function NewsroomReferencesView({
|
|
|
443
476
|
demo = false,
|
|
444
477
|
disabled = false,
|
|
445
478
|
onRelevanceReview,
|
|
479
|
+
sections = [],
|
|
480
|
+
sectionSuggestions,
|
|
481
|
+
sectionAgreement = null,
|
|
446
482
|
initialReferenceLineageId = null,
|
|
447
483
|
onReview,
|
|
448
484
|
referenceAttachments = [],
|
|
@@ -531,9 +567,9 @@ export function NewsroomReferencesView({
|
|
|
531
567
|
}
|
|
532
568
|
};
|
|
533
569
|
|
|
534
|
-
const runReview = (action: ReferenceCurationAction) => {
|
|
570
|
+
const runReview = (action: ReferenceCurationAction, options?: ReferenceReviewOptions) => {
|
|
535
571
|
if (!selectedReference) return;
|
|
536
|
-
onReview(selectedReference, action);
|
|
572
|
+
onReview(selectedReference, action, options);
|
|
537
573
|
};
|
|
538
574
|
|
|
539
575
|
const emptyLabel = canonicalReferences.length
|
|
@@ -688,6 +724,9 @@ export function NewsroomReferencesView({
|
|
|
688
724
|
disabled={disabled}
|
|
689
725
|
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
690
726
|
onReview={runReview}
|
|
727
|
+
sectionAgreement={sectionAgreement}
|
|
728
|
+
sectionSuggestion={sectionSuggestions?.get(referenceLineageId(selectedReference))}
|
|
729
|
+
sections={sections}
|
|
691
730
|
previewAttachments={previewAttachments}
|
|
692
731
|
reference={selectedReference}
|
|
693
732
|
/>
|
|
@@ -722,6 +761,9 @@ export function NewsroomReferencesView({
|
|
|
722
761
|
onClose={closeDetail}
|
|
723
762
|
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
724
763
|
onReview={runReview}
|
|
764
|
+
sectionAgreement={sectionAgreement}
|
|
765
|
+
sectionSuggestion={sectionSuggestions?.get(referenceLineageId(selectedReference))}
|
|
766
|
+
sections={sections}
|
|
725
767
|
previewAttachments={previewAttachments}
|
|
726
768
|
reference={selectedReference}
|
|
727
769
|
/>
|
|
@@ -4,9 +4,15 @@ import { useEffect, useState } from "react";
|
|
|
4
4
|
import { Check } from "lucide-react";
|
|
5
5
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
6
6
|
import { LOCAL_PREVIEW_TENANTS, getLocalPreviewTenantId, setLocalPreviewTenantId } from "../lib/local-preview-tenant";
|
|
7
|
-
import { loadProspectusAccountState, selectProspectusAccount, type ProspectusAccountState } from "../lib/prospectus-account-client";
|
|
7
|
+
import { createProspectusAccount, loadProspectusAccountState, selectProspectusAccount, type ProspectusAccountState } from "../lib/prospectus-account-client";
|
|
8
|
+
|
|
9
|
+
/** Local preview: add ?no-accounts=1 to the address to see a signed-in person with no publishing account. */
|
|
10
|
+
function previewHasNoAccounts(): boolean {
|
|
11
|
+
return typeof window !== "undefined" && new URLSearchParams(window.location.search).has("no-accounts");
|
|
12
|
+
}
|
|
8
13
|
|
|
9
14
|
function previewAccountState(): ProspectusAccountState {
|
|
15
|
+
if (previewHasNoAccounts()) return { status: "signedIn", publishingAccounts: [], selectedAccountId: null, cyclotronLabAccess: false };
|
|
10
16
|
return {
|
|
11
17
|
status: "signedIn",
|
|
12
18
|
publishingAccounts: LOCAL_PREVIEW_TENANTS.map(tenant => ({ accountId: tenant.accountId, name: tenant.name, role: tenant.role })),
|
|
@@ -50,6 +56,8 @@ export function ProspectusAccountChooser() {
|
|
|
50
56
|
const [state, setState] = useState<ProspectusAccountState>({ status: "unavailable" });
|
|
51
57
|
const [error, setError] = useState<string | null>(null);
|
|
52
58
|
const [pending, setPending] = useState<string | null>(null);
|
|
59
|
+
const [creating, setCreating] = useState(false);
|
|
60
|
+
const [newName, setNewName] = useState("");
|
|
53
61
|
useEffect(() => {
|
|
54
62
|
let cancelled = false;
|
|
55
63
|
const returnPath = `${window.location.pathname}${window.location.search}`;
|
|
@@ -76,6 +84,20 @@ export function ProspectusAccountChooser() {
|
|
|
76
84
|
if ("error" in result) { setError(result.error); return; }
|
|
77
85
|
window.location.reload();
|
|
78
86
|
};
|
|
87
|
+
const create = async () => {
|
|
88
|
+
if (pending) return;
|
|
89
|
+
setPending("create"); setError(null);
|
|
90
|
+
if (isLocalPreviewEnabled()) {
|
|
91
|
+
const name = newName.trim() || "New publishing account";
|
|
92
|
+
setState({ status: "signedIn", publishingAccounts: [{ accountId: "preview-created-account", name, role: "owner" }], selectedAccountId: "preview-created-account", cyclotronLabAccess: false });
|
|
93
|
+
setPending(null); setCreating(false);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const result = await createProspectusAccount(newName);
|
|
97
|
+
setPending(null);
|
|
98
|
+
if ("error" in result) { setError(result.error); return; }
|
|
99
|
+
window.location.reload();
|
|
100
|
+
};
|
|
79
101
|
return <div className="organizer-account-chooser" data-prospectus-account-chooser="signed-in">
|
|
80
102
|
<p className="organizer-account-chooser-heading">Publishing account</p>
|
|
81
103
|
{state.publishingAccounts.length === 0
|
|
@@ -91,6 +113,17 @@ export function ProspectusAccountChooser() {
|
|
|
91
113
|
</li>;
|
|
92
114
|
})}
|
|
93
115
|
</ul>}
|
|
116
|
+
{state.publishingAccounts.length === 0 ? (
|
|
117
|
+
creating
|
|
118
|
+
? <form className="organizer-account-create" data-prospectus-create-account onSubmit={event => { event.preventDefault(); void create(); }}>
|
|
119
|
+
<label>Account name<input autoFocus disabled={pending !== null} maxLength={80} onChange={event => setNewName(event.target.value)} placeholder="Optional" type="text" value={newName} /></label>
|
|
120
|
+
<span>
|
|
121
|
+
<button data-prospectus-create-account-submit disabled={pending !== null} type="submit">{pending === "create" ? "Creating…" : "Create account"}</button>
|
|
122
|
+
<button disabled={pending !== null} onClick={() => setCreating(false)} type="button">Cancel</button>
|
|
123
|
+
</span>
|
|
124
|
+
</form>
|
|
125
|
+
: <button className="organizer-account-create-start" data-prospectus-create-account-start onClick={() => setCreating(true)} type="button">Create publishing account</button>
|
|
126
|
+
) : null}
|
|
94
127
|
{!state.selectedAccountId && state.publishingAccounts.length > 0 ? <p className="organizer-account-chooser-note">Choose the account you are working as.</p> : null}
|
|
95
128
|
{state.cyclotronLabAccess ? <p className="organizer-account-chooser-note" data-cyclotron-lab-access>Cyclotron lab access is on for you.</p> : null}
|
|
96
129
|
{error ? <p className="organizer-account-chooser-note" role="alert">{error}</p> : null}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { formatPercentage } from "../lib/cyclotron-suggestions";
|
|
4
|
+
import { sectionAgreementIsMeasured, type SectionAgreement, type SectionSuggestion, type SiteSectionAgreement } from "../lib/site-section-suggestions";
|
|
5
|
+
import { MINIMUM_COMPARISONS_FOR_AGREEMENT } from "../lib/cyclotron-suggestions";
|
|
6
|
+
|
|
7
|
+
export type SectionChoice = { id: string; title: string };
|
|
8
|
+
|
|
9
|
+
function titleOf(sections: SectionChoice[], sectionId: string): string {
|
|
10
|
+
return sections.find((section) => section.id === sectionId)?.title ?? sectionId;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function agreementWords(agreement: SectionAgreement | undefined): string {
|
|
14
|
+
if (!agreement) return "no editor decisions compared yet";
|
|
15
|
+
if (!sectionAgreementIsMeasured(agreement)) {
|
|
16
|
+
return `${agreement.comparisons} of ${MINIMUM_COMPARISONS_FOR_AGREEMENT} editor decisions compared, too few to measure`;
|
|
17
|
+
}
|
|
18
|
+
return `agreement ${formatPercentage(agreement.agreement)}, ${agreement.comparisons} editor decisions compared`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The suggested Section for a pending reference, how likely the other Sections
|
|
23
|
+
* are, and how often this Section's suggestions matched editors. Advisory: no
|
|
24
|
+
* Section is chosen until an editor picks one.
|
|
25
|
+
*/
|
|
26
|
+
export function SectionSuggestionBlock({
|
|
27
|
+
agreement,
|
|
28
|
+
referenceLineageId,
|
|
29
|
+
sections,
|
|
30
|
+
suggestion,
|
|
31
|
+
}: {
|
|
32
|
+
agreement: SiteSectionAgreement | null;
|
|
33
|
+
referenceLineageId: string;
|
|
34
|
+
sections: SectionChoice[];
|
|
35
|
+
suggestion: SectionSuggestion;
|
|
36
|
+
}) {
|
|
37
|
+
const figures = agreement?.sections.find((entry) => entry.sectionId === suggestion.sectionId);
|
|
38
|
+
const others = suggestion.probabilities.filter((entry) => entry.sectionId !== suggestion.sectionId).slice(0, 3);
|
|
39
|
+
return (
|
|
40
|
+
<section aria-label="Suggested Section" className="space-y-2 border-y border-foreground py-3" data-reference-section-suggestion={referenceLineageId}>
|
|
41
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-foreground">Suggested Section</h3>
|
|
42
|
+
<p className="text-sm text-foreground">
|
|
43
|
+
The cyclotron suggests <span className="font-semibold">{titleOf(sections, suggestion.sectionId)}</span>
|
|
44
|
+
{suggestion.confidence !== null ? ` (${formatPercentage(suggestion.confidence)})` : ""}.
|
|
45
|
+
</p>
|
|
46
|
+
{others.length ? (
|
|
47
|
+
<p className="text-xs text-foreground" data-reference-section-alternatives>
|
|
48
|
+
Next most likely: {others.map((entry) => `${titleOf(sections, entry.sectionId)} ${formatPercentage(entry.probability)}`).join(", ")}.
|
|
49
|
+
</p>
|
|
50
|
+
) : null}
|
|
51
|
+
<p className="text-xs text-foreground" data-reference-section-agreement>
|
|
52
|
+
For {titleOf(sections, suggestion.sectionId)}: {agreementWords(figures)}.
|
|
53
|
+
</p>
|
|
54
|
+
<p className="text-xs text-foreground" data-reference-section-notice>
|
|
55
|
+
A suggestion never places a reference. The Section you choose when you accept does.
|
|
56
|
+
</p>
|
|
57
|
+
</section>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Picks the Section an accepted reference belongs to. The choice is sent with
|
|
63
|
+
* the accept and is what the cyclotron learns from; nothing is preselected.
|
|
64
|
+
*/
|
|
65
|
+
export function SectionPicker({
|
|
66
|
+
agreement,
|
|
67
|
+
chosenSectionId,
|
|
68
|
+
disabled,
|
|
69
|
+
onChoose,
|
|
70
|
+
referenceLineageId,
|
|
71
|
+
sections,
|
|
72
|
+
suggestedSectionId,
|
|
73
|
+
}: {
|
|
74
|
+
agreement: SiteSectionAgreement | null;
|
|
75
|
+
chosenSectionId: string | null;
|
|
76
|
+
disabled?: boolean;
|
|
77
|
+
onChoose: (sectionId: string | null) => void;
|
|
78
|
+
referenceLineageId: string;
|
|
79
|
+
sections: SectionChoice[];
|
|
80
|
+
suggestedSectionId: string | null;
|
|
81
|
+
}) {
|
|
82
|
+
return (
|
|
83
|
+
<fieldset className="space-y-1 border-l-2 border-foreground pl-3" data-reference-section-picker={referenceLineageId} disabled={disabled}>
|
|
84
|
+
<legend className="text-xs font-semibold uppercase tracking-wide text-foreground">Section when you accept</legend>
|
|
85
|
+
<label className="flex items-center gap-2 text-sm text-foreground">
|
|
86
|
+
<input checked={chosenSectionId === null} name={`section-${referenceLineageId}`} onChange={() => onChoose(null)} type="radio" />
|
|
87
|
+
<span className={chosenSectionId === null ? "font-semibold" : undefined}>No Section</span>
|
|
88
|
+
</label>
|
|
89
|
+
{sections.map((section) => {
|
|
90
|
+
const figures = agreement?.sections.find((entry) => entry.sectionId === section.id);
|
|
91
|
+
const chosen = chosenSectionId === section.id;
|
|
92
|
+
return (
|
|
93
|
+
<label className="flex flex-wrap items-center gap-x-2 text-sm text-foreground" key={section.id}>
|
|
94
|
+
<input checked={chosen} name={`section-${referenceLineageId}`} onChange={() => onChoose(section.id)} type="radio" value={section.id} />
|
|
95
|
+
<span className={chosen ? "font-semibold underline underline-offset-4" : undefined}>{section.title}</span>
|
|
96
|
+
{section.id === suggestedSectionId ? <span className="text-xs">suggested</span> : null}
|
|
97
|
+
{sectionAgreementIsMeasured(figures) ? <span className="text-xs">agreement {formatPercentage(figures?.agreement ?? null)}</span> : null}
|
|
98
|
+
</label>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
<p className="text-xs text-foreground">The Section you choose is a training label for the cyclotron.</p>
|
|
102
|
+
</fieldset>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -56,6 +56,7 @@ import {
|
|
|
56
56
|
type NewsroomRecordPage,
|
|
57
57
|
loadCyclotronStatus,
|
|
58
58
|
loadCyclotronShadowAgreement,
|
|
59
|
+
loadSiteSectionAgreement,
|
|
59
60
|
requestCyclotronPriming,
|
|
60
61
|
loadCyclotronPrimingRequests,
|
|
61
62
|
requestCyclotronReviewRate,
|
|
@@ -162,6 +163,8 @@ import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
|
162
163
|
import { currentRelevanceDecisions } from "../lib/relevance-decisions";
|
|
163
164
|
import { delegatedCurationsByReference, referencesWithHumanLatestCuration, type ShadowAgreement } from "../lib/cyclotron-suggestions";
|
|
164
165
|
import { createLocalPreviewCyclotronFixtures } from "../lib/local-preview-seed";
|
|
166
|
+
import { createLocalPreviewSectionFixtures } from "../lib/local-preview-section-fixtures";
|
|
167
|
+
import { currentSectionSuggestions, siteCyclotronIdOf, type SiteSectionAgreement } from "../lib/site-section-suggestions";
|
|
165
168
|
import {
|
|
166
169
|
buildPrimingRequest,
|
|
167
170
|
buildReviewRateRequest,
|
|
@@ -862,6 +865,35 @@ function NewsDeskDashboard({
|
|
|
862
865
|
[dashboard.newsroomSections],
|
|
863
866
|
);
|
|
864
867
|
const [newsroomSections, setNewsroomSections] = useState(fallbackNewsroomSections);
|
|
868
|
+
const sectionChoices = useMemo(
|
|
869
|
+
() => newsroomSections
|
|
870
|
+
.filter((section) => section.enabled !== false && section.enabledStatus !== "disabled")
|
|
871
|
+
.sort((left, right) => left.sortOrder - right.sortOrder)
|
|
872
|
+
.map((section) => ({ id: section.id, title: section.title })),
|
|
873
|
+
[newsroomSections],
|
|
874
|
+
);
|
|
875
|
+
const sectionPreviewFixtures = useMemo(
|
|
876
|
+
() => (isLocalPreviewEnabled() ? createLocalPreviewSectionFixtures(references, sectionChoices.map((section) => section.id)) : null),
|
|
877
|
+
[references, sectionChoices],
|
|
878
|
+
);
|
|
879
|
+
const sectionSuggestions = useMemo(
|
|
880
|
+
() => currentSectionSuggestions(sectionPreviewFixtures ? [...semanticRelations, ...sectionPreviewFixtures.relations] : semanticRelations),
|
|
881
|
+
[sectionPreviewFixtures, semanticRelations],
|
|
882
|
+
);
|
|
883
|
+
const siteCyclotronId = useMemo(() => siteCyclotronIdOf(sectionSuggestions), [sectionSuggestions]);
|
|
884
|
+
const [siteSectionAgreement, setSiteSectionAgreement] = useState<SiteSectionAgreement | null>(null);
|
|
885
|
+
useEffect(() => {
|
|
886
|
+
if (sectionPreviewFixtures) {
|
|
887
|
+
setSiteSectionAgreement(sectionPreviewFixtures.agreement);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (!siteCyclotronId || dashboard.isDemo) return;
|
|
891
|
+
let cancelled = false;
|
|
892
|
+
loadSiteSectionAgreement(siteCyclotronId)
|
|
893
|
+
.then((loaded) => { if (!cancelled) setSiteSectionAgreement(loaded); })
|
|
894
|
+
.catch(() => { if (!cancelled) setSiteSectionAgreement(null); });
|
|
895
|
+
return () => { cancelled = true; };
|
|
896
|
+
}, [dashboard.isDemo, sectionPreviewFixtures, siteCyclotronId]);
|
|
865
897
|
const [procedureDefinitions, setProcedureDefinitions] = useState(dashboard.procedureDefinitions ?? []);
|
|
866
898
|
const [procedureVersions, setProcedureVersions] = useState(dashboard.procedureVersions ?? []);
|
|
867
899
|
const [procedureRuns, setProcedureRuns] = useState(dashboard.procedureRuns ?? []);
|
|
@@ -1586,6 +1618,7 @@ function NewsDeskDashboard({
|
|
|
1586
1618
|
note?: string,
|
|
1587
1619
|
reasonCode?: ReferenceRejectionReasonCode | null,
|
|
1588
1620
|
relevance?: { decisionRelationId: string; shareable: boolean },
|
|
1621
|
+
sectionId?: string | null,
|
|
1589
1622
|
) {
|
|
1590
1623
|
const nextStatus = referenceCurationStatusForAction(action);
|
|
1591
1624
|
setActionState({ id: reference.id, message: `${action} pending`, tone: "pending" });
|
|
@@ -1615,6 +1648,7 @@ function NewsDeskDashboard({
|
|
|
1615
1648
|
curationStatus: nextStatus,
|
|
1616
1649
|
reasonCode: action === "reject" ? reasonCode ?? null : null,
|
|
1617
1650
|
...(relevance ? { decisionRelationId: relevance.decisionRelationId, shareable: relevance.shareable } : {}),
|
|
1651
|
+
...(action === "accept" && sectionId ? { sectionId } : {}),
|
|
1618
1652
|
},
|
|
1619
1653
|
}, ...current]);
|
|
1620
1654
|
setSemanticRelations((current) => [{
|
|
@@ -1657,6 +1691,7 @@ function NewsDeskDashboard({
|
|
|
1657
1691
|
reasonCode: action === "reject" ? reasonCode ?? undefined : undefined,
|
|
1658
1692
|
decisionRelationId: relevance?.decisionRelationId,
|
|
1659
1693
|
shareable: relevance ? relevance.shareable : undefined,
|
|
1694
|
+
sectionId: action === "accept" ? sectionId ?? undefined : undefined,
|
|
1660
1695
|
},
|
|
1661
1696
|
{ authMode: USER_POOL_AUTH_MODE },
|
|
1662
1697
|
);
|
|
@@ -3265,7 +3300,10 @@ function NewsDeskDashboard({
|
|
|
3265
3300
|
normalizeReferenceRejectionReasonCode(curation.reasonCode),
|
|
3266
3301
|
{ decisionRelationId: curation.decisionRelationId, shareable: curation.shareable },
|
|
3267
3302
|
)}
|
|
3268
|
-
|
|
3303
|
+
sections={sectionChoices}
|
|
3304
|
+
sectionSuggestions={sectionSuggestions}
|
|
3305
|
+
sectionAgreement={siteSectionAgreement}
|
|
3306
|
+
onReview={(reference, action, options) => runReferenceCurationAction(reference, action, undefined, undefined, undefined, options?.sectionId)}
|
|
3269
3307
|
referenceAttachments={referenceAttachments}
|
|
3270
3308
|
references={references}
|
|
3271
3309
|
/>
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
export
|
|
2
|
-
export const MINIMUM_DELEGATION_HOURS = 1;
|
|
3
|
-
export const MAXIMUM_DELEGATION_HOURS = 72;
|
|
1
|
+
export type CurationDelegationState = "pending" | "approved" | "denied" | "expired" | "revoked";
|
|
4
2
|
|
|
5
3
|
export type CurationDelegationRecord = {
|
|
6
4
|
delegationId: string;
|
|
@@ -8,25 +6,15 @@ export type CurationDelegationRecord = {
|
|
|
8
6
|
grantorLabel: string | null;
|
|
9
7
|
expiresAt: string;
|
|
10
8
|
revokedAt: string | null;
|
|
9
|
+
state?: CurationDelegationState | null;
|
|
10
|
+
/** XXXX-XXXX, present only while the request is pending. */
|
|
11
|
+
userCode?: string | null;
|
|
12
|
+
requestedHours?: number | null;
|
|
13
|
+
pendingExpiresAt?: string | null;
|
|
14
|
+
approvedAt?: string | null;
|
|
15
|
+
deniedAt?: string | null;
|
|
11
16
|
};
|
|
12
17
|
|
|
13
|
-
export type DelegationInputValidation =
|
|
14
|
-
| { ok: true; agentLabel: string; hours: number }
|
|
15
|
-
| { ok: false; message: string };
|
|
16
|
-
|
|
17
|
-
export function validateDelegationInput(agentLabelText: string, hoursText: string): DelegationInputValidation {
|
|
18
|
-
const agentLabel = agentLabelText.trim();
|
|
19
|
-
if (!agentLabel) return { ok: false, message: "Enter the name of the agent you are trusting." };
|
|
20
|
-
if (/\s/.test(agentLabel)) return { ok: false, message: "The agent name cannot contain spaces. Use the name the agent passes as --agent." };
|
|
21
|
-
const trimmedHours = hoursText.trim();
|
|
22
|
-
if (!/^\d+$/.test(trimmedHours)) return { ok: false, message: `Enter a whole number of hours from ${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS}.` };
|
|
23
|
-
const hours = Number(trimmedHours);
|
|
24
|
-
if (hours < MINIMUM_DELEGATION_HOURS || hours > MAXIMUM_DELEGATION_HOURS) {
|
|
25
|
-
return { ok: false, message: `A delegation lasts from ${MINIMUM_DELEGATION_HOURS} to ${MAXIMUM_DELEGATION_HOURS} hours.` };
|
|
26
|
-
}
|
|
27
|
-
return { ok: true, agentLabel, hours };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
18
|
export function delegationExpiryText(expiresAt: string, revokedAt: string | null, nowMilliseconds: number): string {
|
|
31
19
|
if (revokedAt) return "Revoked";
|
|
32
20
|
const remaining = Date.parse(expiresAt) - nowMilliseconds;
|
|
@@ -41,11 +29,6 @@ export function isDelegationActive(record: CurationDelegationRecord, nowMillisec
|
|
|
41
29
|
return !record.revokedAt && Date.parse(record.expiresAt) > nowMilliseconds;
|
|
42
30
|
}
|
|
43
31
|
|
|
44
|
-
export function buildDelegatedCommandLine(input: { email: string; agentLabel: string; token: string; action?: "approve" | "reject" }): string {
|
|
45
|
-
const email = input.email.trim() || "<your-email>";
|
|
46
|
-
return `papyrus references ${input.action ?? "approve"} <reference-id> --on-behalf-of ${email} --agent ${input.agentLabel} --instruction "<the person's own words>" --delegation-token ${input.token}`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
32
|
export function describeDelegationError(error: unknown): string {
|
|
50
33
|
const raw = error instanceof Error ? error.message : typeof error === "string" ? error : "";
|
|
51
34
|
const graphql = (error as { errors?: Array<{ message?: string }> } | null)?.errors?.[0]?.message ?? "";
|
|
@@ -57,3 +40,30 @@ export function describeDelegationError(error: unknown): string {
|
|
|
57
40
|
const detail = (graphql || raw).trim();
|
|
58
41
|
return detail ? `The server refused the request: ${detail}` : "Something went wrong. Nothing was changed.";
|
|
59
42
|
}
|
|
43
|
+
|
|
44
|
+
/** A pending request that an agent started and nobody has approved, denied or let lapse. */
|
|
45
|
+
export function isPendingDelegation(record: CurationDelegationRecord, nowMilliseconds: number): boolean {
|
|
46
|
+
return record.state === "pending" && Boolean(record.userCode) && Date.parse(record.pendingExpiresAt ?? record.expiresAt) > nowMilliseconds;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** "9:41 left" for a request that is still open, "Expired" once its window has passed. */
|
|
50
|
+
export function pendingCountdownText(pendingExpiresAt: string | null | undefined, nowMilliseconds: number): string {
|
|
51
|
+
const remaining = Date.parse(pendingExpiresAt ?? "") - nowMilliseconds;
|
|
52
|
+
if (!Number.isFinite(remaining) || remaining <= 0) return "Expired";
|
|
53
|
+
const totalSeconds = Math.ceil(remaining / 1000);
|
|
54
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
55
|
+
const seconds = totalSeconds % 60;
|
|
56
|
+
return `${minutes}:${String(seconds).padStart(2, "0")} left`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The state of a decided or lapsed delegation in words an editor reads in the list. */
|
|
60
|
+
export function delegationStateText(record: CurationDelegationRecord, nowMilliseconds: number): string {
|
|
61
|
+
if (record.state === "denied") return "Denied";
|
|
62
|
+
if (record.state === "pending") return pendingCountdownText(record.pendingExpiresAt ?? record.expiresAt, nowMilliseconds) === "Expired" ? "Request expired" : "Waiting for a decision";
|
|
63
|
+
return delegationExpiryText(record.expiresAt, record.revokedAt, nowMilliseconds);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function requestedHoursText(hours: number | null | undefined): string {
|
|
67
|
+
if (!hours || hours <= 0) return "Duration not stated";
|
|
68
|
+
return `Asks for ${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
69
|
+
}
|