@anthusai/papyrus 1.2.0 → 1.4.0-next.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/app/newsroom/[section]/page.tsx +6 -0
- package/app/newsroom/articles/page.tsx +3 -0
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-taxonomy-client.ts +133 -2
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-board-view.tsx +74 -50
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-organizer-shell.tsx +12 -7
- package/components/newsroom-organizer.css +4 -3
- package/components/newsroom-references-view.tsx +18 -0
- package/components/reference-cyclotron-priming.tsx +85 -0
- package/components/topic-steering-workspace.tsx +35 -1
- package/lib/article-flow.ts +95 -0
- package/lib/article-frame-solver.ts +519 -0
- package/lib/chrome-text.ts +504 -0
- package/lib/content-item-lifecycle.ts +25 -26
- package/lib/front-mosaic.ts +1132 -0
- package/lib/furniture.ts +385 -0
- package/lib/labels.ts +37 -0
- package/lib/layout-config.ts +237 -0
- package/lib/layout-plan.ts +37 -9
- package/lib/layout-scenarios.ts +21 -11
- package/lib/layout-types.ts +457 -0
- package/lib/local-preview-seed.ts +97 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-board.ts +63 -62
- package/lib/newsroom-organizer-nav.ts +3 -4
- package/lib/pretext-layout.ts +22 -0
- package/lib/relevance-status.ts +73 -0
- package/lib/site-brand.ts +15 -1
- package/lib/text-columns.ts +251 -0
- package/package.json +13 -2
- package/renderers/pretext/presentation-shell.tsx +1 -1
- package/routes.manifest.json +1 -9
- package/app/newsroom/work/page.tsx +0 -10
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveActiveSiteBrand } from "../../../lib/site-brand-tenant";
|
|
2
2
|
import { notFound, redirect } from "next/navigation";
|
|
3
3
|
import { toPublicNewsroomPath } from "../../../lib/newsroom-base-path";
|
|
4
|
+
import { NewsroomBoardView } from "../../../components/newsroom-board-view";
|
|
4
5
|
import { NewsDeskPage } from "../../../components/news-desk-page";
|
|
5
6
|
import type { NewsDeskTab } from "../../../components/topic-steering-workspace";
|
|
6
7
|
|
|
@@ -42,9 +43,14 @@ const NEWS_DESK_ROUTE_SECTIONS = new Set<NewsDeskTab>([
|
|
|
42
43
|
|
|
43
44
|
export default async function NewsDeskSectionPage({ params, searchParams }: NewsDeskSectionPageProps) {
|
|
44
45
|
const { section } = await params;
|
|
46
|
+
if (section === "work") redirect(toPublicNewsroomPath("/newsroom/articles"));
|
|
45
47
|
if (section === "users") redirect(toPublicNewsroomPath("/newsroom/administration/users"));
|
|
46
48
|
if (section === "doctrine") redirect(toPublicNewsroomPath("/newsroom/administration/policies"));
|
|
47
49
|
if (section === "desks" && resolveActiveSiteBrand().newsroomLayout !== "organizer") redirect(toPublicNewsroomPath("/newsroom/topics"));
|
|
48
50
|
if (!NEWS_DESK_ROUTE_SECTIONS.has(section as NewsDeskTab)) notFound();
|
|
51
|
+
if (section === "assignments" && resolveActiveSiteBrand().newsroomLayout === "organizer") {
|
|
52
|
+
const resolved = await searchParams;
|
|
53
|
+
if (!resolved?.assignment && resolved?.demo !== "1") return <NewsroomBoardView subject="assignment" />;
|
|
54
|
+
}
|
|
49
55
|
return <NewsDeskPage section={section} searchParams={searchParams} />;
|
|
50
56
|
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { NewsroomBoardView } from "../../../components/newsroom-board-view";
|
|
2
|
+
import { resolveActiveSiteBrand } from "../../../lib/site-brand-tenant";
|
|
1
3
|
import { NewsroomArticlesView } from "../../../components/newsroom-articles-view";
|
|
2
4
|
|
|
3
5
|
export const dynamic = "force-dynamic";
|
|
@@ -9,5 +11,6 @@ type NewsroomArticlesPageProps = {
|
|
|
9
11
|
export default async function NewsroomArticlesPage({ searchParams }: NewsroomArticlesPageProps) {
|
|
10
12
|
const resolved = await searchParams;
|
|
11
13
|
const demoValue = Array.isArray(resolved?.demo) ? resolved?.demo[0] : resolved?.demo;
|
|
14
|
+
if (resolveActiveSiteBrand().newsroomLayout === "organizer" && demoValue !== "1") return <NewsroomBoardView subject="contentItem" />;
|
|
12
15
|
return <NewsroomArticlesView demo={demoValue === "1"} />;
|
|
13
16
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { Hub } from "aws-amplify/utils";
|
|
4
4
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
5
5
|
import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
6
|
+
import type { CategorySteeringDashboard } from "../lib/category-repository";
|
|
6
7
|
import { ensureDevSandboxEditorSession } from "./dev-sandbox-editor-sign-in";
|
|
7
8
|
import {
|
|
8
9
|
loadEditorAssignmentsData,
|
|
@@ -46,7 +47,12 @@ const NewsDeskClientContext = createContext<NewsDeskClientContextValue | null>(n
|
|
|
46
47
|
export function NewsDeskClientProvider({
|
|
47
48
|
children,
|
|
48
49
|
devSandboxEditorAuth = null,
|
|
49
|
-
|
|
50
|
+
localPreviewDashboard = null,
|
|
51
|
+
}: Readonly<{
|
|
52
|
+
children: React.ReactNode;
|
|
53
|
+
devSandboxEditorAuth?: DevSandboxEditorAuth | null;
|
|
54
|
+
localPreviewDashboard?: CategorySteeringDashboard | null;
|
|
55
|
+
}>) {
|
|
50
56
|
const [shell, setShell] = useState<NewsDeskShellState>(() => createInitialNewsDeskShellState());
|
|
51
57
|
const [searchTransition, setSearchTransition] = useState<NewsDeskSearchTransition | null>(null);
|
|
52
58
|
const shellRef = useRef(shell);
|
|
@@ -58,6 +64,15 @@ export function NewsDeskClientProvider({
|
|
|
58
64
|
|
|
59
65
|
const refreshDashboard = useCallback(async () => {
|
|
60
66
|
const sequence = ++bootstrapSequenceRef.current;
|
|
67
|
+
if (localPreviewDashboard) {
|
|
68
|
+
setShell((current) => resolveDashboardReady(
|
|
69
|
+
current,
|
|
70
|
+
localPreviewDashboard,
|
|
71
|
+
{ status: "signedIn", label: "Local preview editor", email: null },
|
|
72
|
+
new Date().toISOString(),
|
|
73
|
+
));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
61
76
|
setShell((current) => beginAccessCheck(current));
|
|
62
77
|
|
|
63
78
|
try {
|
|
@@ -101,7 +116,7 @@ export function NewsDeskClientProvider({
|
|
|
101
116
|
error instanceof Error ? error.message : "Could not load Newsroom data.",
|
|
102
117
|
));
|
|
103
118
|
}
|
|
104
|
-
}, [devSandboxEditorAuth]);
|
|
119
|
+
}, [devSandboxEditorAuth, localPreviewDashboard]);
|
|
105
120
|
|
|
106
121
|
const refreshAssignments = useCallback(async () => {
|
|
107
122
|
try {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
4
|
+
import { createLocalPreviewAssignments, createLocalPreviewContentItemVersions, createLocalPreviewReferences } from "../lib/local-preview-seed";
|
|
3
5
|
import { generateClient } from "aws-amplify/data";
|
|
4
6
|
import { testEditorOverrideAllowed } from "../lib/test-editor-override";
|
|
5
7
|
import { downloadData, getUrl } from "aws-amplify/storage";
|
|
@@ -39,7 +41,8 @@ import type {
|
|
|
39
41
|
UserDirectoryEntry,
|
|
40
42
|
} from "../lib/category-repository";
|
|
41
43
|
import { relationTypeKey } from "../lib/semantic-graph";
|
|
42
|
-
import {
|
|
44
|
+
import { contentItemStageKeys, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
|
|
45
|
+
import { getContentItemStageDefinitions } from "../lib/site-brand";
|
|
43
46
|
import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
|
|
44
47
|
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
45
48
|
import { isUnauthenticatedError, loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
|
|
@@ -1862,6 +1865,7 @@ export function normalizeNewsroomReferencePageOrder(value?: string | null): News
|
|
|
1862
1865
|
}
|
|
1863
1866
|
|
|
1864
1867
|
export async function loadNewsroomReferencePage(options: NewsroomReferencePageOptions = {}): Promise<NewsroomRecordPage<ReferenceRecord>> {
|
|
1868
|
+
if (isLocalPreviewEnabled()) return { items: createLocalPreviewReferences(), nextToken: null, hasMore: false };
|
|
1865
1869
|
configureAmplifyClient();
|
|
1866
1870
|
const order = normalizeNewsroomReferencePageOrder(options.order);
|
|
1867
1871
|
if (order === "imported") {
|
|
@@ -2145,6 +2149,7 @@ export async function loadEditorOverviewEditionData(): Promise<{
|
|
|
2145
2149
|
}
|
|
2146
2150
|
|
|
2147
2151
|
export async function loadNewsroomAssignmentPage(options: NewsroomAssignmentPageOptions = {}): Promise<NewsroomRecordPage<AssignmentRecord>> {
|
|
2152
|
+
if (isLocalPreviewEnabled()) return { items: createLocalPreviewAssignments(), nextToken: null, hasMore: false };
|
|
2148
2153
|
return loadNewsroomFeedPage<AssignmentRecord>({
|
|
2149
2154
|
query: NEWSROOM_ASSIGNMENT_FEED_QUERY,
|
|
2150
2155
|
field: "listAssignmentsByNewsroomFeedAndCreatedAt",
|
|
@@ -2168,12 +2173,13 @@ const CONTENT_ITEMS_BY_STAGE_QUERY = `
|
|
|
2168
2173
|
`;
|
|
2169
2174
|
|
|
2170
2175
|
export async function loadNewsroomContentItemVersions(): Promise<ContentItemVersionRecord[]> {
|
|
2176
|
+
if (isLocalPreviewEnabled()) return createLocalPreviewContentItemVersions();
|
|
2171
2177
|
configureAmplifyClient();
|
|
2172
2178
|
const client = generateClient<Schema>() as unknown as {
|
|
2173
2179
|
graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ContentItemVersionRecord>>;
|
|
2174
2180
|
};
|
|
2175
2181
|
const field = "listItemsByStageAndUpdatedAt";
|
|
2176
|
-
const versionsPerStage = await Promise.all(
|
|
2182
|
+
const versionsPerStage = await Promise.all(contentItemStageKeys(getContentItemStageDefinitions()).map(async (stage) => {
|
|
2177
2183
|
const versions: ContentItemVersionRecord[] = [];
|
|
2178
2184
|
let nextToken: string | null = null;
|
|
2179
2185
|
do {
|
|
@@ -2193,6 +2199,62 @@ export async function loadNewsroomContentItemVersions(): Promise<ContentItemVers
|
|
|
2193
2199
|
return versionsPerStage.flat();
|
|
2194
2200
|
}
|
|
2195
2201
|
|
|
2202
|
+
const PUBLISHED_ITEMS_QUERY = `
|
|
2203
|
+
query ListPublishedItems($limit: Int, $nextToken: String) {
|
|
2204
|
+
listPublishedItems(limit: $limit, nextToken: $nextToken) {
|
|
2205
|
+
items { sourceItemId itemLineageId versionNumber type status title headline slug section publishedAt }
|
|
2206
|
+
nextToken
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
`;
|
|
2210
|
+
|
|
2211
|
+
type PublishedItemBoardRow = {
|
|
2212
|
+
sourceItemId: string;
|
|
2213
|
+
itemLineageId: string;
|
|
2214
|
+
versionNumber: number;
|
|
2215
|
+
type: string;
|
|
2216
|
+
status: string;
|
|
2217
|
+
title?: string | null;
|
|
2218
|
+
headline?: string | null;
|
|
2219
|
+
slug: string;
|
|
2220
|
+
section?: string | null;
|
|
2221
|
+
publishedAt?: string | null;
|
|
2222
|
+
};
|
|
2223
|
+
|
|
2224
|
+
export async function loadNewsroomPublishedContentItemVersions(): Promise<ContentItemVersionRecord[]> {
|
|
2225
|
+
configureAmplifyClient();
|
|
2226
|
+
const client = generateClient<Schema>() as unknown as {
|
|
2227
|
+
graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<PublishedItemBoardRow>>;
|
|
2228
|
+
};
|
|
2229
|
+
const rows: PublishedItemBoardRow[] = [];
|
|
2230
|
+
let nextToken: string | null = null;
|
|
2231
|
+
do {
|
|
2232
|
+
const response: GraphQLConnectionResponse<PublishedItemBoardRow> = await client.graphql({
|
|
2233
|
+
query: PUBLISHED_ITEMS_QUERY,
|
|
2234
|
+
variables: { limit: NEWSROOM_PAGE_LIMIT, nextToken },
|
|
2235
|
+
authMode: USER_POOL_AUTH_MODE,
|
|
2236
|
+
});
|
|
2237
|
+
assertNoGraphQLErrors(response.errors);
|
|
2238
|
+
const connection = response.data?.listPublishedItems;
|
|
2239
|
+
if (!connection) throw new Error("Missing GraphQL connection payload for listPublishedItems.");
|
|
2240
|
+
rows.push(...((connection.items ?? []).filter(Boolean) as PublishedItemBoardRow[]));
|
|
2241
|
+
nextToken = connection.nextToken ?? null;
|
|
2242
|
+
} while (nextToken);
|
|
2243
|
+
return rows.map(row => ({
|
|
2244
|
+
id: row.sourceItemId,
|
|
2245
|
+
lineageId: row.itemLineageId,
|
|
2246
|
+
versionNumber: row.versionNumber,
|
|
2247
|
+
stage: "published",
|
|
2248
|
+
type: row.type,
|
|
2249
|
+
status: row.status,
|
|
2250
|
+
title: row.title,
|
|
2251
|
+
headline: row.headline,
|
|
2252
|
+
slug: row.slug,
|
|
2253
|
+
section: row.section,
|
|
2254
|
+
updatedAt: row.publishedAt,
|
|
2255
|
+
}));
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2196
2258
|
export async function loadModelPayloadsForOwner(
|
|
2197
2259
|
ownerKind: string,
|
|
2198
2260
|
ownerId: string,
|
|
@@ -3769,3 +3831,72 @@ export async function requestCyclotronReviewRate(
|
|
|
3769
3831
|
content: JSON.stringify(request),
|
|
3770
3832
|
});
|
|
3771
3833
|
}
|
|
3834
|
+
|
|
3835
|
+
const LIST_PRIMING_REQUEST_PAYLOADS_QUERY = `
|
|
3836
|
+
query ListKnowledgeRawPayloadsByOwnerAndKind($ownerId: ID!, $payloadKind: ModelStringKeyConditionInput, $limit: Int, $nextToken: String) {
|
|
3837
|
+
listKnowledgeRawPayloadsByOwnerAndKind(ownerId: $ownerId, payloadKind: $payloadKind, limit: $limit, nextToken: $nextToken) {
|
|
3838
|
+
items { id }
|
|
3839
|
+
nextToken
|
|
3840
|
+
}
|
|
3841
|
+
}
|
|
3842
|
+
`;
|
|
3843
|
+
|
|
3844
|
+
/** Ask the next decide-relevance sweep to prime the running cyclotron with one pasted URL. */
|
|
3845
|
+
export async function requestCyclotronPriming(
|
|
3846
|
+
cyclotronId: string,
|
|
3847
|
+
request: import("../lib/relevance-status").PrimingRequest,
|
|
3848
|
+
): Promise<void> {
|
|
3849
|
+
const { primingRequestPayloadId, PRIMING_REQUEST_KIND, safeId } = await import("../lib/relevance-status");
|
|
3850
|
+
const id = primingRequestPayloadId(cyclotronId, request.requestId);
|
|
3851
|
+
await runGraphql(CREATE_KNOWLEDGE_RAW_PAYLOAD_MUTATION, {
|
|
3852
|
+
input: {
|
|
3853
|
+
id,
|
|
3854
|
+
ownerType: "cyclotron",
|
|
3855
|
+
ownerId: safeId(cyclotronId),
|
|
3856
|
+
payloadKind: PRIMING_REQUEST_KIND,
|
|
3857
|
+
createdAt: request.requestedAt,
|
|
3858
|
+
updatedAt: request.requestedAt,
|
|
3859
|
+
},
|
|
3860
|
+
});
|
|
3861
|
+
await uploadModelPayloadForOwner({
|
|
3862
|
+
ownerKind: "knowledgeRawPayload",
|
|
3863
|
+
ownerId: id,
|
|
3864
|
+
ownerLineageId: id,
|
|
3865
|
+
role: "raw_payload",
|
|
3866
|
+
sortKey: "priming-request",
|
|
3867
|
+
filename: "priming-request.json",
|
|
3868
|
+
mediaType: "application/json",
|
|
3869
|
+
content: JSON.stringify(request),
|
|
3870
|
+
});
|
|
3871
|
+
}
|
|
3872
|
+
|
|
3873
|
+
/** The saved priming requests of a cyclotron, newest first, each with the sweep's outcome once there is one. */
|
|
3874
|
+
export async function loadCyclotronPrimingRequests(
|
|
3875
|
+
cyclotronId: string,
|
|
3876
|
+
): Promise<import("../lib/relevance-status").PrimingRequestView[]> {
|
|
3877
|
+
const { PRIMING_OUTCOME_ROLE, PRIMING_REQUEST_KIND, isPrimingOutcome, safeId } = await import("../lib/relevance-status");
|
|
3878
|
+
const ids: string[] = [];
|
|
3879
|
+
let nextToken: string | null = null;
|
|
3880
|
+
do {
|
|
3881
|
+
const page: { listKnowledgeRawPayloadsByOwnerAndKind?: { items?: Array<{ id: string } | null>; nextToken?: string | null } | null } =
|
|
3882
|
+
await runGraphql(LIST_PRIMING_REQUEST_PAYLOADS_QUERY, {
|
|
3883
|
+
ownerId: safeId(cyclotronId), payloadKind: { eq: PRIMING_REQUEST_KIND }, limit: 100, nextToken,
|
|
3884
|
+
});
|
|
3885
|
+
const connection = page.listKnowledgeRawPayloadsByOwnerAndKind;
|
|
3886
|
+
ids.push(...(connection?.items ?? []).filter((item): item is { id: string } => Boolean(item)).map((item) => item.id));
|
|
3887
|
+
nextToken = connection?.nextToken ?? null;
|
|
3888
|
+
} while (nextToken);
|
|
3889
|
+
const views = await Promise.all(ids.map(async (id) => {
|
|
3890
|
+
const payloads = await loadModelPayloadsForOwner("knowledgeRawPayload", id, ["raw_payload", PRIMING_OUTCOME_ROLE]);
|
|
3891
|
+
const request = payloads.find((payload) => payload.attachment.role === "raw_payload")?.json;
|
|
3892
|
+
const outcome = payloads.find((payload) => payload.attachment.role === PRIMING_OUTCOME_ROLE)?.json;
|
|
3893
|
+
if (!request || typeof request !== "object") return null;
|
|
3894
|
+
return {
|
|
3895
|
+
request: request as import("../lib/relevance-status").PrimingRequest,
|
|
3896
|
+
outcome: isPrimingOutcome(outcome) ? outcome : null,
|
|
3897
|
+
};
|
|
3898
|
+
}));
|
|
3899
|
+
return views
|
|
3900
|
+
.filter((view): view is import("../lib/relevance-status").PrimingRequestView => view !== null)
|
|
3901
|
+
.sort((left, right) => right.request.requestedAt.localeCompare(left.request.requestedAt));
|
|
3902
|
+
}
|
package/components/newspaper.tsx
CHANGED
|
@@ -1139,10 +1139,10 @@ function FrontStoryBlock({
|
|
|
1139
1139
|
<div className="jump-line">
|
|
1140
1140
|
{block.jumpTargetPage ? (
|
|
1141
1141
|
disableLinks ? (
|
|
1142
|
-
<span>{block.jumpLabel
|
|
1142
|
+
<span>{block.jumpLabel}</span>
|
|
1143
1143
|
) : (
|
|
1144
1144
|
<Link href={getPageHref(block.jumpTargetPage, editionBasePath)} onClick={(event) => handleJumpClick(event, block.jumpTargetPage ?? 1, scrollToPage)}>
|
|
1145
|
-
{block.jumpLabel
|
|
1145
|
+
{block.jumpLabel}
|
|
1146
1146
|
</Link>
|
|
1147
1147
|
)
|
|
1148
1148
|
) : (
|
|
@@ -1178,10 +1178,10 @@ function FrontStoryBlock({
|
|
|
1178
1178
|
<div className="jump-line">
|
|
1179
1179
|
{block.jumpTargetPage ? (
|
|
1180
1180
|
disableLinks ? (
|
|
1181
|
-
<span>{block.jumpLabel
|
|
1181
|
+
<span>{block.jumpLabel}</span>
|
|
1182
1182
|
) : (
|
|
1183
1183
|
<Link href={getPageHref(block.jumpTargetPage, editionBasePath)} onClick={(event) => handleJumpClick(event, block.jumpTargetPage ?? 1, scrollToPage)}>
|
|
1184
|
-
{block.jumpLabel
|
|
1184
|
+
{block.jumpLabel}
|
|
1185
1185
|
</Link>
|
|
1186
1186
|
)
|
|
1187
1187
|
) : (
|
|
@@ -2,13 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
|
-
import { IssueCollection, IssueDetailPanel
|
|
5
|
+
import { IssueCollection, IssueDetailPanel } from "@kanbus/ui";
|
|
6
6
|
import "@kanbus/ui/styles/kanban.css";
|
|
7
7
|
import "./newsroom-newsprint-board.css";
|
|
8
|
-
import {
|
|
8
|
+
import { PlusIcon } from "lucide-react";
|
|
9
|
+
import { buttonVariants } from "./ui/button";
|
|
10
|
+
import { cn } from "../lib/utils";
|
|
11
|
+
import { newsroomHref } from "../lib/newsroom-base-path";
|
|
12
|
+
import {
|
|
13
|
+
ASSIGNMENT_BOARD_TYPE,
|
|
14
|
+
assignmentBoardConfig,
|
|
15
|
+
canReadNewsroomBoard,
|
|
16
|
+
contentItemBoardConfig,
|
|
17
|
+
contentItemBoardType,
|
|
18
|
+
projectAssignmentBoard,
|
|
19
|
+
projectContentItemBoard,
|
|
20
|
+
type NativeBoardIssue,
|
|
21
|
+
} from "../lib/newsroom-board";
|
|
22
|
+
import type { AssignmentRecord } from "../lib/category-repository";
|
|
9
23
|
import { getContentItemName, SITE_BRAND } from "../lib/site-brand";
|
|
10
24
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
11
|
-
import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions,
|
|
25
|
+
import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions, loadNewsroomPublishedContentItemVersions } from "./news-desk-taxonomy-client";
|
|
12
26
|
import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
13
27
|
import { NewsDeskAccessGate } from "./newsroom-access-gate";
|
|
14
28
|
import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
|
|
@@ -16,84 +30,94 @@ import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
|
|
|
16
30
|
import { readLocalReaderSettings, subscribeReaderSettingsChanges } from "./reader-settings";
|
|
17
31
|
|
|
18
32
|
const STATIC_MOTION = { mode: "static" } as const;
|
|
33
|
+
const BOARD_REFRESH_INTERVAL_MILLISECONDS = 30_000;
|
|
19
34
|
|
|
20
|
-
|
|
21
|
-
|
|
35
|
+
async function loadEveryNewsroomAssignment() {
|
|
36
|
+
const assignments: AssignmentRecord[] = [];
|
|
37
|
+
let nextToken: string | null | undefined;
|
|
38
|
+
do {
|
|
39
|
+
const page = await loadNewsroomAssignmentPage({ limit: 100, nextToken });
|
|
40
|
+
assignments.push(...page.items);
|
|
41
|
+
nextToken = page.nextToken;
|
|
42
|
+
} while (nextToken);
|
|
43
|
+
return assignments;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type NewsroomBoardSubject = "contentItem" | "assignment";
|
|
47
|
+
|
|
48
|
+
/** Read-only board of one record kind over authenticated CMS records; existing workspaces own every mutation. */
|
|
49
|
+
export function NewsroomBoardView({ subject }: { subject: NewsroomBoardSubject }) {
|
|
22
50
|
const session = useOptionalNewsDeskClient();
|
|
23
51
|
const allowed = canReadNewsroomBoard(session?.shell.phase);
|
|
24
52
|
const theme = useResolvedPapyrusTheme();
|
|
25
53
|
const [lowMotion, setLowMotion] = useState(false);
|
|
26
54
|
const [issues, setIssues] = useState<NativeBoardIssue[]>([]);
|
|
27
|
-
const [filter, setFilter] = useState(contentItemBoardType());
|
|
28
55
|
const [view, setView] = useState<"board" | "list">("board");
|
|
29
|
-
const [revision, setRevision] = useState(0);
|
|
30
56
|
const [selected, setSelected] = useState<string | null>(null);
|
|
31
57
|
const [loading, setLoading] = useState(false);
|
|
32
58
|
const [error, setError] = useState<string | null>(null);
|
|
33
|
-
const [tokens, setTokens] = useState<{ reference?: string | null; assignment?: string | null }>({});
|
|
34
59
|
const generation = useRef(0);
|
|
35
|
-
const busy = useRef(false);
|
|
36
60
|
useEffect(() => {
|
|
37
61
|
setLowMotion(readLocalReaderSettings().motion === "low");
|
|
38
62
|
return subscribeReaderSettingsChanges(settings => setLowMotion(settings.motion === "low"));
|
|
39
63
|
}, []);
|
|
40
64
|
useEffect(() => {
|
|
41
65
|
const request = ++generation.current;
|
|
42
|
-
|
|
43
|
-
setIssues([]); setSelected(null); setTokens({}); setError(null);
|
|
66
|
+
setIssues([]); setSelected(null); setError(null);
|
|
44
67
|
if (!allowed) { setLoading(false); return; }
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
} catch (reason) {
|
|
71
|
-
if (generation.current === request) setError(reason instanceof Error ? reason.message : "Could not load more records.");
|
|
72
|
-
} finally { if (generation.current === request) { setLoading(false); busy.current = false; } }
|
|
73
|
-
}
|
|
74
|
-
const config = useMemo(() => newsroomBoardConfig(issues), [issues]);
|
|
68
|
+
const loadIssues = () => subject === "contentItem"
|
|
69
|
+
? Promise.all([
|
|
70
|
+
Promise.all([loadNewsroomContentItemVersions(), loadNewsroomPublishedContentItemVersions()]).then(([workingVersions, publishedVersions]) => [...workingVersions, ...publishedVersions]),
|
|
71
|
+
loadEveryNewsroomAssignment(),
|
|
72
|
+
]).then(([contentItemVersions, assignments]) => projectContentItemBoard({ contentItemVersions, assignments }))
|
|
73
|
+
: loadEveryNewsroomAssignment().then(projectAssignmentBoard);
|
|
74
|
+
const refresh = (initial: boolean) => {
|
|
75
|
+
if (initial) setLoading(true);
|
|
76
|
+
loadIssues().then(loadedIssues => {
|
|
77
|
+
if (generation.current !== request) return;
|
|
78
|
+
setIssues(loadedIssues);
|
|
79
|
+
setError(null);
|
|
80
|
+
}).catch((reason: unknown) => {
|
|
81
|
+
if (generation.current === request && initial) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
|
|
82
|
+
}).finally(() => { if (generation.current === request && initial) setLoading(false); });
|
|
83
|
+
};
|
|
84
|
+
refresh(true);
|
|
85
|
+
const refreshWhenVisible = () => { if (document.visibilityState === "visible") refresh(false); };
|
|
86
|
+
const interval = window.setInterval(refreshWhenVisible, BOARD_REFRESH_INTERVAL_MILLISECONDS);
|
|
87
|
+
document.addEventListener("visibilitychange", refreshWhenVisible);
|
|
88
|
+
return () => { generation.current = request + 1; window.clearInterval(interval); document.removeEventListener("visibilitychange", refreshWhenVisible); };
|
|
89
|
+
}, [allowed, subject]);
|
|
90
|
+
const config = useMemo(() => subject === "contentItem" ? contentItemBoardConfig(issues) : assignmentBoardConfig(issues), [issues, subject]);
|
|
91
|
+
const boardType = subject === "contentItem" ? contentItemBoardType() : ASSIGNMENT_BOARD_TYPE;
|
|
92
|
+
const pageTitle = subject === "contentItem" ? getContentItemName().plural : "Assignments";
|
|
75
93
|
const issue = issues.find(item => item.id === selected);
|
|
76
94
|
if (!allowed) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
|
|
77
|
-
return <NewsroomOpsShell activeTab="
|
|
95
|
+
return <NewsroomOpsShell activeTab={subject === "contentItem" ? "articles" : "assignments"} appTitle={SITE_BRAND.appTitle} backLabel={SITE_BRAND.backToHomeLabel} pageTitle={pageTitle}
|
|
96
|
+
headerActions={subject === "contentItem" ? (
|
|
97
|
+
<Link className={cn(buttonVariants({ size: "sm" }))} data-newsroom-article-new href={getNewsroomNavHref(newsroomHref("articles", "new"))}>
|
|
98
|
+
<PlusIcon aria-hidden="true" />
|
|
99
|
+
{`New ${getContentItemName().singular.toLowerCase()}`}
|
|
100
|
+
</Link>
|
|
101
|
+
) : undefined}>
|
|
78
102
|
<div className="organizer-native-board papyrus-newsprint-board" data-color-mode={theme}>
|
|
79
103
|
<div className="organizer-board-toolbar">
|
|
80
|
-
<
|
|
81
|
-
<div role="group" aria-label="Record presentation"><button type="button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button><button type="button" disabled={loading} onClick={() => setRevision(current => current + 1)}>Refresh records</button></div>
|
|
104
|
+
<div role="group" aria-label="Record presentation"><button type="button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button></div>
|
|
82
105
|
</div>
|
|
83
|
-
<p className="organizer-board-note">{
|
|
106
|
+
<p className="organizer-board-note">{subject === "contentItem"
|
|
107
|
+
? `Each ${getContentItemName().singular.toLowerCase()} appears once, in the column for its stage. Open one to use its existing editor.`
|
|
108
|
+
: "Each assignment appears once, in the column for its status. Open one to use its existing workflow controls."}</p>
|
|
84
109
|
{error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
|
|
85
|
-
<p role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded
|
|
110
|
+
<p role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded.`}</p>
|
|
86
111
|
<div className="organizer-board-body" data-detail-open={Boolean(issue)}>
|
|
87
|
-
<IssueCollection issues={issues} config={config} query={{ type:
|
|
112
|
+
<IssueCollection issues={issues} config={config} query={{ type: boardType }} view={view}
|
|
88
113
|
motion={lowMotion ? STATIC_MOTION : undefined}
|
|
89
|
-
columnStateKey={`papyrus.cms.columns.v1.${SITE_BRAND.id}`} initialScroll="start"
|
|
90
|
-
selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel=
|
|
114
|
+
columnStateKey={`papyrus.cms.columns.v1.${SITE_BRAND.id}.${subject}`} initialScroll="start"
|
|
115
|
+
selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel={pageTitle}
|
|
91
116
|
emptyMessage={loading ? "Loading records…" : error ? "Records could not be loaded." : "No records in this selection."} />
|
|
92
117
|
{issue && <IssueDetailPanel issue={issue} onClose={() => setSelected(null)} fields={[{ label: "CMS record ID", value: issue.nativeId }, { label: "Status", value: issue.nativeStatus }]}>
|
|
93
118
|
<Link href={getNewsroomNavHref(issue.href)}>Open {issue.type}</Link>
|
|
94
119
|
</IssueDetailPanel>}
|
|
95
120
|
</div>
|
|
96
|
-
<div className="organizer-board-pages">{tokens.reference && <button disabled={loading} type="button" onClick={() => void loadMore("reference")}>Load more references</button>}{tokens.assignment && <button disabled={loading} type="button" onClick={() => void loadMore("assignment")}>Load more assignments</button>}</div>
|
|
97
121
|
</div>
|
|
98
122
|
</NewsroomOpsShell>;
|
|
99
123
|
}
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import { NewsDeskClientProvider } from "./news-desk-client-provider";
|
|
2
|
+
import { createDemoCategorySteeringDashboard } from "../lib/category-repository";
|
|
2
3
|
import { resolveDevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
4
|
+
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
5
|
+
import { getBrandNewsroomSectionsConfigPath, resolveActiveSiteBrand } from "../lib/site-brand-tenant";
|
|
3
6
|
|
|
4
7
|
export function NewsroomClientShell({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
5
8
|
const devSandboxEditorAuth = resolveDevSandboxEditorAuth();
|
|
9
|
+
const activeBrand = resolveActiveSiteBrand();
|
|
10
|
+
const localPreviewDashboard = isLocalPreviewEnabled()
|
|
11
|
+
? createDemoCategorySteeringDashboard(activeBrand.id, getBrandNewsroomSectionsConfigPath(activeBrand))
|
|
12
|
+
: null;
|
|
6
13
|
|
|
7
14
|
return (
|
|
8
|
-
<NewsDeskClientProvider devSandboxEditorAuth={devSandboxEditorAuth}>
|
|
15
|
+
<NewsDeskClientProvider devSandboxEditorAuth={devSandboxEditorAuth} localPreviewDashboard={localPreviewDashboard}>
|
|
9
16
|
{children}
|
|
10
17
|
</NewsDeskClientProvider>
|
|
11
18
|
);
|
|
@@ -15,7 +15,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGro
|
|
|
15
15
|
import type { NewsroomOpsShellProps } from "./newsroom-ops-shell";
|
|
16
16
|
import "./newsroom-organizer.css";
|
|
17
17
|
|
|
18
|
-
const ICONS = { overview: LayoutDashboard,
|
|
18
|
+
const ICONS = { overview: LayoutDashboard, articles: ClipboardList, references: BookOpen, messages: MessagesSquare };
|
|
19
19
|
|
|
20
20
|
/** Navigation is a presentation layer; existing route and server authorization still own access. */
|
|
21
21
|
function Navigation({ activePath, demo, admin, surface, onNavigate }: { activePath: string; demo: boolean; admin: boolean; surface: string; onNavigate?: () => void }) {
|
|
@@ -99,18 +99,23 @@ export function NewsroomOrganizerShell({ activeTab, appTitle, backHref = "/", ba
|
|
|
99
99
|
return () => { document.removeEventListener("focusin", remember); media.removeEventListener("change", change); cancelAnimationFrame(frame); };
|
|
100
100
|
}, []);
|
|
101
101
|
const activePath = browserPathToInternalNewsroomPath(pathname ?? "/newsroom");
|
|
102
|
+
const sidebarToggleLabel = collapsed ? "Expand sidebar" : "Collapse sidebar";
|
|
103
|
+
const sidebarToggle = <button type="button" className="organizer-toggle" title={sidebarToggleLabel}
|
|
104
|
+
aria-label={sidebarToggleLabel} aria-expanded={!collapsed} aria-controls="organizer-sidebar" onClick={() => setCollapsed(value => !value)}>
|
|
105
|
+
{collapsed ? <PanelLeftOpen size={16} aria-hidden="true" /> : <PanelLeftClose size={16} aria-hidden="true" />}
|
|
106
|
+
</button>;
|
|
102
107
|
return <div className="newsroom-organizer" data-news-desk data-newsroom-ops-shell="true" data-newsroom-chrome="organizer" data-sidebar-collapsed={collapsed || !showNavigation ? "true" : "false"}>
|
|
103
108
|
{showNavigation && <aside className="organizer-sidebar" hidden={collapsed} id="organizer-sidebar">
|
|
104
|
-
<
|
|
109
|
+
<div className="organizer-sidebar-head">
|
|
110
|
+
<Link className="organizer-brand" href={getNewsroomNavHref("/newsroom", demo)}>{appTitle}<small>The newsroom</small></Link>
|
|
111
|
+
{sidebarToggle}
|
|
112
|
+
</div>
|
|
105
113
|
<Navigation activePath={activePath} demo={demo} admin={admin} surface="sidebar" />
|
|
106
114
|
<Account placement="sidebar" />
|
|
107
115
|
</aside>}
|
|
108
116
|
<div className="organizer-workspace">
|
|
109
117
|
<header className="organizer-topbar" data-newsroom-ops-topbar>
|
|
110
|
-
{showNavigation &&
|
|
111
|
-
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"} aria-expanded={!collapsed} aria-controls="organizer-sidebar" onClick={() => setCollapsed(value => !value)}>
|
|
112
|
-
{collapsed ? <PanelLeftOpen size={16} aria-hidden="true" /> : <PanelLeftClose size={16} aria-hidden="true" />}<span>{collapsed ? "Expand sidebar" : "Collapse sidebar"}</span>
|
|
113
|
-
</button>}
|
|
118
|
+
{showNavigation && collapsed && sidebarToggle}
|
|
114
119
|
<Link className="organizer-masthead" href={backHref} title={backLabel}>{appTitle}</Link>
|
|
115
120
|
<h1 data-newsroom-ops-title>{pageTitle}</h1><div className="organizer-header-actions">{headerActions}</div><Account placement="header" />
|
|
116
121
|
</header>
|
|
@@ -118,7 +123,7 @@ export function NewsroomOrganizerShell({ activeTab, appTitle, backHref = "/", ba
|
|
|
118
123
|
</div>
|
|
119
124
|
{showNavigation && <nav className="organizer-bottom-nav" aria-label="Primary newsroom navigation"><ul>
|
|
120
125
|
{ORGANIZER_PRIMARY_NAV.map(item => { const Icon = ICONS[item.key as keyof typeof ICONS]; return <li key={item.key}><Link
|
|
121
|
-
href={getNewsroomNavHref(item.path, demo)} title={item.description} aria-label={item.label} aria-current={activeTab === item.key
|
|
126
|
+
href={getNewsroomNavHref(item.path, demo)} title={item.description} aria-label={item.label} aria-current={activeTab === item.key ? "page" : undefined}>
|
|
122
127
|
<Icon size={18} aria-hidden="true" /><span>{item.label}</span></Link></li>; })}
|
|
123
128
|
<li><Sheet open={menuOpen} onOpenChange={setMenuOpen}><SheetTrigger className="organizer-more" aria-label="More"><Menu size={18} aria-hidden="true" /><span>More</span></SheetTrigger>
|
|
124
129
|
<SheetContent side="bottom" className="organizer-menu"><SheetHeader><SheetTitle>Newsroom</SheetTitle><SheetDescription>Workspaces and administration</SheetDescription></SheetHeader>
|
|
@@ -23,7 +23,8 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
23
23
|
.organizer-topbar{height:57px;min-height:57px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--org-ink)}
|
|
24
24
|
.organizer-masthead{font:900 28px Newsroom Display,Georgia,serif;white-space:nowrap;max-width:30vw;overflow:hidden;text-overflow:ellipsis}
|
|
25
25
|
.organizer-topbar h1{font:400 12px Newsroom UI,system-ui;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
|
|
26
|
-
.organizer-toggle{display:flex;align-items:center;
|
|
26
|
+
.organizer-toggle{display:flex;align-items:center;justify-content:center;width:36px;min-height:36px;padding:0;border:1px solid var(--org-line);flex-shrink:0}
|
|
27
|
+
.organizer-sidebar-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.organizer-sidebar-head .organizer-brand{flex:1;min-width:0}
|
|
27
28
|
.organizer-header-actions{display:flex;align-items:center;gap:8px;min-width:0;max-width:45%;overflow-x:auto}
|
|
28
29
|
.organizer-main{min-width:0;min-height:0;overflow:auto;overscroll-behavior:contain;flex:1;padding:16px 0}
|
|
29
30
|
.organizer-main>[data-category-steering]{min-height:0}.organizer-demo-notice{font-size:11px;color:var(--org-muted);margin-bottom:12px}
|
|
@@ -37,8 +38,8 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
37
38
|
.organizer-main:has(.organizer-native-board){display:flex;overflow:hidden}
|
|
38
39
|
.organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:12px}
|
|
39
40
|
.organizer-board-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;flex-wrap:wrap}
|
|
40
|
-
.organizer-board-toolbar [role=group]
|
|
41
|
-
.organizer-board-toolbar button
|
|
41
|
+
.organizer-board-toolbar [role=group]{display:flex;gap:8px;flex-wrap:wrap}
|
|
42
|
+
.organizer-board-toolbar button{min-height:44px;padding:8px 12px;border:1px solid var(--org-line)}
|
|
42
43
|
.organizer-board-toolbar button[aria-pressed=true]{background:var(--org-ink);color:var(--org-paper)}
|
|
43
44
|
.organizer-board-note{font-size:11px;line-height:1.5;margin:0;color:var(--org-muted)}
|
|
44
45
|
.organizer-board-body{position:relative;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-width:0;min-height:0;gap:16px}
|
|
@@ -49,7 +49,9 @@ import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
|
|
|
49
49
|
import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
|
|
50
50
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
51
51
|
import { ReferenceRelevanceReview } from "./reference-relevance-review";
|
|
52
|
+
import { ReferenceCyclotronPriming, type PrimingFormInput } from "./reference-cyclotron-priming";
|
|
52
53
|
import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
|
|
54
|
+
import type { PrimingRequestView } from "../lib/relevance-status";
|
|
53
55
|
import type { CyclotronStatus } from "cyclotron/cyclotron-status";
|
|
54
56
|
import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
55
57
|
import {
|
|
@@ -62,6 +64,12 @@ type NewsroomReferencesViewProps = {
|
|
|
62
64
|
priming?: PrimingPanelProps;
|
|
63
65
|
/** The relevance cyclotron's status snapshot, shown above the list. */
|
|
64
66
|
cyclotronStatus?: CyclotronStatus | null;
|
|
67
|
+
/** Present for editors who may prime the running relevance cyclotron with a pasted URL. */
|
|
68
|
+
cyclotronPriming?: {
|
|
69
|
+
labels: readonly string[];
|
|
70
|
+
requests: PrimingRequestView[];
|
|
71
|
+
onRequest: (input: PrimingFormInput) => Promise<void> | void;
|
|
72
|
+
};
|
|
65
73
|
/** Present for editors who may ask for a manual review rate. */
|
|
66
74
|
onRequestReviewRate?: (choice: ReviewRateChoice) => Promise<void> | void;
|
|
67
75
|
demo?: boolean;
|
|
@@ -399,6 +407,7 @@ function ReferenceDetailPanel({
|
|
|
399
407
|
export function NewsroomReferencesView({
|
|
400
408
|
priming,
|
|
401
409
|
cyclotronStatus,
|
|
410
|
+
cyclotronPriming,
|
|
402
411
|
decisions,
|
|
403
412
|
onRequestReviewRate,
|
|
404
413
|
demo = false,
|
|
@@ -516,6 +525,15 @@ export function NewsroomReferencesView({
|
|
|
516
525
|
</div>
|
|
517
526
|
<ReferenceCurationControls disabled={disabled} />
|
|
518
527
|
{priming && !disabled ? <ReferencePriming {...priming} /> : null}
|
|
528
|
+
{cyclotronPriming && !disabled ? (
|
|
529
|
+
<div className="shrink-0">
|
|
530
|
+
<ReferenceCyclotronPriming
|
|
531
|
+
labels={cyclotronPriming.labels}
|
|
532
|
+
onRequestPriming={cyclotronPriming.onRequest}
|
|
533
|
+
requests={cyclotronPriming.requests}
|
|
534
|
+
/>
|
|
535
|
+
</div>
|
|
536
|
+
) : null}
|
|
519
537
|
{cyclotronStatus ? (
|
|
520
538
|
<div className="shrink-0">
|
|
521
539
|
<ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
|