@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.
- package/amplify/data/resource.js +134 -3
- package/amplify/data/resource.ts +145 -3
- package/amplify/data/schema.js +131 -0
- package/amplify/data/schema.ts +142 -0
- package/amplify/functions/category-action/handler.ts +272 -20
- 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/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/renditions/requirements.txt +1 -1
- package/amplify/functions/shared/curation-delegation-flow.ts +147 -0
- package/amplify/functions/shared/curation-delegation.ts +115 -0
- package/amplify/functions/shared/python-bundle.js +3 -3
- package/amplify/functions/shared/python-bundle.ts +3 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +51 -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/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -1
- package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +2 -1
- package/app/[year]/[month]/[day]/page.tsx +3 -0
- package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +2 -0
- package/app/api/archive/editions/route.ts +2 -0
- package/app/api/auth/prospectus/accounts/route.ts +9 -0
- package/app/api/local-preview/derive/route.ts +28 -0
- package/app/api/local-preview/media/route.ts +25 -0
- package/app/archive/page.tsx +3 -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 +8 -1
- package/app/newsroom/articles/page.tsx +10 -0
- package/app/newsroom/insights/[threadId]/page.tsx +5 -16
- package/app/newsroom/insights/page.tsx +4 -12
- package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
- package/app/page.tsx +30 -1
- package/components/administration-delegations-panel.tsx +156 -0
- package/components/curation-delegation-client.ts +104 -0
- package/components/item-stream-view.tsx +63 -0
- package/components/markus-ir-preview.tsx +47 -2
- package/components/news-desk-client-provider.tsx +17 -2
- package/components/news-desk-page.tsx +0 -1
- package/components/news-desk-taxonomy-client.ts +154 -240
- package/components/newspaper.tsx +4 -4
- package/components/newsroom-article-editor.tsx +138 -40
- package/components/newsroom-assignment-detail.tsx +79 -0
- package/components/newsroom-assignments-view.tsx +1 -1
- package/components/newsroom-board-view.tsx +153 -43
- package/components/newsroom-client-shell.tsx +8 -1
- package/components/newsroom-newsprint-board.css +5 -46
- package/components/newsroom-ops-overview.tsx +27 -5
- package/components/newsroom-organizer-shell.tsx +36 -13
- package/components/newsroom-organizer.css +269 -13
- package/components/newsroom-references-view.tsx +118 -23
- package/components/newsroom-topics-view.tsx +1 -1
- package/components/priming-proof-page.tsx +17 -8
- package/components/prospectus-account-chooser.tsx +131 -0
- package/components/reference-cyclotron-priming.tsx +85 -0
- package/components/reference-cyclotron-status.tsx +5 -5
- package/components/reference-cyclotron-suggestion.tsx +143 -0
- package/components/reference-live-agreement.tsx +62 -0
- package/components/reference-section-suggestion.tsx +104 -0
- package/components/story-reporting-trail-client.ts +91 -0
- package/components/story-reporting-trail.tsx +115 -0
- package/components/topic-steering-workspace.tsx +280 -679
- package/components/ui/sheet.tsx +10 -4
- package/components/use-paged-content-item-versions.ts +151 -0
- package/components/wiki-concepts-view.tsx +153 -0
- package/components/wiki-index-client.ts +51 -0
- package/infra/amplify-app-shell.js +17 -3
- package/infra/amplify-app-shell.ts +18 -3
- package/infra/site-config.js +6 -1
- package/infra/site-config.ts +7 -1
- package/lib/approved-priming-proof-reports.ts +6 -3
- package/lib/approved-priming-proofs/papyrus.json +90 -0
- package/lib/article-flow.ts +95 -0
- package/lib/article-frame-solver.ts +519 -0
- package/lib/blog-feature-solver.ts +1 -109
- package/lib/category-repository.ts +1 -1
- package/lib/chrome-text.ts +504 -0
- package/lib/content-actions-client.ts +3 -0
- package/lib/content-actions-demo.ts +55 -0
- package/lib/content-item-lifecycle.ts +34 -5
- package/lib/content-source-context.ts +9 -1
- package/lib/curation-delegation-helpers.ts +69 -0
- package/lib/cyclotron-suggestions.ts +263 -0
- package/lib/front-matter-fields.ts +42 -0
- package/lib/front-mosaic.ts +1133 -0
- package/lib/furniture.ts +385 -0
- package/lib/graphql-content-repository.ts +42 -7
- package/lib/item-stream.ts +47 -0
- package/lib/labels.ts +37 -0
- package/lib/layout-config.ts +237 -0
- package/lib/layout-plan.ts +40 -11
- package/lib/layout-scenarios.ts +21 -11
- package/lib/layout-types.ts +457 -0
- package/lib/layout-warnings.ts +8 -0
- package/lib/local-preview-section-fixtures.ts +72 -0
- package/lib/local-preview-seed.ts +286 -0
- package/lib/local-preview-tenant.ts +27 -0
- package/lib/local-preview.ts +3 -0
- package/lib/newspaper-layout.ts +17 -3410
- package/lib/newsroom-articles.ts +2 -0
- package/lib/newsroom-assignments.ts +14 -1
- package/lib/newsroom-board.ts +99 -47
- package/lib/newsroom-demo-dashboard.ts +1 -1
- 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 +9 -16
- 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/pretext-layout.ts +22 -0
- package/lib/priming-proof-report.ts +1 -0
- package/lib/prospectus-account-client.ts +90 -0
- package/lib/prospectus-sign-in-routes.ts +45 -0
- package/lib/prospectus-site-identity.ts +13 -1
- package/lib/prospectus-tenant.ts +22 -0
- package/lib/reader-static-params.ts +2 -1
- package/lib/relevance-decisions.ts +29 -0
- package/lib/relevance-status.ts +73 -0
- package/lib/reporting-story-budget.ts +2 -2
- package/lib/semantic-graph.ts +5 -5
- package/lib/site-brand.ts +28 -1
- package/lib/site-env.ts +5 -0
- package/lib/site-section-suggestions.ts +120 -0
- package/lib/site-stack.ts +1 -1
- package/lib/story-reporting-trail.ts +106 -0
- package/lib/tenant-keys.ts +32 -0
- package/lib/text-columns.ts +251 -0
- package/lib/wiki.ts +56 -0
- package/package.json +46 -3
- package/renderers/pretext/presentation-shell.tsx +1 -1
- package/renderers/pretext/rhythm-article-page.tsx +1 -18
- package/routes.manifest.json +34 -15
- 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:
|
|
38
|
-
{ url:
|
|
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 = "
|
|
82
|
-
const darkHref = "
|
|
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
|
|
|
@@ -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 {
|
|
1
|
+
import { permanentRedirect } from "next/navigation";
|
|
2
|
+
import { toPublicNewsroomPath } from "../../../../lib/newsroom-base-path";
|
|
2
3
|
|
|
3
|
-
|
|
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
|
|
8
|
+
export default async function NewsroomMessageThreadRedirectPage({ params }: NewsroomMessageThreadRedirectPageProps) {
|
|
14
9
|
const { threadId } = await params;
|
|
15
|
-
|
|
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 {
|
|
1
|
+
import { permanentRedirect } from "next/navigation";
|
|
2
|
+
import { toPublicNewsroomPath } from "../../../lib/newsroom-base-path";
|
|
2
3
|
|
|
3
|
-
export
|
|
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'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
|
|
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) {
|