@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.
- package/amplify/data/resource.js +13 -3
- package/amplify/data/resource.ts +13 -3
- package/amplify/data/schema.js +10 -0
- package/amplify/data/schema.ts +10 -0
- package/amplify/functions/category-action/handler.ts +20 -19
- package/amplify/functions/console-chat-responder/src/main.rs +1 -1
- package/amplify/functions/content-actions/handler.py +23 -0
- package/amplify/functions/content-actions/requirements.txt +1 -1
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/renditions/requirements.txt +1 -1
- package/amplify/functions/shared/reference-curation-metadata.ts +15 -0
- package/amplify/functions/shared/slack-events.ts +2 -2
- package/amplify/publication/renditions-function.js +1 -1
- package/amplify/publication/renditions-function.ts +1 -1
- package/app/api/local-preview/derive/route.ts +28 -0
- package/app/api/local-preview/media/route.ts +25 -0
- package/app/globals.css +28 -28
- package/app/layout.tsx +8 -5
- package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
- package/app/newsroom/[section]/page.tsx +0 -1
- package/app/newsroom/insights/[threadId]/page.tsx +5 -16
- package/app/newsroom/insights/page.tsx +4 -12
- package/components/board-card-excerpts.tsx +45 -0
- package/components/markus-ir-preview.tsx +47 -2
- package/components/news-desk-page.tsx +0 -1
- package/components/news-desk-taxonomy-client.ts +25 -220
- package/components/newsroom-article-editor.tsx +135 -39
- package/components/newsroom-assignment-detail.tsx +35 -1
- package/components/newsroom-board-view.tsx +29 -7
- package/components/newsroom-newsprint-board.css +10 -2
- package/components/newsroom-ops-overview.tsx +27 -5
- package/components/newsroom-organizer-shell.tsx +25 -7
- package/components/newsroom-organizer.css +196 -4
- package/components/priming-proof-page.tsx +17 -8
- package/components/prospectus-account-chooser.tsx +98 -0
- package/components/story-reporting-trail-client.ts +91 -0
- package/components/story-reporting-trail.tsx +115 -0
- package/components/topic-steering-workspace.tsx +154 -671
- package/components/ui/sheet.tsx +1 -0
- package/components/wiki-concepts-view.tsx +153 -0
- package/components/wiki-index-client.ts +51 -0
- package/lib/content-actions-client.ts +3 -0
- package/lib/content-actions-demo.ts +55 -3
- package/lib/content-item-lifecycle.ts +4 -2
- package/lib/content-source-context.ts +9 -1
- package/lib/front-matter-fields.ts +42 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/local-preview-seed.ts +55 -2
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/newsroom-articles.ts +2 -0
- package/lib/newsroom-board.ts +4 -0
- package/lib/newsroom-forum-routes.ts +2 -43
- package/lib/newsroom-index-filters.ts +3 -40
- package/lib/newsroom-media-backend.ts +5 -0
- package/lib/newsroom-nav.ts +1 -3
- package/lib/newsroom-organizer-nav.ts +5 -12
- package/lib/newsroom-section-routing.ts +1 -1
- package/lib/newsroom-sections.ts +12 -0
- package/lib/papyrus-web-locations.ts +3 -24
- package/lib/priming-proof-report.ts +1 -0
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/semantic-graph.ts +5 -5
- package/lib/site-brand.ts +9 -0
- package/lib/story-reporting-trail.ts +106 -0
- package/lib/tenant-keys.ts +32 -0
- package/lib/wiki.ts +56 -0
- package/package.json +15 -1
- 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"
|
|
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 {
|
|
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
|
|
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>("
|
|
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(
|
|
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(
|
|
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
|
-
}, [
|
|
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("
|
|
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
|
|
404
|
-
const
|
|
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
|
-
<
|
|
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=
|
|
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>
|
|
547
|
-
<
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
value={editor.section}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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">
|
|
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"
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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}
|