@anthusai/papyrus 1.3.0 → 1.4.0-next.10

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 (141) hide show
  1. package/amplify/data/resource.js +134 -3
  2. package/amplify/data/resource.ts +145 -3
  3. package/amplify/data/schema.js +131 -0
  4. package/amplify/data/schema.ts +142 -0
  5. package/amplify/functions/category-action/handler.ts +272 -20
  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/model-attachment-upload/handler.ts +1 -0
  12. package/amplify/functions/renditions/requirements.txt +1 -1
  13. package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
  14. package/amplify/functions/shared/curation-delegation.ts +115 -0
  15. package/amplify/functions/shared/python-bundle.js +3 -3
  16. package/amplify/functions/shared/python-bundle.ts +3 -3
  17. package/amplify/functions/shared/reference-curation-metadata.ts +51 -0
  18. package/amplify/functions/shared/slack-events.ts +2 -2
  19. package/amplify/publication/renditions-function.js +1 -1
  20. package/amplify/publication/renditions-function.ts +1 -1
  21. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
  22. package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
  23. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
  24. package/app/[year]/[month]/[day]/page.tsx +3 -0
  25. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
  26. package/app/api/archive/editions/route.ts +2 -0
  27. package/app/api/auth/prospectus/accounts/route.ts +9 -0
  28. package/app/api/local-preview/derive/route.ts +28 -0
  29. package/app/api/local-preview/media/route.ts +25 -0
  30. package/app/archive/page.tsx +3 -0
  31. package/app/globals.css +28 -28
  32. package/app/layout.tsx +8 -5
  33. package/app/newsroom/[section]/[...selection]/page.tsx +0 -1
  34. package/app/newsroom/[section]/page.tsx +8 -1
  35. package/app/newsroom/articles/page.tsx +10 -0
  36. package/app/newsroom/insights/[threadId]/page.tsx +5 -16
  37. package/app/newsroom/insights/page.tsx +4 -12
  38. package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
  39. package/app/page.tsx +30 -1
  40. package/components/administration-delegations-panel.tsx +156 -0
  41. package/components/curation-delegation-client.ts +104 -0
  42. package/components/item-stream-view.tsx +63 -0
  43. package/components/markus-ir-preview.tsx +47 -2
  44. package/components/news-desk-client-provider.tsx +17 -2
  45. package/components/news-desk-page.tsx +0 -1
  46. package/components/news-desk-taxonomy-client.ts +154 -240
  47. package/components/newspaper.tsx +4 -4
  48. package/components/newsroom-article-editor.tsx +138 -40
  49. package/components/newsroom-assignment-detail.tsx +79 -0
  50. package/components/newsroom-assignments-view.tsx +1 -1
  51. package/components/newsroom-board-view.tsx +153 -43
  52. package/components/newsroom-client-shell.tsx +8 -1
  53. package/components/newsroom-newsprint-board.css +5 -46
  54. package/components/newsroom-ops-overview.tsx +27 -5
  55. package/components/newsroom-organizer-shell.tsx +36 -13
  56. package/components/newsroom-organizer.css +269 -13
  57. package/components/newsroom-references-view.tsx +118 -23
  58. package/components/newsroom-topics-view.tsx +1 -1
  59. package/components/priming-proof-page.tsx +17 -8
  60. package/components/prospectus-account-chooser.tsx +131 -0
  61. package/components/reference-cyclotron-priming.tsx +85 -0
  62. package/components/reference-cyclotron-status.tsx +5 -5
  63. package/components/reference-cyclotron-suggestion.tsx +143 -0
  64. package/components/reference-live-agreement.tsx +62 -0
  65. package/components/reference-section-suggestion.tsx +104 -0
  66. package/components/story-reporting-trail-client.ts +91 -0
  67. package/components/story-reporting-trail.tsx +115 -0
  68. package/components/topic-steering-workspace.tsx +280 -679
  69. package/components/ui/sheet.tsx +10 -4
  70. package/components/use-paged-content-item-versions.ts +151 -0
  71. package/components/wiki-concepts-view.tsx +153 -0
  72. package/components/wiki-index-client.ts +51 -0
  73. package/infra/amplify-app-shell.js +17 -3
  74. package/infra/amplify-app-shell.ts +18 -3
  75. package/infra/site-config.js +6 -1
  76. package/infra/site-config.ts +7 -1
  77. package/lib/approved-priming-proof-reports.ts +6 -3
  78. package/lib/approved-priming-proofs/papyrus.json +90 -0
  79. package/lib/article-flow.ts +95 -0
  80. package/lib/article-frame-solver.ts +519 -0
  81. package/lib/blog-feature-solver.ts +1 -109
  82. package/lib/category-repository.ts +1 -1
  83. package/lib/chrome-text.ts +504 -0
  84. package/lib/content-actions-client.ts +3 -0
  85. package/lib/content-actions-demo.ts +55 -0
  86. package/lib/content-item-lifecycle.ts +34 -5
  87. package/lib/content-source-context.ts +9 -1
  88. package/lib/curation-delegation-helpers.ts +69 -0
  89. package/lib/cyclotron-suggestions.ts +263 -0
  90. package/lib/front-matter-fields.ts +42 -0
  91. package/lib/front-mosaic.ts +1133 -0
  92. package/lib/furniture.ts +385 -0
  93. package/lib/graphql-content-repository.ts +42 -7
  94. package/lib/item-stream.ts +47 -0
  95. package/lib/labels.ts +37 -0
  96. package/lib/layout-config.ts +237 -0
  97. package/lib/layout-plan.ts +40 -11
  98. package/lib/layout-scenarios.ts +21 -11
  99. package/lib/layout-types.ts +457 -0
  100. package/lib/layout-warnings.ts +8 -0
  101. package/lib/local-preview-section-fixtures.ts +72 -0
  102. package/lib/local-preview-seed.ts +286 -0
  103. package/lib/local-preview-tenant.ts +27 -0
  104. package/lib/local-preview.ts +3 -0
  105. package/lib/newspaper-layout.ts +17 -3410
  106. package/lib/newsroom-articles.ts +2 -0
  107. package/lib/newsroom-assignments.ts +14 -1
  108. package/lib/newsroom-board.ts +99 -47
  109. package/lib/newsroom-demo-dashboard.ts +1 -1
  110. package/lib/newsroom-forum-routes.ts +2 -43
  111. package/lib/newsroom-index-filters.ts +3 -40
  112. package/lib/newsroom-media-backend.ts +5 -0
  113. package/lib/newsroom-nav.ts +1 -3
  114. package/lib/newsroom-organizer-nav.ts +9 -16
  115. package/lib/newsroom-section-routing.ts +1 -1
  116. package/lib/newsroom-sections.ts +12 -0
  117. package/lib/papyrus-web-locations.ts +3 -24
  118. package/lib/pretext-layout.ts +22 -0
  119. package/lib/priming-proof-report.ts +1 -0
  120. package/lib/prospectus-account-client.ts +90 -0
  121. package/lib/prospectus-sign-in-routes.ts +45 -0
  122. package/lib/prospectus-site-identity.ts +13 -1
  123. package/lib/prospectus-tenant.ts +22 -0
  124. package/lib/reader-static-params.ts +2 -1
  125. package/lib/relevance-decisions.ts +29 -0
  126. package/lib/relevance-status.ts +73 -0
  127. package/lib/reporting-story-budget.ts +2 -2
  128. package/lib/semantic-graph.ts +5 -5
  129. package/lib/site-brand.ts +28 -1
  130. package/lib/site-env.ts +5 -0
  131. package/lib/site-section-suggestions.ts +120 -0
  132. package/lib/site-stack.ts +1 -1
  133. package/lib/story-reporting-trail.ts +106 -0
  134. package/lib/tenant-keys.ts +32 -0
  135. package/lib/text-columns.ts +251 -0
  136. package/lib/wiki.ts +56 -0
  137. package/package.json +46 -3
  138. package/renderers/pretext/presentation-shell.tsx +1 -1
  139. package/renderers/pretext/rhythm-article-page.tsx +1 -18
  140. package/routes.manifest.json +34 -15
  141. package/app/newsroom/work/page.tsx +0 -10
