@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.
Files changed (37) hide show
  1. package/amplify/functions/model-attachment-upload/handler.ts +1 -0
  2. package/app/newsroom/[section]/page.tsx +6 -0
  3. package/app/newsroom/articles/page.tsx +3 -0
  4. package/components/news-desk-client-provider.tsx +17 -2
  5. package/components/news-desk-taxonomy-client.ts +133 -2
  6. package/components/newspaper.tsx +4 -4
  7. package/components/newsroom-board-view.tsx +74 -50
  8. package/components/newsroom-client-shell.tsx +8 -1
  9. package/components/newsroom-organizer-shell.tsx +12 -7
  10. package/components/newsroom-organizer.css +4 -3
  11. package/components/newsroom-references-view.tsx +18 -0
  12. package/components/reference-cyclotron-priming.tsx +85 -0
  13. package/components/topic-steering-workspace.tsx +35 -1
  14. package/lib/article-flow.ts +95 -0
  15. package/lib/article-frame-solver.ts +519 -0
  16. package/lib/chrome-text.ts +504 -0
  17. package/lib/content-item-lifecycle.ts +25 -26
  18. package/lib/front-mosaic.ts +1132 -0
  19. package/lib/furniture.ts +385 -0
  20. package/lib/labels.ts +37 -0
  21. package/lib/layout-config.ts +237 -0
  22. package/lib/layout-plan.ts +37 -9
  23. package/lib/layout-scenarios.ts +21 -11
  24. package/lib/layout-types.ts +457 -0
  25. package/lib/local-preview-seed.ts +97 -0
  26. package/lib/local-preview.ts +3 -0
  27. package/lib/newspaper-layout.ts +17 -3410
  28. package/lib/newsroom-board.ts +63 -62
  29. package/lib/newsroom-organizer-nav.ts +3 -4
  30. package/lib/pretext-layout.ts +22 -0
  31. package/lib/relevance-status.ts +73 -0
  32. package/lib/site-brand.ts +15 -1
  33. package/lib/text-columns.ts +251 -0
  34. package/package.json +13 -2
  35. package/renderers/pretext/presentation-shell.tsx +1 -1
  36. package/routes.manifest.json +1 -9
  37. package/app/newsroom/work/page.tsx +0 -10
@@ -48,6 +48,7 @@ const ALLOWED_ROLES = new Set([
48
48
  "code",
49
49
  "graph_export",
50
50
  "raw_payload",
51
+ "priming_outcome",
51
52
  ]);
52
53
  const ALLOWED_MEDIA_TYPES = new Set([
53
54
  "application/json",
@@ -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
- }: Readonly<{ children: React.ReactNode; devSandboxEditorAuth?: DevSandboxEditorAuth | null }>) {
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 { CONTENT_ITEM_STAGES, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
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(CONTENT_ITEM_STAGES.map(async (stage) => {
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
+ }
@@ -1139,10 +1139,10 @@ function FrontStoryBlock({
1139
1139
  <div className="jump-line">
1140
1140
  {block.jumpTargetPage ? (
1141
1141
  disableLinks ? (
1142
- <span>{block.jumpLabel ?? `SEE MORE ON A${block.jumpTargetPage}`}</span>
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 ?? `SEE MORE ON A${block.jumpTargetPage}`}
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 ?? `SEE MORE ON A${block.jumpTargetPage}`}</span>
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 ?? `SEE MORE ON A${block.jumpTargetPage}`}
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, IssueTypeFilter, issueTypeFromFilter } from "@kanbus/ui";
5
+ import { IssueCollection, IssueDetailPanel } from "@kanbus/ui";
6
6
  import "@kanbus/ui/styles/kanban.css";
7
7
  import "./newsroom-newsprint-board.css";
8
- import { canReadNewsroomBoard, contentItemBoardType, newsroomBoardConfig, projectNewsroomBoard, type NativeBoardIssue } from "../lib/newsroom-board";
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, loadNewsroomReferencePage } from "./news-desk-taxonomy-client";
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
- /** Read-only shared board over authenticated CMS records; existing workspaces own every mutation. */
21
- export function NewsroomBoardView() {
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
- busy.current = false;
43
- setIssues([]); setSelected(null); setTokens({}); setError(null);
66
+ setIssues([]); setSelected(null); setError(null);
44
67
  if (!allowed) { setLoading(false); return; }
45
- setLoading(true); busy.current = true;
46
- Promise.all([
47
- loadNewsroomContentItemVersions(),
48
- loadNewsroomReferencePage({ limit: 50 }),
49
- loadNewsroomAssignmentPage({ limit: 50 }),
50
- ]).then(([contentItemVersions, references, assignments]) => {
51
- if (generation.current !== request) return;
52
- setIssues(projectNewsroomBoard({ contentItemVersions, references: references.items, assignments: assignments.items }));
53
- setTokens({ reference: references.nextToken, assignment: assignments.nextToken });
54
- }).catch((reason: unknown) => {
55
- if (generation.current === request) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
56
- }).finally(() => { if (generation.current === request) { setLoading(false); busy.current = false; } });
57
- return () => { generation.current = request + 1; };
58
- }, [allowed, revision]);
59
- async function loadMore(kind: "reference" | "assignment") {
60
- if (!allowed || busy.current || !tokens[kind]) return;
61
- const request = generation.current;
62
- busy.current = true; setLoading(true); setError(null);
63
- try {
64
- const added = kind === "reference"
65
- ? await loadNewsroomReferencePage({ limit: 50, nextToken: tokens.reference }).then(page => ({ issues: projectNewsroomBoard({ references: page.items }), token: page.nextToken }))
66
- : await loadNewsroomAssignmentPage({ limit: 50, nextToken: tokens.assignment }).then(page => ({ issues: projectNewsroomBoard({ assignments: page.items }), token: page.nextToken }));
67
- if (generation.current !== request) return;
68
- setIssues(current => [...new Map([...current, ...added.issues].map(issue => [issue.id, issue])).values()]);
69
- setTokens(current => ({ ...current, [kind]: added.token }));
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="work" appTitle={SITE_BRAND.appTitle} backLabel={SITE_BRAND.backToHomeLabel} pageTitle="Board">
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
- <IssueTypeFilter config={config} value={filter} onChange={value => { setFilter(value); setSelected(null); }} name="Record type" />
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">{`Each ${getContentItemName().singular.toLowerCase()} appears once, in the column for its stage. Open a record to use its existing editor or workflow controls.`}</p>
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. References and assignments load in pages.`}</p>
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: issueTypeFromFilter(filter) }} view={view}
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="Newsroom records"
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, board: ClipboardList, references: BookOpen, messages: MessagesSquare };
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
- <Link className="organizer-brand" href={getNewsroomNavHref("/newsroom", demo)}>{appTitle}<small>The newsroom</small></Link>
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 && <button type="button" className="organizer-toggle" title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
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 || activeTab === "work" && item.key === "board" ? "page" : undefined}>
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;gap:8px;min-height:36px;padding:8px 12px;border:1px solid var(--org-line);font:400 11px Newsroom UI,system-ui;flex-shrink:0}
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],.organizer-board-pages{display:flex;gap:8px;flex-wrap:wrap}
41
- .organizer-board-toolbar button,.organizer-board-pages button{min-height:44px;padding:8px 12px;border:1px solid var(--org-line)}
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} />