@anthusai/papyrus 1.4.0-next.4 → 1.4.0-next.6

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 (69) hide show
  1. package/amplify/data/resource.js +13 -3
  2. package/amplify/data/resource.ts +13 -3
  3. package/amplify/data/schema.js +10 -0
  4. package/amplify/data/schema.ts +10 -0
  5. package/amplify/functions/category-action/handler.ts +20 -19
  6. package/amplify/functions/console-chat-responder/src/main.rs +1 -1
  7. package/amplify/functions/content-actions/handler.py +23 -0
  8. package/amplify/functions/content-actions/requirements.txt +1 -1
  9. package/amplify/functions/content-actions/resource.js +1 -1
  10. package/amplify/functions/content-actions/resource.ts +1 -1
  11. package/amplify/functions/renditions/requirements.txt +1 -1
  12. package/amplify/functions/shared/reference-curation-metadata.ts +15 -0
  13. package/amplify/functions/shared/slack-events.ts +2 -2
  14. package/amplify/publication/renditions-function.js +1 -1
  15. package/amplify/publication/renditions-function.ts +1 -1
  16. package/app/api/local-preview/derive/route.ts +28 -0
  17. package/app/api/local-preview/media/route.ts +25 -0
  18. package/app/globals.css +28 -28
  19. package/app/layout.tsx +8 -5
  20. package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
  21. package/app/newsroom/[section]/page.tsx +0 -1
  22. package/app/newsroom/insights/[threadId]/page.tsx +5 -16
  23. package/app/newsroom/insights/page.tsx +4 -12
  24. package/components/board-card-excerpts.tsx +45 -0
  25. package/components/markus-ir-preview.tsx +47 -2
  26. package/components/news-desk-page.tsx +0 -1
  27. package/components/news-desk-taxonomy-client.ts +25 -220
  28. package/components/newsroom-article-editor.tsx +135 -39
  29. package/components/newsroom-assignment-detail.tsx +35 -1
  30. package/components/newsroom-board-view.tsx +29 -7
  31. package/components/newsroom-newsprint-board.css +10 -2
  32. package/components/newsroom-ops-overview.tsx +27 -5
  33. package/components/newsroom-organizer-shell.tsx +25 -7
  34. package/components/newsroom-organizer.css +196 -4
  35. package/components/priming-proof-page.tsx +17 -8
  36. package/components/prospectus-account-chooser.tsx +98 -0
  37. package/components/story-reporting-trail-client.ts +91 -0
  38. package/components/story-reporting-trail.tsx +115 -0
  39. package/components/topic-steering-workspace.tsx +154 -671
  40. package/components/ui/sheet.tsx +1 -0
  41. package/components/wiki-concepts-view.tsx +153 -0
  42. package/components/wiki-index-client.ts +51 -0
  43. package/lib/content-actions-client.ts +3 -0
  44. package/lib/content-actions-demo.ts +55 -3
  45. package/lib/content-item-lifecycle.ts +4 -2
  46. package/lib/content-source-context.ts +9 -1
  47. package/lib/front-matter-fields.ts +42 -0
  48. package/lib/graphql-content-repository.ts +42 -7
  49. package/lib/local-preview-seed.ts +55 -2
  50. package/lib/local-preview-tenant.ts +27 -0
  51. package/lib/newsroom-articles.ts +2 -0
  52. package/lib/newsroom-board.ts +4 -0
  53. package/lib/newsroom-forum-routes.ts +2 -43
  54. package/lib/newsroom-index-filters.ts +3 -40
  55. package/lib/newsroom-media-backend.ts +5 -0
  56. package/lib/newsroom-nav.ts +1 -3
  57. package/lib/newsroom-organizer-nav.ts +5 -12
  58. package/lib/newsroom-section-routing.ts +1 -1
  59. package/lib/newsroom-sections.ts +12 -0
  60. package/lib/papyrus-web-locations.ts +3 -24
  61. package/lib/priming-proof-report.ts +1 -0
  62. package/lib/prospectus-tenant.ts +22 -0
  63. package/lib/semantic-graph.ts +5 -5
  64. package/lib/site-brand.ts +9 -0
  65. package/lib/story-reporting-trail.ts +106 -0
  66. package/lib/tenant-keys.ts +32 -0
  67. package/lib/wiki.ts +56 -0
  68. package/package.json +15 -1
  69. package/routes.manifest.json +23 -7
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { isEditorialWorkAssignment } from "../lib/newsroom-assignments";
4
4
  import { isLocalPreviewEnabled } from "../lib/local-preview";
5
- import { createLocalPreviewAssignments, createLocalPreviewContentItemVersions, createLocalPreviewReferences } from "../lib/local-preview-seed";
5
+ import { createLocalPreviewRealPublishedVersions, createLocalPreviewAssignments, createLocalPreviewContentItemVersions, createLocalPreviewReferences } from "../lib/local-preview-seed";
6
6
  import { generateClient } from "aws-amplify/data";