package/app/layout.tsx CHANGED
@@ -5,7 +5,7 @@ import { AmplifyClientProvider } from "../components/amplify-client-provider";
5
5
  import { PapyrusConsoleShell } from "../components/papyrus-console-shell";
6
6
  import { TooltipProvider } from "../components/ui/tooltip";
7
7
  import { resolveBrandFontAssets } from "../lib/brand-fonts";
8
- import { getPresentationChoices, getSiteBrand } from "../lib/site-brand";
8
+ import { DEFAULT_SITE_BRAND_ICONS, getPresentationChoices, getSiteBrand } from "../lib/site-brand";
9
9
  import { getSiteStack } from "../lib/site-stack";
10
10
  import { isIndexable, showStagingBanner } from "../lib/site-env";
11
11
  import { StagingBanner } from "../components/staging-banner";
@@ -34,9 +34,10 @@ export async function generateMetadata(): Promise<Metadata> {
34
34
  ...(isIndexable() ? {} : { robots: { index: false, follow: false } }),
35
35
  icons: {
36
36
  icon: [
37
- { url: `/icon-light.png?v=${faviconVersion}`, type: "image/png" },
38
- { url: `/icon.png?v=${faviconVersion}`, type: "image/png" },
37
+ { url: `${siteBrand.icons?.light ?? DEFAULT_SITE_BRAND_ICONS.light}?v=${faviconVersion}`, type: "image/png" },
38
+ ...(siteBrand.icons ? [] : [{ url: `${DEFAULT_SITE_BRAND_ICONS.fallback}?v=${faviconVersion}`, type: "image/png" }]),
39
39
  ],
40
+ ...(siteBrand.icons?.appleTouch ? { apple: [{ url: `${siteBrand.icons.appleTouch}?v=${faviconVersion}`, type: "image/png" }] } : {}),
40
41
  },
41
42
  };
42
43
  }
@@ -46,6 +47,8 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
46
47
  const presentationChoices = getPresentationChoices(siteBrand);
47
48
  const siteStack = getSiteStack(siteBrand);
48
49
  const brandFonts = resolveBrandFontAssets(siteBrand.fonts);
50
+ const lightIconPath = siteBrand.icons?.light ?? DEFAULT_SITE_BRAND_ICONS.light;
51
+ const darkIconPath = siteBrand.icons ? siteBrand.icons.dark ?? siteBrand.icons.light : DEFAULT_SITE_BRAND_ICONS.dark;
49
52
 
50
53
  return (
51
54
  <html
@@ -78,8 +81,8 @@ export default async function RootLayout({ children }: Readonly<{ children: Reac
78
81
  <Script id="papyrus-favicon-color-scheme" strategy="beforeInteractive">
79
82
  {`(() => {
80
83
  const version = "${faviconVersion}";
81
- const lightHref = "/icon-light.png?v=" + version;
82
- const darkHref = "/icon-dark.png?v=" + version;
84
+ const lightHref = ${JSON.stringify(lightIconPath)} + "?v=" + version;
85
+ const darkHref = ${JSON.stringify(darkIconPath)} + "?v=" + version;
83
86
  const settingsStorageKey = "papyrus:reader-settings";
84
87
  const defaultTheme = "${defaultTheme}";
85
88
 
@@ -33,7 +33,6 @@ const NEWS_DESK_ROUTE_SECTIONS = new Set<NewsDeskTab>([
33
33
  "topics",
34
34
  "concepts",
35
35
  "references",
36
- "insights",
37
36
  "messages",
38
37
  "assignments",
39
38
  ]);
@@ -1,6 +1,8 @@
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 { contentItemBoardPathSegment } from "../../../lib/newsroom-board";
5
+ import { NewsroomBoardView } from "../../../components/newsroom-board-view";
4
6
  import { NewsDeskPage } from "../../../components/news-desk-page";
5
7
  import type { NewsDeskTab } from "../../../components/topic-steering-workspace";
6
8
 
@@ -34,7 +36,6 @@ const NEWS_DESK_ROUTE_SECTIONS = new Set<NewsDeskTab>([
34
36
  "topics",
35
37
  "concepts",
36
38
  "references",
37
- "insights",
38
39
  "messages",
39
40
  "assignments",
40
41
  "search",
@@ -42,9 +43,15 @@ 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/${contentItemBoardPathSegment()}`));
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"));
50
+ if (section === contentItemBoardPathSegment() && resolveActiveSiteBrand().newsroomLayout === "organizer") return <NewsroomBoardView subject="contentItem" />;
48
51
  if (!NEWS_DESK_ROUTE_SECTIONS.has(section as NewsDeskTab)) notFound();
52
+ if (section === "assignments" && resolveActiveSiteBrand().newsroomLayout === "organizer") {
53
+ const resolved = await searchParams;
54
+ if (!resolved?.assignment && resolved?.demo !== "1") return <NewsroomBoardView subject="assignment" />;
55
+ }
49
56
  return <NewsDeskPage section={section} searchParams={searchParams} />;
50
57
  }
@@ -1,3 +1,8 @@
1
+ import { redirect } from "next/navigation";
2
+ import { contentItemBoardPathSegment } from "../../../lib/newsroom-board";
3
+ import { toPublicNewsroomPath } from "../../../lib/newsroom-base-path";
4
+ import { NewsroomBoardView } from "../../../components/newsroom-board-view";
5
+ import { resolveActiveSiteBrand } from "../../../lib/site-brand-tenant";
1
6
  import { NewsroomArticlesView } from "../../../components/newsroom-articles-view";
2
7
 
3
8
  export const dynamic = "force-dynamic";
@@ -9,5 +14,10 @@ type NewsroomArticlesPageProps = {
9
14
  export default async function NewsroomArticlesPage({ searchParams }: NewsroomArticlesPageProps) {
10
15
  const resolved = await searchParams;
11
16
  const demoValue = Array.isArray(resolved?.demo) ? resolved?.demo[0] : resolved?.demo;
17
+ if (resolveActiveSiteBrand().newsroomLayout === "organizer" && demoValue !== "1") {
18
+ const boardPathSegment = contentItemBoardPathSegment();
19
+ if (boardPathSegment !== "articles") redirect(toPublicNewsroomPath(`/newsroom/${boardPathSegment}`));
20
+ return <NewsroomBoardView subject="contentItem" />;
21
+ }
12
22
  return <NewsroomArticlesView demo={demoValue === "1"} />;
13
23
  }
@@ -1,22 +1,11 @@
1
- import { NewsDeskPage } from "../../../../components/news-desk-page";
1
+ import { permanentRedirect } from "next/navigation";
2
+ import { toPublicNewsroomPath } from "../../../../lib/newsroom-base-path";
2
3
 
3
- export const dynamic = "force-dynamic";
4
-
5
- type NewsroomInsightThreadPageProps = {
4
+ type NewsroomMessageThreadRedirectPageProps = {
6
5
  params: Promise<{ threadId: string }>;
7
- searchParams?: Promise<{
8
- demo?: string | string[];
9
- domain?: string | string[];
10
- }>;
11
6
  };
12
7
 
13
- export default async function NewsroomInsightThreadPage({ params, searchParams }: NewsroomInsightThreadPageProps) {
8
+ export default async function NewsroomMessageThreadRedirectPage({ params }: NewsroomMessageThreadRedirectPageProps) {
14
9
  const { threadId } = await params;
15
- return (
16
- <NewsDeskPage
17
- section="insights"
18
- selectionPath={[threadId]}
19
- searchParams={searchParams}
20
- />
21
- );
10
+ permanentRedirect(toPublicNewsroomPath(`/newsroom/messages/${encodeURIComponent(threadId)}`));
22
11
  }
@@ -1,14 +1,6 @@
1
- import { NewsDeskPage } from "../../../components/news-desk-page";
1
+ import { permanentRedirect } from "next/navigation";
2
+ import { toPublicNewsroomPath } from "../../../lib/newsroom-base-path";
2
3
 
3
- export const dynamic = "force-dynamic";
4
-
5
- type NewsroomInsightsPageProps = {
6
- searchParams?: Promise<{
7
- demo?: string | string[];
8
- domain?: string | string[];
9
- }>;
10
- };
11
-
12
- export default async function NewsroomInsightsPage({ searchParams }: NewsroomInsightsPageProps) {
13
- return <NewsDeskPage section="insights" searchParams={searchParams} />;
4
+ export default function NewsroomMessagesRedirectPage() {
5
+ permanentRedirect(toPublicNewsroomPath("/newsroom/messages"));
14
6
  }
@@ -1,4 +1,6 @@
1
1
  import { notFound } from "next/navigation";
2
+ import { resolveActiveSiteBrand } from "../../../../lib/site-brand-tenant";
3
+ import { NewsroomBoardView } from "../../../../components/newsroom-board-view";
2
4
  import { NewsDeskPage } from "../../../../components/news-desk-page";
3
5
 
4
6
  export const dynamic = "force-dynamic";
@@ -13,5 +15,6 @@ export default async function NewsroomDeepSectionPage({ params, searchParams }:
13
15
  const normalizedSectionId = decodeURIComponent(sectionId ?? "").trim();
14
16
  if (!normalizedSectionId) notFound();
15
17
 
18
+ if (resolveActiveSiteBrand().newsroomLayout === "organizer") return <NewsroomBoardView subject="desk" deskId={normalizedSectionId} />;
16
19
  return <NewsDeskPage sectionPageId={normalizedSectionId} searchParams={searchParams} />;
17
20
  }
package/app/page.tsx CHANGED
@@ -15,7 +15,10 @@ import {
15
15
  type PublicPlaceholderTopic,
16
16
  } from "../lib/public-placeholder-config";
17
17
  import type { PublicationItem } from "../lib/publication-items";
18
- import { SITE_BRAND, rootRoute } from "../lib/site-brand";
18
+ import { SITE_BRAND, editionsEnabled, rootRoute } from "../lib/site-brand";
19
+ import { ItemStreamView } from "../components/item-stream-view";
20
+ import { ITEM_STREAM_RECENT_EDITION_COUNT, buildItemStream, selectNewestStreamItems } from "../lib/item-stream";
21
+ import { listCachedPublishedEditions } from "../lib/cached-content-repository";
19
22
  import { notFound, redirect } from "next/navigation";
20
23
  import { getReaderBasePath } from "../lib/reader-base-path";
21
24
  import { buildRootRedirectTarget } from "../lib/root-route-redirect";
@@ -27,6 +30,7 @@ export const dynamic = "force-dynamic";
27
30
  type HomePageProps = {
28
31
  searchParams?: Promise<{
29
32
  scenario?: string | string[];
33
+ section?: string | string[];
30
34
  code?: string | string[];
31
35
  state?: string | string[];
32
36
  error?: string | string[];
@@ -59,6 +63,13 @@ export default async function Home({ searchParams }: HomePageProps) {
59
63
  notFound();
60
64
  }
61
65
 
66
+ if (!editionsEnabled()) {
67
+ const sectionParam = resolvedSearchParams?.section;
68
+ const sectionKey = Array.isArray(sectionParam) ? sectionParam[0] : sectionParam;
69
+ const items = await loadNewestStreamItemsOrNone();
70
+ return <ItemStreamView stream={buildItemStream(items, sectionKey)} />;
71
+ }
72
+
62
73
  // Resolved only on the reader path: Markus brands (renderer "markus") have
63
74
  // no Next.js reader and getSiteRenderer() throws for them, but they can
64
75
  // still serve the newsroom at the root via rootRoute above.
@@ -88,6 +99,24 @@ export default async function Home({ searchParams }: HomePageProps) {
88
99
  return <siteRenderer.renderEdition content={content} />;
89
100
  }
90
101
 
102
+ async function loadNewestStreamItemsOrNone() {
103
+ try {
104
+ return await loadNewestStreamItems();
105
+ } catch (error) {
106
+ if (!isMissingReaderBackendError(error)) throw error;
107
+ return [];
108
+ }
109
+ }
110
+
111
+ async function loadNewestStreamItems() {
112
+ const { editions } = await listCachedPublishedEditions({ limit: ITEM_STREAM_RECENT_EDITION_COUNT });
113
+ const newestFirst = [...editions].sort((left, right) => right.editionDate.localeCompare(left.editionDate));
114
+ const contents = await Promise.all(
115
+ newestFirst.map((edition) => loadCachedEditionContent({ editionDate: edition.editionDate })),
116
+ );
117
+ return selectNewestStreamItems(contents);
118
+ }
119
+
91
120
  async function loadLatestGraphQLEdition(): Promise<EditionContent | null> {
92
121
  const latestEdition = await getCachedLatestPublishedEdition();
93
122
  if (!latestEdition) return null;
@@ -0,0 +1,156 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import {
5
+ delegationStateText,
6
+ describeDelegationError,
7
+ isPendingDelegation,
8
+ pendingCountdownText,
9
+ requestedHoursText,
10
+ type CurationDelegationRecord,
11
+ } from "../lib/curation-delegation-helpers";
12
+ import { approveCurationDelegation, denyCurationDelegation, listCurationDelegations, revokeCurationDelegation } from "./curation-delegation-client";
13
+
14
+ const REFRESH_MILLISECONDS = 10_000;
15
+
16
+ export function AdministrationDelegationsPanel({ disabled }: { disabled: boolean }) {
17
+ const [busyCode, setBusyCode] = useState<string | null>(null);
18
+ const [busy, setBusy] = useState(false);
19
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
20
+ const [delegations, setDelegations] = useState<CurationDelegationRecord[]>([]);
21
+ const [confirmingRevokeId, setConfirmingRevokeId] = useState<string | null>(null);
22
+ const [nowMilliseconds, setNowMilliseconds] = useState(() => Date.now());
23
+ const mounted = useRef(true);
24
+
25
+ useEffect(() => {
26
+ mounted.current = true;
27
+ void refreshDelegations();
28
+ const refreshTimer = window.setInterval(() => void refreshDelegations(), REFRESH_MILLISECONDS);
29
+ const clockTimer = window.setInterval(() => setNowMilliseconds(Date.now()), 1_000);
30
+ return () => {
31
+ mounted.current = false;
32
+ window.clearInterval(refreshTimer);
33
+ window.clearInterval(clockTimer);
34
+ };
35
+ }, []);
36
+
37
+ async function refreshDelegations() {
38
+ try {
39
+ const listed = await listCurationDelegations();
40
+ if (mounted.current) setDelegations(listed);
41
+ } catch (error) {
42
+ if (mounted.current) setErrorMessage(describeDelegationError(error));
43
+ }
44
+ }
45
+
46
+ async function decide(userCode: string, decision: "approve" | "deny") {
47
+ setBusyCode(userCode);
48
+ setErrorMessage(null);
49
+ try {
50
+ await (decision === "approve" ? approveCurationDelegation(userCode) : denyCurationDelegation(userCode));
51
+ await refreshDelegations();
52
+ } catch (error) {
53
+ setErrorMessage(describeDelegationError(error));
54
+ await refreshDelegations();
55
+ } finally {
56
+ setBusyCode(null);
57
+ }
58
+ }
59
+
60
+ async function handleRevoke(delegationId: string) {
61
+ setBusy(true);
62
+ setErrorMessage(null);
63
+ try {
64
+ await revokeCurationDelegation(delegationId);
65
+ await refreshDelegations();
66
+ setConfirmingRevokeId(null);
67
+ } catch (error) {
68
+ setErrorMessage(describeDelegationError(error));
69
+ } finally {
70
+ setBusy(false);
71
+ }
72
+ }
73
+
74
+ const pending = delegations.filter((entry) => isPendingDelegation(entry, nowMilliseconds));
75
+ const others = delegations.filter((entry) => entry.state !== "pending");
76
+
77
+ return (
78
+ <div data-news-desk-admin-panel="delegations">
79
+ <article className="news-desk-doctrine-card" data-delegation-pending-block>
80
+ <div className="news-desk-doctrine-card__header">
81
+ <div>
82
+ <h3>Pending requests</h3>
83
+ <span>An agent asked to record your decisions. Check the code matches what it showed you.</span>
84
+ </div>
85
+ </div>
86
+ {errorMessage ? <p className="news-desk-delegation-error" role="alert" data-delegation-error>{errorMessage}</p> : null}
87
+ {pending.length === 0 ? (
88
+ <p className="news-desk-delegation-note" data-delegation-pending-empty>No agent is waiting for approval. A request appears here as soon as an agent asks.</p>
89
+ ) : (
90
+ <ul className="news-desk-delegation-pending-list">
91
+ {pending.map((entry) => {
92
+ const userCode = entry.userCode ?? "";
93
+ const working = busyCode === userCode;
94
+ return (
95
+ <li key={entry.delegationId} data-delegation-pending={entry.delegationId}>
96
+ <div>
97
+ <strong>{entry.agentLabel}</strong>
98
+ <span>{requestedHoursText(entry.requestedHours)}</span>
99
+ <span className="news-desk-delegation-countdown" data-delegation-countdown>{pendingCountdownText(entry.pendingExpiresAt ?? entry.expiresAt, nowMilliseconds)}</span>
100
+ </div>
101
+ <code className="news-desk-delegation-code" data-delegation-user-code aria-label={`Code ${userCode.split("").join(" ")}`}>{userCode}</code>
102
+ <span className="news-desk-delegation-decision">
103
+ <button type="button" data-delegation-approve onClick={() => void decide(userCode, "approve")} disabled={disabled || working || busyCode !== null}>Approve</button>
104
+ <button type="button" data-delegation-deny onClick={() => void decide(userCode, "deny")} disabled={disabled || working || busyCode !== null}>Deny</button>
105
+ </span>
106
+ </li>
107
+ );
108
+ })}
109
+ </ul>
110
+ )}
111
+ <p className="news-desk-delegation-note" data-delegation-words-note>
112
+ While a delegation lasts, that agent&apos;s approvals and rejections are recorded under your name and marked as relayed, with the words you gave it kept with each record. You can revoke it at any moment.
113
+ </p>
114
+ </article>
115
+
116
+ <article className="news-desk-doctrine-card">
117
+ <div className="news-desk-doctrine-card__header">
118
+ <div>
119
+ <h3>Delegations</h3>
120
+ <span>Decided requests, newest first</span>
121
+ </div>
122
+ </div>
123
+ {others.length === 0 ? (
124
+ <p className="news-desk-delegation-note" data-delegation-empty>You have not granted any delegations.</p>
125
+ ) : (
126
+ <ul className="news-desk-delegation-list">
127
+ {others.map((entry) => {
128
+ const stateText = delegationStateText(entry, nowMilliseconds);
129
+ const live = entry.state === "approved" && !entry.revokedAt && stateText !== "Expired";
130
+ return (
131
+ <li key={entry.delegationId} data-delegation-row={entry.delegationId} data-delegation-live={live || undefined}>
132
+ <div>
133
+ <strong>{entry.agentLabel}</strong>
134
+ <span>{`Granted by ${entry.grantorLabel ?? "you"}`}</span>
135
+ </div>
136
+ <span className="news-desk-delegation-state">{stateText}</span>
137
+ {live ? (
138
+ confirmingRevokeId === entry.delegationId ? (
139
+ <span className="news-desk-delegation-confirm">
140
+ <span>{`Stop ${entry.agentLabel} from curating for you?`}</span>
141
+ <button type="button" data-delegation-revoke-confirm onClick={() => void handleRevoke(entry.delegationId)} disabled={busy}>Revoke now</button>
142
+ <button type="button" onClick={() => setConfirmingRevokeId(null)} disabled={busy}>Keep</button>
143
+ </span>
144
+ ) : (
145
+ <button type="button" data-delegation-revoke onClick={() => setConfirmingRevokeId(entry.delegationId)} disabled={disabled || busy}>Revoke</button>
146
+ )
147
+ ) : null}
148
+ </li>
149
+ );
150
+ })}
151
+ </ul>
152
+ )}
153
+ </article>
154
+ </div>
155
+ );
156
+ }
@@ -0,0 +1,104 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
5
+ import type { CurationDelegationRecord } from "../lib/curation-delegation-helpers";
6
+ import { configureAmplifyClient } from "./amplify-client-provider";
7
+
8
+ const USER_POOL_AUTH_MODE = "userPool";
9
+
10
+ const DELEGATION_FIELDS = "delegationId agentLabel grantorLabel expiresAt revokedAt state userCode requestedHours pendingExpiresAt approvedAt deniedAt";
11
+
12
+ const APPROVE_MUTATION = `
13
+ mutation ApproveCurationDelegation($userCode: String!) {
14
+ approveCurationDelegation(userCode: $userCode) { ${DELEGATION_FIELDS} }
15
+ }
16
+ `;
17
+
18
+ const DENY_MUTATION = `
19
+ mutation DenyCurationDelegation($userCode: String!) {
20
+ denyCurationDelegation(userCode: $userCode) { ${DELEGATION_FIELDS} }
21
+ }
22
+ `;
23
+
24
+ const REVOKE_MUTATION = `
25
+ mutation RevokeCurationDelegation($delegationId: ID!) {
26
+ revokeCurationDelegation(delegationId: $delegationId) {
27
+ ${DELEGATION_FIELDS}
28
+ }
29
+ }
30
+ `;
31
+
32
+ const LIST_QUERY = `
33
+ query ListCurationDelegations {
34
+ listCurationDelegations {
35
+ ${DELEGATION_FIELDS}
36
+ }
37
+ }
38
+ `;
39
+
40
+ function previewPendingRequest(agentLabel: string, userCode: string, requestedHours: number, minutesLeft: number, id: string): CurationDelegationRecord {
41
+ const pendingExpiresAt = new Date(Date.now() + minutesLeft * 60_000).toISOString();
42
+ return { delegationId: id, agentLabel, grantorLabel: null, expiresAt: pendingExpiresAt, revokedAt: null, state: "pending", userCode, requestedHours, pendingExpiresAt, approvedAt: null, deniedAt: null };
43
+ }
44
+
45
+ const previewDelegations: CurationDelegationRecord[] = [
46
+ previewPendingRequest("e2e-check", "WDJB-MJHT", 1, 9.5, "curation-delegation-preview-pending-1"),
47
+ previewPendingRequest("section-curator", "KQPR-4XNZ", 8, 4, "curation-delegation-preview-pending-2"),
48
+ ];
49
+
50
+ function normalizedCode(code: string): string {
51
+ return code.replace(/[^a-z0-9]/gi, "").toUpperCase();
52
+ }
53
+
54
+ async function runGraphql<T>(query: string, variables: Record<string, unknown>): Promise<T> {
55
+ configureAmplifyClient();
56
+ const client = generateClient() as unknown as {
57
+ graphql: (options: Record<string, unknown>) => Promise<{ data?: T | null; errors?: Array<{ message?: string }> | null }>;
58
+ };
59
+ const response = await client.graphql({ query, variables, authMode: USER_POOL_AUTH_MODE });
60
+ if (response.errors?.length) throw Object.assign(new Error(response.errors[0]?.message ?? "Request failed."), { errors: response.errors });
61
+ if (!response.data) throw new Error("The server returned no result.");
62
+ return response.data;
63
+ }
64
+
65
+ export async function approveCurationDelegation(userCode: string): Promise<CurationDelegationRecord> {
66
+ if (isLocalPreviewEnabled()) {
67
+ const record = previewDelegations.find((entry) => entry.state === "pending" && normalizedCode(entry.userCode ?? "") === normalizedCode(userCode));
68
+ if (!record) throw new Error("That code was not found, has expired, or was already decided.");
69
+ const now = Date.now();
70
+ Object.assign(record, { state: "approved", userCode: null, grantorLabel: "ryan@example.com", approvedAt: new Date(now).toISOString(), expiresAt: new Date(now + (record.requestedHours ?? 1) * 3_600_000).toISOString() });
71
+ return { ...record };
72
+ }
73
+ const data = await runGraphql<{ approveCurationDelegation: CurationDelegationRecord }>(APPROVE_MUTATION, { userCode });
74
+ return data.approveCurationDelegation;
75
+ }
76
+
77
+ export async function denyCurationDelegation(userCode: string): Promise<CurationDelegationRecord> {
78
+ if (isLocalPreviewEnabled()) {
79
+ const record = previewDelegations.find((entry) => entry.state === "pending" && normalizedCode(entry.userCode ?? "") === normalizedCode(userCode));
80
+ if (!record) throw new Error("That code was not found, has expired, or was already decided.");
81
+ Object.assign(record, { state: "denied", userCode: null, deniedAt: new Date().toISOString() });
82
+ return { ...record };
83
+ }
84
+ const data = await runGraphql<{ denyCurationDelegation: CurationDelegationRecord }>(DENY_MUTATION, { userCode });
85
+ return data.denyCurationDelegation;
86
+ }
87
+
88
+ export async function revokeCurationDelegation(delegationId: string): Promise<CurationDelegationRecord> {
89
+ if (isLocalPreviewEnabled()) {
90
+ const record = previewDelegations.find((entry) => entry.delegationId === delegationId);
91
+ if (!record) throw new Error("That delegation was not found.");
92
+ record.revokedAt = record.revokedAt ?? new Date().toISOString();
93
+ record.state = "revoked";
94
+ return { ...record };
95
+ }
96
+ const data = await runGraphql<{ revokeCurationDelegation: CurationDelegationRecord }>(REVOKE_MUTATION, { delegationId });
97
+ return data.revokeCurationDelegation;
98
+ }
99
+
100
+ export async function listCurationDelegations(): Promise<CurationDelegationRecord[]> {
101
+ if (isLocalPreviewEnabled()) return previewDelegations.map((entry) => ({ ...entry }));
102
+ const data = await runGraphql<{ listCurationDelegations: CurationDelegationRecord[] }>(LIST_QUERY, {});
103
+ return data.listCurationDelegations ?? [];
104
+ }
@@ -0,0 +1,63 @@
1
+ import Link from "next/link";
2
+ import type { ItemStream } from "../lib/item-stream";
3
+ import { toPublicReaderPath } from "../lib/reader-base-path";
4
+ import { SITE_BRAND } from "../lib/site-brand";
5
+
6
+ /** The home of a publication without editions: a masthead, section filters and the newest items. */
7
+ export function ItemStreamView({ stream }: { stream: ItemStream }) {
8
+ const homePath = toPublicReaderPath("/");
9
+ const tagline = SITE_BRAND.mastheadTagline ?? null;
10
+
11
+ return (
12
+ <main className="presentation-page presentation-page--blog" data-presentation-engine="item-stream">
13
+ <header className="presentation-header">
14
+ <div className="presentation-header__copy-stack">
15
+ <h1>
16
+ <Link href={homePath}>{SITE_BRAND.mastheadTitle}</Link>
17
+ </h1>
18
+ <div className="presentation-header__meta">
19
+ <span className="presentation-header__subtitle">{SITE_BRAND.mastheadSubtitle}</span>
20
+ </div>
21
+ {tagline ? <span className="presentation-header__tagline">{tagline}</span> : null}
22
+ </div>
23
+ </header>
24
+ {stream.sections.length > 1 ? (
25
+ <nav className="presentation-section-nav" aria-label="Sections">
26
+ <Link href={homePath} aria-current={stream.activeSectionKey === null ? "page" : undefined}>
27
+ All
28
+ </Link>
29
+ {stream.sections.map((section) => (
30
+ <Link
31
+ aria-current={stream.activeSectionKey === section.key ? "page" : undefined}
32
+ href={`${homePath}?section=${encodeURIComponent(section.key)}`}
33
+ key={section.key}
34
+ >
35
+ {section.label}
36
+ </Link>
37
+ ))}
38
+ </nav>
39
+ ) : null}
40
+ {stream.items.length === 0 ? (
41
+ <section className="item-stream-empty" data-item-stream-empty="true">
42
+ <p>Nothing published yet</p>
43
+ </section>
44
+ ) : (
45
+ <div className="blog-sections">
46
+ {stream.items.map((item) => (
47
+ <article className="presentation-item presentation-item--blog" data-item-id={item.slug} key={item.slug}>
48
+ <div className="presentation-item__copy">
49
+ <header className="presentation-item__header">
50
+ <p>{item.section ?? "General"}</p>
51
+ <h2>
52
+ <Link href={toPublicReaderPath(`/articles/${encodeURIComponent(item.slug)}`)}>{item.headline}</Link>
53
+ </h2>
54
+ {item.deck ? <span>{item.deck}</span> : null}
55
+ </header>
56
+ </div>
57
+ </article>
58
+ ))}
59
+ </div>
60
+ )}
61
+ </main>
62
+ );
63
+ }
@@ -21,6 +21,8 @@ type PreviewContext = {
21
21
  sidecar: PreviewSidecar;
22
22
  citationNumbers: Map<string, number>;
23
23
  imageUrls: Record<string, string>;
24
+ dynamicDirectives: Record<string, (attributes: Record<string, unknown>) => ReactNode>;
25
+ renderLink?: (href: string, children: ReactNode) => ReactNode | null;
24
26
  };
25
27
 
26
28
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -62,7 +64,7 @@ function imagePlaceholder(token: string, context: PreviewContext): ReactNode {
62
64
  // eslint-disable-next-line @next/next/no-img-element
63
65
  <img
64
66
  alt={alt}
65
- className="my-3 h-auto max-w-full rounded-lg"
67
+ className="my-3 h-auto max-w-full"
66
68
  data-markus-preview-image-loaded
67
69
  loading="lazy"
68
70
  src={url}
@@ -112,7 +114,9 @@ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactN
112
114
  return <s key={index}>{renderInlines(node.children, context)}</s>;
113
115
  case "code_span":
114
116
  return <code className="rounded bg-muted px-1 py-0.5 font-mono text-[0.85em]" key={index}>{node.code}</code>;
115
- case "link":
117
+ case "link": {
118
+ const custom = node.href ? context.renderLink?.(node.href, renderInlines(node.children, context)) : null;
119
+ if (custom) return <span key={index}>{custom}</span>;
116
120
  return node.href ? (
117
121
  <a className="text-primary underline" href={node.href} key={index} rel="noopener noreferrer" target="_blank">
118
122
  {renderInlines(node.children, context)}
@@ -120,6 +124,7 @@ function renderInlines(inlines: MarkusInline[], context: PreviewContext): ReactN
120
124
  ) : (
121
125
  <span key={index}>{renderInlines(node.children, context)}</span>
122
126
  );
127
+ }
123
128
  case "image":
124
129
  return (
125
130
  <span className="rounded border border-dashed border-border px-1 text-xs text-muted-foreground" key={index}>
@@ -228,6 +233,36 @@ function renderBlock(block: MarkusBlock, index: number, context: PreviewContext)
228
233
  }
229
234
  }
230
235
 
236
+ function normalizedFigureSource(src: string): string {
237
+ return src.replace(/^(\.\.?\/)+/, "");
238
+ }
239
+
240
+ function renderFigure(directive: MarkusDirective, index: number, context: PreviewContext): ReactNode {
241
+ const attribute = (name: string) => typeof directive.attributes[name] === "string" ? String(directive.attributes[name]) : "";
242
+ const src = attribute("src");
243
+ const alt = attribute("alt");
244
+ const caption = attribute("caption");
245
+ const credit = attribute("credit");
246
+ const url = context.imageUrls[src] ?? context.imageUrls[normalizedFigureSource(src)];
247
+ return (
248
+ <figure className="my-4" data-markus-preview-directive="figure" key={index}>
249
+ {url ? (
250
+ // eslint-disable-next-line @next/next/no-img-element
251
+ <img alt={alt} className="h-auto max-w-full" data-markus-preview-image-loaded loading="lazy" src={url} />
252
+ ) : (
253
+ <div className="border border-dashed border-border bg-muted/30 px-3 py-6 text-center text-xs text-muted-foreground" data-markus-preview-image>
254
+ {`Figure: ${src}${alt ? ` (${alt})` : ""}`}
255
+ </div>
256
+ )}
257
+ {caption || credit ? (
258
+ <figcaption className="mt-1 text-sm text-muted-foreground">
259
+ {caption}{caption && credit ? " " : ""}{credit ? <span className="text-xs">{credit}</span> : null}
260
+ </figcaption>
261
+ ) : null}
262
+ </figure>
263
+ );
264
+ }
265
+
231
266
  function renderDirective(directive: MarkusDirective, index: number, context: PreviewContext): ReactNode {
232
267
  if (directive.name === "pull-quote") {
233
268
  const attribution = directive.attributes.attribution;
@@ -244,6 +279,9 @@ function renderDirective(directive: MarkusDirective, index: number, context: Pre
244
279
  </blockquote>
245
280
  );
246
281
  }
282
+ if (directive.name === "figure") return renderFigure(directive, index, context);
283
+ const dynamicRenderer = context.dynamicDirectives[directive.name];
284
+ if (dynamicRenderer) return <div data-markus-preview-directive={directive.name} key={index}>{dynamicRenderer(directive.attributes)}</div>;
247
285
  const attributeEntries = Object.entries(directive.attributes).filter(([, value]) => value !== null && value !== "");
248
286
  return (
249
287
  <section
@@ -275,9 +313,14 @@ function renderBlocks(nodes: MarkusNode[], context: PreviewContext): ReactNode[]
275
313
  export function MarkusIrPreview({
276
314
  bodyIr,
277
315
  imageUrls = {},
316
+ dynamicDirectives = {},
317
+ renderLink,
278
318
  }: {
279
319
  bodyIr: unknown;
280
320
  imageUrls?: Record<string, string>;
321
+ /** Live components for leaf directives whose content the host app computes, keyed by directive name. */
322
+ dynamicDirectives?: PreviewContext["dynamicDirectives"];
323
+ renderLink?: PreviewContext["renderLink"];
281
324
  }) {
282
325
  try {
283
326
  const envelope = typeof bodyIr === "string" ? JSON.parse(bodyIr) : bodyIr;
@@ -288,6 +331,8 @@ export function MarkusIrPreview({
288
331
  sidecar,
289
332
  citationNumbers: numberCitationsByFirstAppearance(envelope.document, sidecar),
290
333
  imageUrls,
334
+ dynamicDirectives,
335
+ renderLink,
291
336
  };
292
337
  return <div data-markus-preview>{renderBlocks(document.children, context)}</div>;
293
338
  } catch (error) {