7
7
  import { testEditorOverrideAllowed } from "../lib/test-editor-override";
8
8
  import { downloadData, getUrl } from "aws-amplify/storage";
@@ -691,16 +691,11 @@ type NewsroomSemanticRelationPageOptions = NewsroomPageOptions & {
691
691
 
692
692
  export type ForumThreadWithMessages = MessageThreadRecord & {
693
693
  messages: MessageRecord[];
694
- scope: "edition" | "section" | "insight";
694
+ scope: "edition" | "section";
695
695
  sectionKey?: string | null;
696
696
  sectionTitle?: string | null;
697
697
  };
698
698
 
699
- export type InsightForumThreadsPage = {
700
- threads: ForumThreadWithMessages[];
701
- nextToken: string | null;
702
- };
703
-
704
699
  export type EditionForumThreadsResult = {
705
700
  editionId: string;
706
701
  editionThreads: ForumThreadWithMessages[];
@@ -1098,6 +1093,27 @@ export async function loadEditorProcedureData(): Promise<{
1098
1093
  };
1099
1094
  }
1100
1095
 
1096
+ const UPDATE_ASSIGNMENT_DESK_MUTATION = `
1097
+ mutation UpdateAssignmentDesk($input: UpdateAssignmentInput!) {
1098
+ updateAssignment(input: $input) { id sectionId sectionKey }
1099
+ }
1100
+ `;
1101
+
1102
+ /** Puts an assignment on a desk's board, or takes it off every desk when `deskId` is null. */
1103
+ export async function saveNewsroomAssignmentDesk(assignmentId: string, deskId: string | null): Promise<void> {
1104
+ if (isLocalPreviewEnabled()) return;
1105
+ const dataClient = generateClient<Schema>();
1106
+ const graphClient = dataClient as unknown as {
1107
+ graphql: (options: Record<string, unknown>) => Promise<{ data?: Record<string, unknown> | null; errors?: unknown[] | null }>;
1108
+ };
1109
+ const response = await graphClient.graphql({
1110
+ query: UPDATE_ASSIGNMENT_DESK_MUTATION,
1111
+ variables: { input: { id: assignmentId, sectionId: deskId, sectionKey: deskId } },
1112
+ authMode: USER_POOL_AUTH_MODE,
1113
+ });
1114
+ assertNoGraphQLErrors(response.errors);
1115
+ }
1116
+
1101
1117
  export async function saveProcedureDefinitionRecord(input: Record<string, unknown>) {
1102
1118
  const dataClient = generateClient<Schema>();
1103
1119
  const graphClient = dataClient as unknown as {
@@ -1278,148 +1294,6 @@ export async function loadEditionForumThreads(options: {
1278
1294
  };
1279
1295
  }
1280
1296
 
1281
- export async function loadInsightForumThreads(options: {
1282
- domain?: string;
1283
- limit?: number;
1284
- nextToken?: string | null;
1285
- includeMessages?: boolean;
1286
- } = {}): Promise<InsightForumThreadsPage> {
1287
- const testMock = getTestEditorNewsroomMock();
1288
- if (testMock?.insightForumThreads) {
1289
- const domain = String(options.domain || "").trim();
1290
- let threads = testMock.insightForumThreads;
1291
- if (domain) threads = threads.filter((thread) => thread.sectionKey === domain);
1292
- return { threads, nextToken: null };
1293
- }
1294
-
1295
- const limit = Math.max(1, Math.min(options.limit ?? NEWSROOM_PAGE_LIMIT, 100));
1296
- const domain = String(options.domain || "").trim();
1297
- const includeMessages = options.includeMessages !== false;
1298
- type KindConnectionResponse = {
1299
- listMessagesByKindAndCreatedAt?: {
1300
- items?: Array<MessageRecord | null> | null;
1301
- nextToken?: string | null;
1302
- } | null;
1303
- };
1304
- const response: KindConnectionResponse = await runGraphql<KindConnectionResponse>(
1305
- LIST_MESSAGES_BY_KIND_AND_CREATED_AT_QUERY,
1306
- {
1307
- messageKind: "insight",
1308
- sortDirection: "DESC",
1309
- limit,
1310
- nextToken: options.nextToken ?? null,
1311
- },
1312
- );
1313
- const connection = response.listMessagesByKindAndCreatedAt;
1314
- const roots = (connection?.items ?? [])
1315
- .filter(Boolean)
1316
- .map((item) => normalizeMessageRecord(item as MessageRecord))
1317
- .filter(isInsightThreadRootMessage)
1318
- .filter((message) => !domain || message.messageDomain === domain);
1319
-
1320
- const threads = await Promise.all(roots.map(async (root) => {
1321
- const messages = includeMessages
1322
- ? await loadInsightThreadMessages(root.id, root)
1323
- : [root];
1324
- return buildInsightForumThread(root, messages);
1325
- }));
1326
-
1327
- return {
1328
- threads,
1329
- nextToken: connection?.nextToken ?? null,
1330
- };
1331
- }
1332
-
1333
- export async function loadInsightForumThreadById(
1334
- threadId: string,
1335
- options: { includeMessages?: boolean } = {},
1336
- ): Promise<ForumThreadWithMessages | null> {
1337
- const normalized = String(threadId || "").trim();
1338
- if (!normalized) return null;
1339
- const testMock = getTestEditorNewsroomMock();
1340
- if (testMock?.insightForumThreads) {
1341
- return testMock.insightForumThreads.find((thread) => thread.id === normalized) ?? null;
1342
- }
1343
- const root = await getMessageRecordById(normalized);
1344
- if (!root || !isInsightThreadRootMessage(root)) return null;
1345
- const includeMessages = options.includeMessages !== false;
1346
- const messages = includeMessages
1347
- ? await loadInsightThreadMessages(normalized, root)
1348
- : [root];
1349
- return buildInsightForumThread(root, messages);
1350
- }
1351
-
1352
- export async function appendInsightThreadReplyRecord(input: {
1353
- threadId: string;
1354
- summary: string;
1355
- content: string;
1356
- role?: string;
1357
- authorLabel?: string;
1358
- parentMessageId?: string | null;
1359
- metadata?: Record<string, unknown>;
1360
- }): Promise<MessageRecord> {
1361
- const threadId = String(input.threadId || "").trim();
1362
- const summary = String(input.summary || "").trim();
1363
- const content = String(input.content || "").trim();
1364
- if (!threadId) throw new Error("threadId is required");
1365
- if (!summary) throw new Error("summary is required");
1366
- if (!content) throw new Error("content is required");
1367
- const root = await getMessageRecordById(threadId);
1368
- if (!root || !isInsightThreadRootMessage(root)) throw new Error(`Insight thread not found: ${threadId}`);
1369
- const existing = await listMessagesByThreadId(threadId, 1000);
1370
- const nextSequence = Math.max(1, ...existing.map((message) => Number(message.sequenceNumber || 0))) + 1;
1371
- const now = new Date().toISOString();
1372
- const message: MessageRecord = {
1373
- id: `message-insight-reply-${safeForumId(threadId)}-${String(nextSequence).padStart(4, "0")}`,
1374
- messageKind: "insight_reply",
1375
- messageDomain: root.messageDomain ?? "knowledge",
1376
- status: "active",
1377
- summary,
1378
- source: "newsroom.insights",
1379
- authorLabel: input.authorLabel?.trim() || "editor",
1380
- threadId,
1381
- parentMessageId: input.parentMessageId?.trim() || null,
1382
- sequenceNumber: nextSequence,
1383
- role: input.role || "human",
1384
- messageType: "insight_forum_reply",
1385
- content,
1386
- metadata: input.metadata ?? {},
1387
- createdAt: now,
1388
- updatedAt: now,
1389
- newsroomFeedKey: "messages",
1390
- };
1391
- const created = await runGraphql<{ createMessage?: MessageRecord | null }>(
1392
- CREATE_MESSAGE_MUTATION,
1393
- { input: message },
1394
- );
1395
- return normalizeMessageRecord(created.createMessage ?? message);
1396
- }
1397
-
1398
- export async function deleteInsightThreadMessageRecord(input: {
1399
- threadId: string;
1400
- messageId: string;
1401
- }): Promise<MessageRecord> {
1402
- const threadId = String(input.threadId || "").trim();
1403
- const messageId = String(input.messageId || "").trim();
1404
- if (!threadId) throw new Error("threadId is required");
1405
- if (!messageId) throw new Error("messageId is required");
1406
- const existing = await listMessagesByThreadId(threadId, 1000);
1407
- const target = existing.find((message) => message.id === messageId);
1408
- if (!target) throw new Error(`Message not found in thread: ${messageId}`);
1409
- const now = new Date().toISOString();
1410
- const updatedMessage = await runGraphql<{ updateMessage?: MessageRecord | null }>(
1411
- UPDATE_MESSAGE_MUTATION,
1412
- {
1413
- input: {
1414
- id: target.id,
1415
- status: "deleted",
1416
- updatedAt: now,
1417
- },
1418
- },
1419
- );
1420
- return normalizeMessageRecord(updatedMessage.updateMessage ?? { ...target, status: "deleted", updatedAt: now });
1421
- }
1422
-
1423
1297
  export async function ensureEditionForumThreadRecord(input: {
1424
1298
  editionId: string;
1425
1299
  title?: string;
@@ -1683,14 +1557,6 @@ async function listMessagesByThreadId(threadId: string, limit = 400): Promise<Me
1683
1557
  return records;
1684
1558
  }
1685
1559
 
1686
- function isInsightThreadRootMessage(message: MessageRecord): boolean {
1687
- if (String(message.messageKind || "") !== "insight") return false;
1688
- const threadId = String(message.threadId || "").trim();
1689
- const sequenceNumber = Number(message.sequenceNumber || 0);
1690
- if (threadId && threadId !== message.id && sequenceNumber > 1) return false;
1691
- return true;
1692
- }
1693
-
1694
1560
  async function getMessageRecordById(messageId: string): Promise<MessageRecord | null> {
1695
1561
  const normalized = String(messageId || "").trim();
1696
1562
  if (!normalized) return null;
@@ -1702,66 +1568,6 @@ async function getMessageRecordById(messageId: string): Promise<MessageRecord |
1702
1568
  return message ? normalizeMessageRecord(message) : null;
1703
1569
  }
1704
1570
 
1705
- async function loadInsightThreadMessages(threadId: string, root: MessageRecord): Promise<MessageRecord[]> {
1706
- const threadMessages = await listMessagesByThreadId(threadId, 500);
1707
- const byId = new Map<string, MessageRecord>();
1708
- byId.set(root.id, root);
1709
- for (const message of threadMessages) byId.set(message.id, message);
1710
- const merged = [...byId.values()].sort(
1711
- (left, right) => Number(left.sequenceNumber ?? 0) - Number(right.sequenceNumber ?? 0),
1712
- );
1713
- return hydrateMessageRecordsContent(merged);
1714
- }
1715
-
1716
- function insightForumThreadTitle(root: MessageRecord): string {
1717
- const metadata = normalizeJsonValue(root.metadata);
1718
- if (metadata && typeof metadata === "object" && !Array.isArray(metadata)) {
1719
- const explicit = String((metadata as Record<string, unknown>).insightTitle || "").trim();
1720
- if (explicit) return explicit;
1721
- }
1722
- const summary = String(root.summary || "").trim();
1723
- return summary || "Insight";
1724
- }
1725
-
1726
- function buildInsightForumThread(root: MessageRecord, messages: MessageRecord[]): ForumThreadWithMessages {
1727
- const activeMessages = messages.filter((message) => String(message.status || "active") === "active");
1728
- const sorted = [...activeMessages].sort(
1729
- (left, right) => Number(left.sequenceNumber ?? 0) - Number(right.sequenceNumber ?? 0),
1730
- );
1731
- const lastMessage = sorted.at(-1) ?? root;
1732
- const activityAt = lastMessage.updatedAt ?? lastMessage.createdAt ?? root.updatedAt ?? root.createdAt;
1733
- return {
1734
- id: root.id,
1735
- threadKind: "insight_forum",
1736
- status: "active",
1737
- title: insightForumThreadTitle(root),
1738
- summary: formatInsightForumDomainLabel(root.messageDomain),
1739
- primaryAnchorKind: "message",
1740
- primaryAnchorId: root.id,
1741
- primaryAnchorLineageId: root.id,
1742
- primaryAnchorKey: `message#${root.id}`,
1743
- createdByLabel: root.authorLabel ?? null,
1744
- messageCount: sorted.length,
1745
- lastMessageId: lastMessage.id,
1746
- lastMessageAt: activityAt,
1747
- metadata: normalizeJsonValue(root.metadata) as Record<string, unknown>,
1748
- createdAt: root.createdAt,
1749
- updatedAt: activityAt,
1750
- newsroomFeedKey: "messages",
1751
- messages: sorted.length ? sorted : [root],
1752
- scope: "insight",
1753
- sectionKey: root.messageDomain ?? null,
1754
- };
1755
- }
1756
-
1757
- function formatInsightForumDomainLabel(domain: string | null | undefined): string {
1758
- const value = String(domain || "").trim();
1759
- if (value === "assignment_work") return "Assignment research";
1760
- if (value === "knowledge") return "Reference knowledge";
1761
- if (!value) return "Insight";
1762
- return value.replace(/_/g, " ");
1763
- }
1764
-
1765
1571
  async function hydrateMessageRecordsContent(messages: MessageRecord[]): Promise<MessageRecord[]> {
1766
1572
  return Promise.all(messages.map(async (message) => {
1767
1573
  try {
@@ -2167,7 +1973,7 @@ export async function loadNewsroomAssignmentPage(options: NewsroomAssignmentPage
2167
1973
  const CONTENT_ITEMS_BY_STAGE_QUERY = `
2168
1974
  query ListItemsByStageAndUpdatedAt($stage: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
2169
1975
  listItemsByStageAndUpdatedAt(stage: $stage, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
2170
- items { id lineageId versionNumber stage type status title headline slug section updatedAt }
1976
+ items { id lineageId versionNumber stage type status title headline deck metadata slug section updatedAt }
2171
1977
  nextToken
2172
1978
  }
2173
1979
  }
@@ -2237,7 +2043,7 @@ type PublishedItemBoardRow = {
2237
2043
  };
2238
2044
 
2239
2045
  export async function loadNewsroomPublishedContentItemVersions(): Promise<ContentItemVersionRecord[]> {
2240
- if (isLocalPreviewEnabled()) return [];
2046
+ if (isLocalPreviewEnabled()) return createLocalPreviewRealPublishedVersions();
2241
2047
  configureAmplifyClient();
2242
2048
  const client = generateClient<Schema>() as unknown as {
2243
2049
  graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<PublishedItemBoardRow>>;
@@ -2691,7 +2497,6 @@ export function hasTestEditorOverride(): boolean {
2691
2497
 
2692
2498
  type TestEditorNewsroomMock = {
2693
2499
  forumThreadsByEdition?: Record<string, ForumThreadWithMessages[]>;
2694
- insightForumThreads?: ForumThreadWithMessages[];
2695
2500
  messages?: MessageRecord[];
2696
2501
  payloads?: Record<string, HydratedModelPayload[]>;
2697
2502
  referenceAttachments?: ReferenceAttachmentRecord[];
@@ -14,10 +14,15 @@ import {
14
14
  DialogTitle,
15
15
  } from "./ui/dialog";
16
16
  import { Input } from "./ui/input";
17
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
17
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
18
+ import { newsroomDeskOptions } from "../lib/newsroom-sections";
19
+ import { StoryReportingTrailView } from "./story-reporting-trail";
20
+ import { loadStoryReportingTrail } from "./story-reporting-trail-client";
21
+ import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "./ui/select";
18
22
  import { Spinner } from "./ui/spinner";
19
23
  import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
20
24
  import { Textarea } from "./ui/textarea";
25
+ import { FRONT_MATTER_BLURB_KEY, FRONT_MATTER_SUBTITLE_KEY, FRONT_MATTER_TITLE_KEY, readFrontMatterText, writeFrontMatterText } from "../lib/front-matter-fields";
21
26
  import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
22
27
  import { ContentActionFailure, type ContentActionError } from "../lib/content-actions-client";
23
28
  import { newsroomHref } from "../lib/newsroom-base-path";
@@ -59,7 +64,7 @@ type ValidationState =
59
64
  | { status: "unavailable"; message: string };
60
65
 
61
66
  type Notice = { tone: "ok" | "error" | "info"; text: string };
62
- type MobileTab = "edit" | "preview";
67
+ type MobileTab = "edit" | "preview" | "trail" | "references";
63
68
 
64
69
  function lineStartIndex(text: string, line: number): number {
65
70
  let index = 0;
@@ -91,10 +96,15 @@ function failureMessage(error: unknown): string {
91
96
  return error instanceof Error ? error.message : "The request failed.";
92
97
  }
93
98
 
99
+ const NO_DESK_VALUE = "__no-desk__";
100
+
94
101
  export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { articleId: string; demo: boolean; embedded?: boolean }) {
95
102
  const session = useOptionalNewsDeskClient();
96
- const blocked = !demo && articlesAccessBlocked(session?.shell.phase);
97
- const backend: ArticlesBackend = useMemo(() => resolveArticlesBackend(demo), [demo]);
103
+ const blocked = !(demo || isLocalPreviewEnabled()) && articlesAccessBlocked(session?.shell.phase);
104
+ const previewMode = demo || isLocalPreviewEnabled();
105
+ const backend: ArticlesBackend = useMemo(() => resolveArticlesBackend(previewMode), [previewMode]);
106
+ const [activeArticleId, setActiveArticleId] = useState(articleId);
107
+ useEffect(() => { setActiveArticleId(articleId); }, [articleId]);
98
108
  const isNew = articleId === "new";
99
109
 
100
110
  const [editor, setEditor] = useState<NewsroomArticleEditorState | null>(isNew ? emptyArticle() : null);
@@ -108,8 +118,15 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
108
118
  const [notice, setNotice] = useState<Notice | null>(null);
109
119
  const [conflictOpen, setConflictOpen] = useState(false);
110
120
  const [unpublishOpen, setUnpublishOpen] = useState(false);
111
- const [mobileTab, setMobileTab] = useState<MobileTab>("edit");
121
+ const [mobileTab, setMobileTab] = useState<MobileTab>("preview");
122
+ const [focusedReferenceId, setFocusedReferenceId] = useState<string | null>(null);
123
+ const [lineageId, setLineageId] = useState<string | null>(null);
124
+ const [latestVersion, setLatestVersion] = useState<{ id: string; number: number } | null>(null);
125
+ const latestVersionNumber = latestVersion?.number ?? null;
126
+ const [changeReason, setChangeReason] = useState("");
127
+ const citedKeys = useMemo(() => Object.values(((bodyIr as { papyrus?: { citations?: Record<string, string[]> } } | null)?.papyrus?.citations) ?? {}).flat(), [bodyIr]);
112
128
  const [sections, setSections] = useState<string[]>([]);
129
+ const deskOptions = useMemo(() => newsroomDeskOptions(session?.shell.dashboard?.newsroomSections ?? []), [session?.shell.dashboard?.newsroomSections]);
113
130
  const [reloadToken, setReloadToken] = useState(0);
114
131
  const [imageUrls, setImageUrls] = useState<Record<string, string>>({});
115
132
  const [imageUploading, setImageUploading] = useState(false);
@@ -118,17 +135,18 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
118
135
  const frontMatterRef = useRef<HTMLTextAreaElement | null>(null);
119
136
  const bodyRef = useRef<HTMLTextAreaElement | null>(null);
120
137
  const imageInputRef = useRef<HTMLInputElement | null>(null);
121
- const mediaBackend = useMemo(() => resolveMediaBackend(demo), [demo]);
138
+ const mediaBackend = useMemo(() => resolveMediaBackend(previewMode), [previewMode]);
122
139
  const validationSequence = useRef(0);
123
140
 
124
141
  useEffect(() => {
125
142
  if (blocked || isNew) return;
126
143
  let cancelled = false;
127
144
  backend
128
- .loadArticle(articleId)
145
+ .loadArticle(activeArticleId)
129
146
  .then((article) => {
130
147
  if (cancelled) return;
131
148
  setEditor(stateFromArticle(article));
149
+ setLineageId(article.lineageId ?? null);
132
150
  setAliases(article.aliases);
133
151
  setEverPublished(article.everPublished);
134
152
  setSlugEdited(true);
@@ -140,7 +158,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
140
158
  return () => {
141
159
  cancelled = true;
142
160
  };
143
- }, [articleId, backend, blocked, isNew, reloadToken]);
161
+ }, [activeArticleId, backend, blocked, isNew, reloadToken]);
144
162
 
145
163
  useEffect(() => {
146
164
  if (blocked) return;
@@ -234,8 +252,12 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
234
252
  patchEditor(patch);
235
253
  };
236
254
 
255
+ const changeFrontMatterField = (key: string, value: string) => {
256
+ if (editor) changeFrontMatter(writeFrontMatterText(editor.frontMatterYaml, key, value));
257
+ };
258
+
237
259
  const goToLine = (location: ErrorLocation) => {
238
- setMobileTab("edit");
260
+ setMobileTab("preview");
239
261
  window.setTimeout(() => {
240
262
  const target = location.field === "frontMatterYaml" ? frontMatterRef.current : bodyRef.current;
241
263
  if (!target) return;
@@ -274,6 +296,41 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
274
296
  }
275
297
  };
276
298
 
299
+ useEffect(() => {
300
+ if (!lineageId || isNew) { setLatestVersion(null); return; }
301
+ let cancelled = false;
302
+ loadStoryReportingTrail(lineageId, activeArticleId)
303
+ .then(trail => { if (!cancelled) { const newest = trail.versions[0]; setLatestVersion(newest ? { id: newest.id, number: newest.versionNumber } : null); } })
304
+ .catch(() => { if (!cancelled) setLatestVersion(null); });
305
+ return () => { cancelled = true; };
306
+ }, [activeArticleId, isNew, lineageId, reloadToken]);
307
+
308
+ const saveNewVersion = useCallback(async () => {
309
+ if (!editor || editor.id === null) return;
310
+ setBusy("save");
311
+ try {
312
+ const result = await backend.actions.saveItemDraft({
313
+ id: editor.id,
314
+ type: editor.type,
315
+ slug: editor.slug,
316
+ section: editor.section.trim() ? editor.section.trim() : null,
317
+ frontMatterYaml: editor.frontMatterYaml,
318
+ bodyMarkus: editor.bodyMarkus,
319
+ aliases,
320
+ expectedContentHash: editor.contentHash,
321
+ newVersion: true,
322
+ changeReason: changeReason.trim() || null,
323
+ });
324
+ setChangeReason("");
325
+ setActiveArticleId(result.item.id);
326
+ setNotice({ tone: "ok", text: `Saved as version ${result.item.versionNumber ?? ""}.` });
327
+ } catch (error) {
328
+ setNotice({ tone: "error", text: failureMessage(error) });
329
+ } finally {
330
+ setBusy(null);
331
+ }
332
+ }, [aliases, backend, changeReason, editor]);
333
+
277
334
  const saveDraft = useCallback(async (): Promise<string | null> => {
278
335
  if (!editor) return null;
279
336
  setBusy("save");
@@ -400,8 +457,9 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
400
457
 
401
458
  const slugLocked = everPublished;
402
459
  const invalid = validation.status === "invalid" || validation.status === "unavailable";
403
- const publishBlocked = invalid || validation.status === "checking" || busy !== null || !editor.slug.trim();
404
- const saveBlocked = invalid || busy !== null || !editor.slug.trim() || (!editor.dirty && editor.id !== null);
460
+ const isOlderVersion = latestVersionNumber !== null && editor.versionNumber !== null && editor.versionNumber < latestVersionNumber;
461
+ const publishBlocked = isOlderVersion || invalid || validation.status === "checking" || busy !== null || !editor.slug.trim();
462
+ const saveBlocked = isOlderVersion || invalid || busy !== null || !editor.slug.trim() || (!editor.dirty && editor.id !== null);
405
463
  const stagingUrl = stagingPreviewUrl(
406
464
  process.env.NEXT_PUBLIC_PAPYRUS_STAGING_URL,
407
465
  editor.type,
@@ -480,6 +538,16 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
480
538
  {busy === "save" ? <Spinner /> : null}
481
539
  Save draft
482
540
  </Button>
541
+ <Button
542
+ data-newsroom-article-save-version
543
+ disabled={isOlderVersion || invalid || busy !== null || editor.id === null || !editor.dirty}
544
+ onClick={() => void saveNewVersion()}
545
+ size="sm"
546
+ type="button"
547
+ variant="outline"
548
+ >
549
+ Save as new version
550
+ </Button>
483
551
  <Button
484
552
  data-newsroom-article-publish
485
553
  disabled={publishBlocked}
@@ -493,7 +561,7 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
493
561
  {editor.status === "published" ? (
494
562
  <Button
495
563
  data-newsroom-article-unpublish
496
- disabled={busy !== null}
564
+ disabled={isOlderVersion || busy !== null}
497
565
  onClick={() => setUnpublishOpen(true)}
498
566
  size="sm"
499
567
  type="button"
@@ -511,16 +579,9 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
511
579
  </div>
512
580
  ) : null}
513
581
 
514
- <Tabs className="lg:hidden" defaultValue="edit" onValueChange={(value) => setMobileTab(value as MobileTab)} value={mobileTab}>
515
- <TabsList>
516
- <TabsTrigger className="min-w-[6rem]" value="edit">Edit</TabsTrigger>
517
- <TabsTrigger className="min-w-[6rem]" value="preview">Preview</TabsTrigger>
518
- </TabsList>
519
- </Tabs>
520
-
521
- <div className="grid gap-6 lg:grid-cols-2">
582
+ <div className="grid gap-6">
522
583
  <div
523
- className={cn("min-w-0 space-y-4", mobileTab === "edit" ? "block" : "hidden", "lg:block")}
584
+ className="min-w-0 space-y-4"
524
585
  data-newsroom-article-edit-column
525
586
  >
526
587
  <div className="grid gap-3 sm:grid-cols-3">
@@ -543,18 +604,29 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
543
604
  </Select>
544
605
  </label>
545
606
  <label className="space-y-1 text-sm font-medium">
546
- <span>Section</span>
547
- <Input
548
- data-newsroom-article-section
549
- list="newsroom-article-sections"
550
- onChange={(event) => patchEditor({ section: event.target.value })}
551
- value={editor.section}
552
- />
553
- <datalist id="newsroom-article-sections">
554
- {sections.map((section) => (
555
- <option key={section} value={section} />
556
- ))}
557
- </datalist>
607
+ <span>Desk</span>
608
+ <Select
609
+ onValueChange={(value) => {
610
+ if (typeof value === "string") patchEditor({ section: value === NO_DESK_VALUE ? "" : value });
611
+ }}
612
+ value={deskOptions.some(option => option.value === editor.section) ? editor.section : NO_DESK_VALUE}
613
+ >
614
+ <SelectTrigger aria-label="Desk" className="w-full" data-newsroom-article-section>
615
+ <SelectValue>{deskOptions.find(option => option.value === editor.section)?.label ?? "No desk"}</SelectValue>
616
+ </SelectTrigger>
617
+ <SelectContent>
618
+ <SelectItem value={NO_DESK_VALUE}>No desk</SelectItem>
619
+ {(["core", "rotating"] as const).map((kind) => deskOptions.some(option => option.kind === kind) ? (
620
+ <SelectGroup key={kind}>
621
+ <SelectLabel>{kind === "core" ? "Core desks" : "Rotating desks"}</SelectLabel>
622
+ {deskOptions.filter(option => option.kind === kind).map((option) => (
623
+ <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
624
+ ))}
625
+ </SelectGroup>
626
+ ) : null)}
627
+ </SelectContent>
628
+ </Select>
629
+ {editor.section.trim() && !deskOptions.some(option => option.value === editor.section) ? <small className="newsroom-desk-legacy-value">Old section value: {editor.section.trim()}</small> : null}
558
630
  </label>
559
631
  <label className="space-y-1 text-sm font-medium">
560
632
  <span>Slug</span>
@@ -571,8 +643,27 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
571
643
  </label>
572
644
  </div>
573
645
 
646
+ {isOlderVersion ? <p className="newsroom-article-older-version" data-newsroom-article-older-version role="status">
647
+ This is version {editor.versionNumber} of {latestVersionNumber}, shown read-only. <button className="newsroom-article-link-button" onClick={() => setActiveArticleId(latestVersion?.id ?? activeArticleId)} type="button">Open the latest version</button>
648
+ </p> : null}
649
+ <div aria-label="Story view" className="newsroom-article-view-toggle" role="group">
650
+ <button aria-pressed={mobileTab === "preview"} className="organizer-header-button" data-newsroom-article-view="preview" onClick={() => setMobileTab("preview")} type="button">Preview</button>
651
+ <button aria-pressed={mobileTab === "edit"} className="organizer-header-button" data-newsroom-article-view="edit" onClick={() => setMobileTab("edit")} type="button">Edit</button>
652
+ <button aria-pressed={mobileTab === "trail"} className="organizer-header-button" data-newsroom-article-view="trail" onClick={() => setMobileTab("trail")} type="button">Messages</button>
653
+ <button aria-pressed={mobileTab === "references"} className="organizer-header-button" data-newsroom-article-view="references" onClick={() => { setFocusedReferenceId(null); setMobileTab("references"); }} type="button">References</button>
654
+ </div>
655
+ {(mobileTab === "trail" || mobileTab === "references") && editor.id ? <StoryReportingTrailView focusedReferenceId={focusedReferenceId} onShowReference={(referenceId) => { setFocusedReferenceId(referenceId); setMobileTab("references"); }} view={mobileTab === "references" ? "references" : "messages"} citedKeys={citedKeys} demo={previewMode} itemId={editor.id} lineageId={lineageId} onOpenVersion={(versionId) => { setActiveArticleId(versionId); setMobileTab("preview"); }} /> : null}
656
+ <div className={cn("space-y-4", mobileTab === "edit" ? "block" : "hidden")} data-newsroom-article-raw-code>
657
+ <div className="newsroom-article-front-matter-fields" data-newsroom-article-fields>
658
+ <label><span>Title</span><Input data-newsroom-article-field="title" onChange={(event) => changeFrontMatterField(FRONT_MATTER_TITLE_KEY, event.target.value)} value={readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_TITLE_KEY)} /></label>
659
+ <label><span>Subtitle</span><Input data-newsroom-article-field="subtitle" onChange={(event) => changeFrontMatterField(FRONT_MATTER_SUBTITLE_KEY, event.target.value)} placeholder="A line under the headline" value={readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY)} /></label>
660
+ <label><span>Blurb</span><Textarea data-newsroom-article-field="blurb" onChange={(event) => changeFrontMatterField(FRONT_MATTER_BLURB_KEY, event.target.value)} placeholder="Two or three lines that describe the story on the board and in listings" rows={3} value={readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY)} /></label>
661
+ </div>
662
+ <Input aria-label="What changed in this version" className="max-w-xl" onChange={(event) => setChangeReason(event.target.value)} placeholder="What changed? Recorded when you save as a new version." value={changeReason} />
663
+ <details className="newsroom-article-front-matter" data-newsroom-article-front-matter-details>
664
+ <summary>Front matter (YAML)</summary>
574
665
  <label className="block space-y-1 text-sm font-medium">
575
- <span>Front matter (YAML)</span>
666
+ <span className="sr-only">Front matter (YAML)</span>
576
667
  <Textarea
577
668
  className={TEXTAREA_CLASS}
578
669
  data-newsroom-article-front-matter
@@ -583,10 +674,11 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
583
674
  value={editor.frontMatterYaml}
584
675
  />
585
676
  </label>
677
+ </details>
586
678
 
587
679
  <div className="space-y-1 text-sm font-medium">
588
680
  <div className="flex flex-wrap items-center justify-between gap-2">
589
- <label htmlFor="newsroom-article-body">Body (Markus)</label>
681
+ <label className="sr-only" htmlFor="newsroom-article-body">Story body</label>
590
682
  <Button
591
683
  data-newsroom-article-insert-image
592
684
  disabled={editor.id === null || imageUploading || busy !== null}
@@ -664,16 +756,20 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
664
756
  })}
665
757
  </ul>
666
758
  ) : null}
759
+ </div>
667
760
  </div>
668
761
 
669
762
  <div
670
- className={cn("min-w-0", mobileTab === "preview" ? "block" : "hidden", "lg:block")}
763
+ className={cn("min-w-0", mobileTab === "preview" ? "block" : "hidden")}
671
764
  data-newsroom-article-preview-column
672
765
  >
673
766
  <div className="rounded-xl border border-border bg-card p-4" data-newsroom-article-preview>
674
- <p className="mb-2 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
675
- Preview
676
- </p>
767
+ {(readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY) || readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY)) ? (
768
+ <div className="newsroom-article-summary" data-newsroom-article-summary>
769
+ {readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY) ? <p className="newsroom-article-subtitle">{readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_SUBTITLE_KEY)}</p> : null}
770
+ {readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY) ? <p className="newsroom-article-blurb">{readFrontMatterText(editor.frontMatterYaml, FRONT_MATTER_BLURB_KEY)}</p> : null}
771
+ </div>
772
+ ) : null}
677
773
  {invalid && bodyIr ? (
678
774
  <p className="mb-2 text-xs text-muted-foreground">Showing the last valid version.</p>
679
775
  ) : null}