@anthusai/papyrus 1.0.0-next.4 → 1.0.0-next.40
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/auth/redirect-urls.js +54 -0
- package/amplify/auth/redirect-urls.ts +66 -0
- package/amplify/auth/resource.js +20 -5
- package/amplify/auth/resource.ts +27 -5
- package/amplify/data/resource.js +28 -70
- package/amplify/data/resource.ts +29 -70
- package/amplify/data/schema.js +33 -58
- package/amplify/data/schema.ts +34 -58
- package/amplify/functions/category-action/handler.ts +5 -3
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +45 -3
- package/amplify/functions/shared/reference-curation-metadata.ts +29 -0
- package/amplify/inbound-email/ses-intake.js +86 -0
- package/amplify/inbound-email/ses-intake.ts +110 -0
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/resolver-waves.js +26 -0
- package/amplify/resolver-waves.ts +34 -0
- package/amplify/site-backend-config.ts +36 -1
- package/amplify/site-backend-names.js +103 -0
- package/amplify/site-backend-names.ts +166 -0
- package/amplify/site-backend.js +93 -170
- package/amplify/site-backend.ts +126 -196
- package/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/amplify/storage-backups/construct.js +31 -0
- package/amplify/storage-backups/construct.ts +47 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -0
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/api/media/[...path]/route.ts +5 -16
- package/app/api/revalidate/route.ts +8 -7
- package/app/articles/[slug]/page.tsx +6 -4
- package/app/blog-rhythm.css +278 -0
- package/app/globals.css +21 -0
- package/app/layout.tsx +16 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +6 -5
- package/app/newsroom/[section]/page.tsx +4 -3
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/app/newsroom/layout.tsx +4 -9
- package/app/page.tsx +18 -5
- package/app/robots.ts +2 -5
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +29 -3
- package/components/amplify-client-provider.tsx +4 -22
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +247 -3
- package/components/blog-page-background.tsx +23 -6
- package/components/markus-ir-preview.tsx +300 -0
- package/components/news-desk-taxonomy-client.ts +51 -0
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +67 -0
- package/components/newsroom-article-editor.tsx +741 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-client-shell.tsx +12 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/newsroom-references-view.tsx +83 -8
- package/components/papyrus-console-shell.tsx +4 -2
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/reference-cyclotron-status.tsx +109 -0
- package/components/reference-relevance-review.tsx +75 -0
- package/components/settings-page.tsx +2 -1
- package/components/topic-steering-workspace.tsx +54 -64
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +212 -50
- package/infra/amplify-app-shell.ts +240 -53
- package/infra/build-specs.js +14 -5
- package/infra/build-specs.ts +17 -7
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +167 -10
- package/infra/site-config.ts +196 -14
- package/lib/amplify-outputs-path.ts +5 -1
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +12 -0
- package/lib/category-repository.ts +42 -93
- package/lib/content-actions-demo.ts +190 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-source-context.ts +12 -2
- package/lib/content-types.ts +4 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/graphql-content-repository.ts +61 -9
- package/lib/media-route-response.ts +45 -0
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-base-path.ts +12 -3
- package/lib/newsroom-category-drilldown.ts +3 -2
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-forum-routes.ts +6 -4
- package/lib/newsroom-index-filters.ts +14 -10
- package/lib/newsroom-media-backend.ts +142 -0
- package/lib/newsroom-media.ts +182 -0
- package/lib/newsroom-nav.ts +3 -0
- package/lib/oauth-redirect-priority.ts +40 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-static-params.ts +13 -0
- package/lib/reference-policy.ts +5 -0
- package/lib/relevance-decisions.ts +103 -0
- package/lib/relevance-status.ts +63 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/robots-policy.ts +14 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/semantic-graph.ts +7 -0
- package/lib/site-brand.ts +51 -0
- package/lib/staging-gated-path.ts +23 -0
- package/lib/staging-preview-object.ts +114 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +77 -0
- package/middleware.ts +23 -18
- package/package.json +45 -1
- package/renderers/pretext/article-page.tsx +39 -3
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +15 -102
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +45 -2
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- package/scripts/videoml/standard-entry.tsx +46 -0
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.mjs +19 -2
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { PlusIcon } from "lucide-react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { Badge } from "./ui/badge";
|
|
7
|
+
import { buttonVariants } from "./ui/button";
|
|
8
|
+
import { Spinner } from "./ui/spinner";
|
|
9
|
+
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
|
10
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
11
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
12
|
+
import { newsroomHref } from "../lib/newsroom-base-path";
|
|
13
|
+
import {
|
|
14
|
+
resolveArticlesBackend,
|
|
15
|
+
rowStatusLabel,
|
|
16
|
+
type NewsroomArticleRow,
|
|
17
|
+
} from "../lib/newsroom-articles";
|
|
18
|
+
import { cn } from "../lib/utils";
|
|
19
|
+
import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
20
|
+
import { NewsDeskAccessGate } from "./newsroom-access-gate";
|
|
21
|
+
import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
|
|
22
|
+
|
|
23
|
+
type ArticleFilter = "all" | "drafts" | "published";
|
|
24
|
+
|
|
25
|
+
export function articlesAccessBlocked(phase: string | undefined): boolean {
|
|
26
|
+
return phase === undefined || phase === "checkingAccess" || phase === "signedOut" || phase === "forbidden" || phase === "error";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function NewsroomArticlesView({ demo }: { demo: boolean }) {
|
|
30
|
+
const session = useOptionalNewsDeskClient();
|
|
31
|
+
const blocked = !demo && articlesAccessBlocked(session?.shell.phase);
|
|
32
|
+
const [rows, setRows] = useState<NewsroomArticleRow[] | null>(null);
|
|
33
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
34
|
+
const [filter, setFilter] = useState<ArticleFilter>("all");
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (blocked) return;
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
resolveArticlesBackend(demo)
|
|
40
|
+
.listRows()
|
|
41
|
+
.then((loaded) => {
|
|
42
|
+
if (!cancelled) setRows(loaded);
|
|
43
|
+
})
|
|
44
|
+
.catch((error: unknown) => {
|
|
45
|
+
if (!cancelled) setLoadError(error instanceof Error ? error.message : "Could not load articles.");
|
|
46
|
+
});
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
}, [blocked, demo]);
|
|
51
|
+
|
|
52
|
+
const visibleRows = useMemo(() => {
|
|
53
|
+
if (!rows) return [];
|
|
54
|
+
if (filter === "drafts") return rows.filter((row) => row.status === "draft");
|
|
55
|
+
if (filter === "published") return rows.filter((row) => row.status === "published");
|
|
56
|
+
return rows;
|
|
57
|
+
}, [filter, rows]);
|
|
58
|
+
|
|
59
|
+
if (blocked) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<NewsroomOpsShell
|
|
63
|
+
activeTab="articles"
|
|
64
|
+
appTitle={SITE_BRAND.appTitle}
|
|
65
|
+
backHref="/"
|
|
66
|
+
backLabel={SITE_BRAND.backToHomeLabel}
|
|
67
|
+
demo={demo}
|
|
68
|
+
headerActions={(
|
|
69
|
+
<>
|
|
70
|
+
{demo ? <Badge variant="outline">Demo</Badge> : null}
|
|
71
|
+
<Link
|
|
72
|
+
className={cn(buttonVariants({ size: "sm" }))}
|
|
73
|
+
data-newsroom-article-new
|
|
74
|
+
href={getNewsroomNavHref(newsroomHref("articles", "new"), demo)}
|
|
75
|
+
>
|
|
76
|
+
<PlusIcon aria-hidden="true" />
|
|
77
|
+
New article
|
|
78
|
+
</Link>
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
81
|
+
pageTitle="Articles"
|
|
82
|
+
>
|
|
83
|
+
<div className="space-y-4" data-newsroom-articles>
|
|
84
|
+
<Tabs defaultValue="all" onValueChange={(value) => setFilter(value as ArticleFilter)} value={filter}>
|
|
85
|
+
<TabsList>
|
|
86
|
+
<TabsTrigger className="min-w-[5.5rem]" value="all">All</TabsTrigger>
|
|
87
|
+
<TabsTrigger className="min-w-[5.5rem]" value="drafts">Drafts</TabsTrigger>
|
|
88
|
+
<TabsTrigger className="min-w-[5.5rem]" value="published">Published</TabsTrigger>
|
|
89
|
+
</TabsList>
|
|
90
|
+
</Tabs>
|
|
91
|
+
{loadError ? <NewsroomOpsStatusBanner tone="error">{loadError}</NewsroomOpsStatusBanner> : null}
|
|
92
|
+
{rows === null && !loadError ? (
|
|
93
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
94
|
+
<Spinner /> Loading articles…
|
|
95
|
+
</div>
|
|
96
|
+
) : null}
|
|
97
|
+
{rows !== null && visibleRows.length === 0 ? (
|
|
98
|
+
<p className="text-sm text-muted-foreground">Nothing here yet.</p>
|
|
99
|
+
) : null}
|
|
100
|
+
<ul className="divide-y divide-border rounded-xl border border-border bg-card" data-newsroom-articles-list>
|
|
101
|
+
{visibleRows.map((row) => (
|
|
102
|
+
<li key={row.id}>
|
|
103
|
+
<Link
|
|
104
|
+
className="flex flex-col gap-1 px-4 py-3 hover:bg-muted/40 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
|
105
|
+
data-newsroom-article-row={row.slug}
|
|
106
|
+
href={getNewsroomNavHref(newsroomHref("articles", row.id), demo)}
|
|
107
|
+
>
|
|
108
|
+
<span className="min-w-0">
|
|
109
|
+
<span className="block truncate text-sm font-medium">{row.title}</span>
|
|
110
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
111
|
+
{row.slug}
|
|
112
|
+
{row.section ? ` · ${row.section}` : ""}
|
|
113
|
+
{row.type !== "article" ? ` · ${row.type}` : ""}
|
|
114
|
+
</span>
|
|
115
|
+
</span>
|
|
116
|
+
<Badge
|
|
117
|
+
data-newsroom-article-status
|
|
118
|
+
variant={row.status === "draft" ? "muted" : row.pending ? "outline" : "secondary"}
|
|
119
|
+
>
|
|
120
|
+
{rowStatusLabel(row)}
|
|
121
|
+
</Badge>
|
|
122
|
+
</Link>
|
|
123
|
+
</li>
|
|
124
|
+
))}
|
|
125
|
+
</ul>
|
|
126
|
+
</div>
|
|
127
|
+
</NewsroomOpsShell>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { NewsDeskClientProvider } from "./news-desk-client-provider";
|
|
2
|
+
import { resolveDevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
3
|
+
|
|
4
|
+
export function NewsroomClientShell({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
5
|
+
const devSandboxEditorAuth = resolveDevSandboxEditorAuth();
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<NewsDeskClientProvider devSandboxEditorAuth={devSandboxEditorAuth}>
|
|
9
|
+
{children}
|
|
10
|
+
</NewsDeskClientProvider>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
import {
|
|
4
4
|
BookOpenIcon,
|
|
5
5
|
ClipboardListIcon,
|
|
6
|
+
FileTextIcon,
|
|
6
7
|
LayersIcon,
|
|
8
|
+
LightbulbIcon,
|
|
7
9
|
MessageSquareIcon,
|
|
10
|
+
NetworkIcon,
|
|
11
|
+
SearchIcon,
|
|
12
|
+
SettingsIcon,
|
|
8
13
|
} from "lucide-react";
|
|
9
14
|
import Link from "next/link";
|
|
10
15
|
import { usePathname } from "next/navigation";
|
|
@@ -12,7 +17,7 @@ import { useMemo } from "react";
|
|
|
12
17
|
import { Badge } from "./ui/badge";
|
|
13
18
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
|
14
19
|
import type { AssignmentRecord, MessageRecord, NewsroomSectionRecord, ReferenceRecord } from "../lib/category-repository";
|
|
15
|
-
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
20
|
+
import { getNewsroomNavHref, NEWSROOM_OPS_NAV, type NewsroomNavTabId } from "../lib/newsroom-nav";
|
|
16
21
|
import {
|
|
17
22
|
countReferencesByStatus,
|
|
18
23
|
filterReferencesByStatus,
|
|
@@ -37,15 +42,28 @@ type NewsroomOpsOverviewProps = {
|
|
|
37
42
|
references: ReferenceRecord[];
|
|
38
43
|
};
|
|
39
44
|
|
|
40
|
-
type DestinationKey =
|
|
45
|
+
type DestinationKey = Exclude<NewsroomNavTabId, "overview" | "desks">;
|
|
41
46
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
assignments:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
const DESTINATION_ICONS: Record<DestinationKey, typeof BookOpenIcon> = {
|
|
48
|
+
articles: FileTextIcon,
|
|
49
|
+
assignments: ClipboardListIcon,
|
|
50
|
+
references: BookOpenIcon,
|
|
51
|
+
messages: MessageSquareIcon,
|
|
52
|
+
insights: LightbulbIcon,
|
|
53
|
+
topics: LayersIcon,
|
|
54
|
+
concepts: NetworkIcon,
|
|
55
|
+
administration: SettingsIcon,
|
|
56
|
+
search: SearchIcon,
|
|
47
57
|
};
|
|
48
58
|
|
|
59
|
+
const DESTINATIONS = NEWSROOM_OPS_NAV.filter((item) => item.id !== "overview").map((item) => ({
|
|
60
|
+
key: item.id as DestinationKey,
|
|
61
|
+
label: item.label,
|
|
62
|
+
detail: item.detail,
|
|
63
|
+
href: item.href,
|
|
64
|
+
icon: DESTINATION_ICONS[item.id as DestinationKey],
|
|
65
|
+
}));
|
|
66
|
+
|
|
49
67
|
function openAssignmentCount(assignments: AssignmentRecord[]): number {
|
|
50
68
|
return assignments.filter((assignment) => {
|
|
51
69
|
const status = assignment.status?.trim().toLowerCase() ?? "";
|
|
@@ -85,11 +103,10 @@ export function NewsroomOpsOverview({
|
|
|
85
103
|
[newsroomSections],
|
|
86
104
|
);
|
|
87
105
|
|
|
88
|
-
const destinationCounts: Record<DestinationKey, number | null
|
|
106
|
+
const destinationCounts: Partial<Record<DestinationKey, number | null>> = {
|
|
89
107
|
references: referenceCounts.pending ?? 0,
|
|
90
108
|
assignments: openAssignmentCount(assignments),
|
|
91
109
|
messages: recentMessageCount(messages),
|
|
92
|
-
topics: null,
|
|
93
110
|
};
|
|
94
111
|
|
|
95
112
|
return (
|
|
@@ -102,8 +119,8 @@ export function NewsroomOpsOverview({
|
|
|
102
119
|
<section aria-label="Destinations" className="space-y-3">
|
|
103
120
|
<h2 className="text-sm font-medium text-muted-foreground">Go to</h2>
|
|
104
121
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
105
|
-
{
|
|
106
|
-
const
|
|
122
|
+
{DESTINATIONS.map((meta) => {
|
|
123
|
+
const key = meta.key;
|
|
107
124
|
const Icon = meta.icon;
|
|
108
125
|
const count = destinationCounts[key];
|
|
109
126
|
const href = getNewsroomNavHref(meta.href, demo);
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
BookOpenIcon,
|
|
5
5
|
ClipboardListIcon,
|
|
6
|
+
FileTextIcon,
|
|
6
7
|
LayoutGridIcon,
|
|
7
8
|
MenuIcon,
|
|
8
9
|
SearchIcon,
|
|
@@ -55,6 +56,7 @@ type NewsroomOpsShellProps = {
|
|
|
55
56
|
|
|
56
57
|
const MOBILE_TAB_ICONS: Record<string, typeof LayoutGridIcon> = {
|
|
57
58
|
overview: LayoutGridIcon,
|
|
59
|
+
articles: FileTextIcon,
|
|
58
60
|
assignments: ClipboardListIcon,
|
|
59
61
|
references: BookOpenIcon,
|
|
60
62
|
topics: TagsIcon,
|
|
@@ -198,7 +200,7 @@ export function NewsroomOpsShell({
|
|
|
198
200
|
className="shrink-0 border-t border-border bg-card/95 pb-[max(env(safe-area-inset-bottom),0.35rem)] pt-1 md:hidden"
|
|
199
201
|
data-newsroom-ops-bottom-nav
|
|
200
202
|
>
|
|
201
|
-
<ul className="grid grid-cols-
|
|
203
|
+
<ul className="grid grid-cols-6 gap-1 px-1">
|
|
202
204
|
{mobilePrimary.map((item) => {
|
|
203
205
|
const Icon = MOBILE_TAB_ICONS[item.id] ?? LayoutGridIcon;
|
|
204
206
|
const active = resolvedActiveTab === item.id;
|
|
@@ -44,21 +44,36 @@ import {
|
|
|
44
44
|
import { normalizeReferenceCurationStatus } from "../lib/reference-policy";
|
|
45
45
|
import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
|
|
46
46
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
47
|
+
import { ReferenceRelevanceReview } from "./reference-relevance-review";
|
|
48
|
+
import { ReferenceCyclotronStatus, type ReviewRateChoice } from "./reference-cyclotron-status";
|
|
49
|
+
import type { CyclotronStatus } from "cyclotron/cyclotron-status";
|
|
50
|
+
import type { RelevanceCuration, RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
47
51
|
import {
|
|
48
52
|
loadReferenceAttachmentsForLineageId,
|
|
49
53
|
loadStoragePathUrl,
|
|
50
54
|
} from "./news-desk-taxonomy-client";
|
|
51
55
|
|
|
52
56
|
type NewsroomReferencesViewProps = {
|
|
57
|
+
/** The relevance cyclotron's status snapshot, shown above the list. */
|
|
58
|
+
cyclotronStatus?: CyclotronStatus | null;
|
|
59
|
+
/** Present for editors who may ask for a manual review rate. */
|
|
60
|
+
onRequestReviewRate?: (choice: ReviewRateChoice) => Promise<void> | void;
|
|
53
61
|
demo?: boolean;
|
|
62
|
+
/** The relevance cyclotron's current decisions, keyed by reference lineage. */
|
|
63
|
+
decisions?: Map<string, RelevanceDecisionView>;
|
|
54
64
|
disabled?: boolean;
|
|
65
|
+
onRelevanceReview?: (reference: ReferenceRecord, curation: RelevanceCuration) => void;
|
|
55
66
|
initialReferenceLineageId?: string | null;
|
|
56
67
|
onReview: (reference: ReferenceRecord, action: ReferenceCurationAction) => void;
|
|
57
68
|
referenceAttachments?: ReferenceAttachmentRecord[];
|
|
58
69
|
references: ReferenceRecord[];
|
|
59
70
|
};
|
|
60
71
|
|
|
61
|
-
|
|
72
|
+
type ReferenceListFilter = ReferenceStatusFilter | "needs_review";
|
|
73
|
+
|
|
74
|
+
const NEEDS_REVIEW_FILTER: { key: ReferenceListFilter; label: string } = { key: "needs_review", label: "Needs review" };
|
|
75
|
+
|
|
76
|
+
const STATUS_FILTERS: Array<{ key: ReferenceListFilter; label: string }> = [
|
|
62
77
|
{ key: "all", label: "All" },
|
|
63
78
|
{ key: "pending", label: "Pending" },
|
|
64
79
|
{ key: "accepted", label: "Accepted" },
|
|
@@ -183,16 +198,30 @@ function useReferencePreviewAttachments(
|
|
|
183
198
|
return previewAttachments;
|
|
184
199
|
}
|
|
185
200
|
|
|
201
|
+
function decisionSummary(decision: RelevanceDecisionView): string {
|
|
202
|
+
const sure = decision.confidence == null ? "confidence unknown" : `${Math.round(decision.confidence * 100)}% sure`;
|
|
203
|
+
return `${decision.label} · ${sure}${decision.version != null ? ` · version ${decision.version}` : ""}`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function needsReview(reference: ReferenceRecord, decision: RelevanceDecisionView | undefined): boolean {
|
|
207
|
+
return Boolean(decision?.reviewRecommended)
|
|
208
|
+
&& normalizeReferenceCurationStatus(reference.curationStatus) === "pending";
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
function ReferenceDetailPanel({
|
|
212
|
+
decision,
|
|
187
213
|
demo,
|
|
188
214
|
disabled,
|
|
215
|
+
onRelevanceReview,
|
|
189
216
|
onReview,
|
|
190
217
|
previewAttachments,
|
|
191
218
|
reference,
|
|
192
219
|
onClose,
|
|
193
220
|
}: {
|
|
221
|
+
decision?: RelevanceDecisionView;
|
|
194
222
|
demo?: boolean;
|
|
195
223
|
disabled?: boolean;
|
|
224
|
+
onRelevanceReview?: (curation: RelevanceCuration) => void;
|
|
196
225
|
onReview: (action: ReferenceCurationAction) => void;
|
|
197
226
|
previewAttachments: ReferenceAttachmentRecord[];
|
|
198
227
|
reference: ReferenceRecord;
|
|
@@ -224,11 +253,25 @@ function ReferenceDetailPanel({
|
|
|
224
253
|
) : null}
|
|
225
254
|
</div>
|
|
226
255
|
|
|
256
|
+
{decision && status === "pending" && onRelevanceReview ? (
|
|
257
|
+
<ReferenceRelevanceReview
|
|
258
|
+
decision={decision}
|
|
259
|
+
disabled={disabled}
|
|
260
|
+
onCurate={onRelevanceReview}
|
|
261
|
+
referenceLineageId={lineageId}
|
|
262
|
+
/>
|
|
263
|
+
) : decision ? (
|
|
264
|
+
<p className="text-sm text-muted-foreground" data-reference-relevance-decision={lineageId}>
|
|
265
|
+
Cyclotron decision: {decisionSummary(decision)}
|
|
266
|
+
</p>
|
|
267
|
+
) : null}
|
|
268
|
+
|
|
227
269
|
<div
|
|
228
270
|
className="flex flex-wrap items-center gap-2"
|
|
229
271
|
data-news-desk-reference-curation-cluster
|
|
230
272
|
data-reference-curation-status={status}
|
|
231
273
|
>
|
|
274
|
+
{decision && status === "pending" && onRelevanceReview ? null : <>
|
|
232
275
|
<Button
|
|
233
276
|
aria-label="Accept reference"
|
|
234
277
|
aria-pressed={status === "accepted"}
|
|
@@ -255,6 +298,7 @@ function ReferenceDetailPanel({
|
|
|
255
298
|
<ThumbsDownIcon className="size-4" />
|
|
256
299
|
Reject
|
|
257
300
|
</Button>
|
|
301
|
+
</>}
|
|
258
302
|
<Button
|
|
259
303
|
aria-label="Archive reference"
|
|
260
304
|
disabled={archiveDisabled}
|
|
@@ -347,8 +391,12 @@ function ReferenceDetailPanel({
|
|
|
347
391
|
}
|
|
348
392
|
|
|
349
393
|
export function NewsroomReferencesView({
|
|
394
|
+
cyclotronStatus,
|
|
395
|
+
decisions,
|
|
396
|
+
onRequestReviewRate,
|
|
350
397
|
demo = false,
|
|
351
398
|
disabled = false,
|
|
399
|
+
onRelevanceReview,
|
|
352
400
|
initialReferenceLineageId = null,
|
|
353
401
|
onReview,
|
|
354
402
|
referenceAttachments = [],
|
|
@@ -365,13 +413,20 @@ export function NewsroomReferencesView({
|
|
|
365
413
|
const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
|
|
366
414
|
const statusCounts = useMemo(() => countReferencesByStatus(canonicalReferences), [canonicalReferences]);
|
|
367
415
|
|
|
368
|
-
const [statusFilter, setStatusFilter] = useState<
|
|
416
|
+
const [statusFilter, setStatusFilter] = useState<ReferenceListFilter>("all");
|
|
417
|
+
const needsReviewCount = useMemo(
|
|
418
|
+
() => canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference)))).length,
|
|
419
|
+
[canonicalReferences, decisions],
|
|
420
|
+
);
|
|
421
|
+
const filters = decisions?.size ? [NEEDS_REVIEW_FILTER, ...STATUS_FILTERS] : STATUS_FILTERS;
|
|
369
422
|
const [selectedLineageId, setSelectedLineageId] = useState(routeLineageId);
|
|
370
423
|
const [mobileDetailOpen, setMobileDetailOpen] = useState(Boolean(routeLineageId) && isMobileDetail);
|
|
371
424
|
|
|
372
425
|
const filteredReferences = useMemo(
|
|
373
|
-
() =>
|
|
374
|
-
|
|
426
|
+
() => statusFilter === "needs_review"
|
|
427
|
+
? canonicalReferences.filter((reference) => needsReview(reference, decisions?.get(referenceLineageId(reference))))
|
|
428
|
+
: filterReferencesByStatus(canonicalReferences, statusFilter),
|
|
429
|
+
[canonicalReferences, decisions, statusFilter],
|
|
375
430
|
);
|
|
376
431
|
|
|
377
432
|
const selectedReference = useMemo(
|
|
@@ -390,7 +445,7 @@ export function NewsroomReferencesView({
|
|
|
390
445
|
? filteredReferences[selectedIndex + 1]
|
|
391
446
|
: null;
|
|
392
447
|
|
|
393
|
-
const syncStatusUrl = useCallback((nextStatus:
|
|
448
|
+
const syncStatusUrl = useCallback((nextStatus: ReferenceListFilter) => {
|
|
394
449
|
if (demo) return;
|
|
395
450
|
syncBrowserNewsroomIndexUrl("references", {
|
|
396
451
|
status: nextStatus === "all" ? "" : nextStatus,
|
|
@@ -452,22 +507,29 @@ export function NewsroomReferencesView({
|
|
|
452
507
|
Review source intake before it becomes accepted evidence.
|
|
453
508
|
</p>
|
|
454
509
|
</div>
|
|
510
|
+
{cyclotronStatus ? (
|
|
511
|
+
<div className="shrink-0">
|
|
512
|
+
<ReferenceCyclotronStatus onRequestRate={disabled ? undefined : onRequestReviewRate} status={cyclotronStatus} />
|
|
513
|
+
</div>
|
|
514
|
+
) : null}
|
|
455
515
|
|
|
456
516
|
<Tabs
|
|
457
517
|
className="shrink-0"
|
|
458
518
|
defaultValue="all"
|
|
459
519
|
onValueChange={(value) => {
|
|
460
|
-
const next = value as
|
|
520
|
+
const next = value as ReferenceListFilter;
|
|
461
521
|
setStatusFilter(next);
|
|
462
522
|
syncStatusUrl(next);
|
|
463
523
|
}}
|
|
464
524
|
value={statusFilter}
|
|
465
525
|
>
|
|
466
526
|
<TabsList className="h-auto w-full flex-wrap justify-start gap-1 bg-muted/40 p-1">
|
|
467
|
-
{
|
|
527
|
+
{filters.map((filter) => {
|
|
468
528
|
const count = filter.key === "all"
|
|
469
529
|
? canonicalReferences.length
|
|
470
|
-
:
|
|
530
|
+
: filter.key === "needs_review"
|
|
531
|
+
? needsReviewCount
|
|
532
|
+
: statusCounts[filter.key] ?? 0;
|
|
471
533
|
return (
|
|
472
534
|
<TabsTrigger className="text-xs sm:text-sm" key={filter.key} value={filter.key}>
|
|
473
535
|
{filter.label}
|
|
@@ -484,6 +546,7 @@ export function NewsroomReferencesView({
|
|
|
484
546
|
const lineageId = referenceLineageId(reference);
|
|
485
547
|
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
486
548
|
const active = selectedLineageId === lineageId;
|
|
549
|
+
const decision = decisions?.get(lineageId);
|
|
487
550
|
return (
|
|
488
551
|
<button
|
|
489
552
|
aria-current={active ? "true" : undefined}
|
|
@@ -504,6 +567,14 @@ export function NewsroomReferencesView({
|
|
|
504
567
|
<Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
|
|
505
568
|
</div>
|
|
506
569
|
<p className="mt-2 text-xs text-muted-foreground">{formatReferenceListDate(reference)}</p>
|
|
570
|
+
{decision ? (
|
|
571
|
+
<p className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground" data-reference-relevance-row={lineageId}>
|
|
572
|
+
<span>Decision: {decisionSummary(decision)}</span>
|
|
573
|
+
{needsReview(reference, decision) ? (
|
|
574
|
+
<Badge variant="outline"><span aria-hidden="true">●</span> Needs review</Badge>
|
|
575
|
+
) : null}
|
|
576
|
+
</p>
|
|
577
|
+
) : null}
|
|
507
578
|
</button>
|
|
508
579
|
);
|
|
509
580
|
}) : (
|
|
@@ -552,8 +623,10 @@ export function NewsroomReferencesView({
|
|
|
552
623
|
<ScrollArea className="min-h-0 flex-1">
|
|
553
624
|
{selectedReference ? (
|
|
554
625
|
<ReferenceDetailPanel
|
|
626
|
+
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
555
627
|
demo={demo}
|
|
556
628
|
disabled={disabled}
|
|
629
|
+
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
557
630
|
onReview={runReview}
|
|
558
631
|
previewAttachments={previewAttachments}
|
|
559
632
|
reference={selectedReference}
|
|
@@ -581,9 +654,11 @@ export function NewsroomReferencesView({
|
|
|
581
654
|
{selectedReference ? (
|
|
582
655
|
<ScrollArea className="max-h-[75dvh]">
|
|
583
656
|
<ReferenceDetailPanel
|
|
657
|
+
decision={decisions?.get(referenceLineageId(selectedReference))}
|
|
584
658
|
demo={demo}
|
|
585
659
|
disabled={disabled}
|
|
586
660
|
onClose={closeDetail}
|
|
661
|
+
onRelevanceReview={onRelevanceReview ? (curation) => onRelevanceReview(selectedReference, curation) : undefined}
|
|
587
662
|
onReview={runReview}
|
|
588
663
|
previewAttachments={previewAttachments}
|
|
589
664
|
reference={selectedReference}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { browserPathToInternalNewsroomPath } from "../lib/newsroom-base-path";
|
|
3
4
|
import type { BotAvatarState } from "anthus-vultus";
|
|
4
5
|
import dynamic from "next/dynamic";
|
|
5
6
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
@@ -152,7 +153,8 @@ export function PapyrusConsoleShell({ children }: PapyrusConsoleShellProps) {
|
|
|
152
153
|
const [session, setSession] = useState<ReaderSessionSnapshot | null>(null);
|
|
153
154
|
const [open, setOpen] = useState(false);
|
|
154
155
|
const [openingReferenceChat, setOpeningReferenceChat] = useState(false);
|
|
155
|
-
const
|
|
156
|
+
const internalPathname = browserPathToInternalNewsroomPath(pathname);
|
|
157
|
+
const isNewsroomPath = internalPathname === "/newsroom" || internalPathname.startsWith("/newsroom/");
|
|
156
158
|
const canUseConsole = Boolean(session?.hasSession && session.groups.some((group) => group === "editor" || group === "admin"));
|
|
157
159
|
const shouldOfferConsole = isNewsroomPath && canUseConsole;
|
|
158
160
|
|
|
@@ -433,7 +435,7 @@ function ConsolePanel({ actorEmail, actorLabel, onClose }: { actorEmail: string
|
|
|
433
435
|
const handledNavigationMessageIdsRef = useRef<Set<string>>(new Set());
|
|
434
436
|
|
|
435
437
|
const currentWebPath = useMemo(() => {
|
|
436
|
-
const path = pathname ?? "/newsroom";
|
|
438
|
+
const path = browserPathToInternalNewsroomPath(pathname ?? "/newsroom");
|
|
437
439
|
const query = searchParams.toString();
|
|
438
440
|
return query ? `${path}?${query}` : path;
|
|
439
441
|
}, [pathname, searchParams]);
|
|
@@ -12,6 +12,9 @@ export type PictogramFigureProps = {
|
|
|
12
12
|
credit: string;
|
|
13
13
|
figureClassName: string;
|
|
14
14
|
height: number;
|
|
15
|
+
/** Rhythm blog layout: solved frame size in CSS pixels; wins over `height`/`width` when set. */
|
|
16
|
+
frameHeight?: number;
|
|
17
|
+
frameWidth?: number;
|
|
15
18
|
layout?: ArticleImageLayout;
|
|
16
19
|
priority?: boolean;
|
|
17
20
|
sizes: string;
|
|
@@ -34,6 +37,8 @@ export function PictogramFigure({
|
|
|
34
37
|
caption,
|
|
35
38
|
credit,
|
|
36
39
|
figureClassName,
|
|
40
|
+
frameHeight,
|
|
41
|
+
frameWidth,
|
|
37
42
|
height,
|
|
38
43
|
priority = false,
|
|
39
44
|
sizes,
|
|
@@ -41,6 +46,8 @@ export function PictogramFigure({
|
|
|
41
46
|
themeVariants,
|
|
42
47
|
width,
|
|
43
48
|
}: PictogramFigureProps) {
|
|
49
|
+
const imageHeight = frameHeight ?? height;
|
|
50
|
+
const imageWidth = frameWidth ?? width;
|
|
44
51
|
const resolvedTheme = useResolvedPapyrusTheme();
|
|
45
52
|
const themedImageSrc = resolveThemedImageSrc(src, themeVariants, resolvedTheme);
|
|
46
53
|
|
|
@@ -53,8 +60,8 @@ export function PictogramFigure({
|
|
|
53
60
|
<Image
|
|
54
61
|
src={themedImageSrc}
|
|
55
62
|
alt={alt}
|
|
56
|
-
width={
|
|
57
|
-
height={
|
|
63
|
+
width={imageWidth}
|
|
64
|
+
height={imageHeight}
|
|
58
65
|
sizes={sizes}
|
|
59
66
|
priority={priority}
|
|
60
67
|
unoptimized={shouldBypassImageOptimization(themedImageSrc)}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { getEditionSectionPath } from "../lib/edition-routes";
|
|
5
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
6
|
+
|
|
7
|
+
export type PresentationHeaderSection = {
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type PresentationHeaderProps = {
|
|
13
|
+
editionDate?: string;
|
|
14
|
+
title?: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
sections?: PresentationHeaderSection[];
|
|
17
|
+
editionBasePath?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function PresentationHeader({
|
|
21
|
+
editionDate,
|
|
22
|
+
title: contentTitle,
|
|
23
|
+
description,
|
|
24
|
+
sections,
|
|
25
|
+
editionBasePath,
|
|
26
|
+
}: PresentationHeaderProps) {
|
|
27
|
+
const title = SITE_BRAND.mastheadSource === "brand" ? SITE_BRAND.mastheadTitle : contentTitle;
|
|
28
|
+
const subtitle = SITE_BRAND.mastheadSource === "brand" ? SITE_BRAND.mastheadSubtitle : description;
|
|
29
|
+
const eyebrow = SITE_BRAND.mastheadEyebrow ?? null;
|
|
30
|
+
const tagline = SITE_BRAND.mastheadTagline ?? null;
|
|
31
|
+
const taglineLines = SITE_BRAND.mastheadTaglineLines ?? null;
|
|
32
|
+
const displayDate = editionDate
|
|
33
|
+
? SITE_BRAND.mastheadDateFormat === "formatted"
|
|
34
|
+
? formatMastheadDate(editionDate)
|
|
35
|
+
: editionDate
|
|
36
|
+
: null;
|
|
37
|
+
|
|
38
|
+
const eyebrowParts = eyebrow ? splitEyebrow(eyebrow) : null;
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<>
|
|
42
|
+
<header className="presentation-header">
|
|
43
|
+
<div className="presentation-header__copy-stack">
|
|
44
|
+
{eyebrowParts ? (
|
|
45
|
+
<span className="presentation-header__eyebrow">
|
|
46
|
+
<span className="presentation-header__eyebrow-strong">{eyebrowParts.lead}</span>
|
|
47
|
+
{eyebrowParts.rest ? (
|
|
48
|
+
<span className="presentation-header__eyebrow-muted">{eyebrowParts.rest}</span>
|
|
49
|
+
) : null}
|
|
50
|
+
</span>
|
|
51
|
+
) : null}
|
|
52
|
+
<h1>
|
|
53
|
+
{SITE_BRAND.mastheadWordSplit && title
|
|
54
|
+
? title.split(/\s+/).map((word) => (
|
|
55
|
+
<span className="presentation-header__word" key={word}>
|
|
56
|
+
<span className="presentation-header__word-text">{word}</span>
|
|
57
|
+
</span>
|
|
58
|
+
))
|
|
59
|
+
: title}
|
|
60
|
+
</h1>
|
|
61
|
+
<div className="presentation-header__meta">
|
|
62
|
+
{subtitle && !eyebrow ? <span className="presentation-header__subtitle">{subtitle}</span> : null}
|
|
63
|
+
{displayDate ? <p className="presentation-header__date">{displayDate}</p> : null}
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
{tagline ? (
|
|
67
|
+
<span className="presentation-header__tagline">
|
|
68
|
+
{taglineLines && taglineLines.length > 0
|
|
69
|
+
? taglineLines.map((line, index) => (
|
|
70
|
+
<span className="presentation-header__tagline-line" key={index}>
|
|
71
|
+
<span className="presentation-header__tagline-emphasis">{line.emphasis}</span>
|
|
72
|
+
<span className="presentation-header__tagline-tail">{line.tail}</span>
|
|
73
|
+
{index < taglineLines.length - 1 ? " " : null}
|
|
74
|
+
</span>
|
|
75
|
+
))
|
|
76
|
+
: tagline}
|
|
77
|
+
</span>
|
|
78
|
+
) : null}
|
|
79
|
+
</header>
|
|
80
|
+
{sections && sections.length > 0 ? (
|
|
81
|
+
<nav className="presentation-section-nav" aria-label="Edition sections">
|
|
82
|
+
{sections.map((section) => (
|
|
83
|
+
<Link
|
|
84
|
+
href={getSectionHref(section, { editionBasePath, editionDate })}
|
|
85
|
+
key={section.key}
|
|
86
|
+
>
|
|
87
|
+
{section.label}
|
|
88
|
+
</Link>
|
|
89
|
+
))}
|
|
90
|
+
</nav>
|
|
91
|
+
) : null}
|
|
92
|
+
</>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getSectionHref(
|
|
97
|
+
section: PresentationHeaderSection,
|
|
98
|
+
options: { editionBasePath?: string; editionDate?: string },
|
|
99
|
+
): string {
|
|
100
|
+
if (SITE_BRAND.sectionLinkStrategy === "anchor") {
|
|
101
|
+
const anchor = `#${getSectionAnchorId(section.key)}`;
|
|
102
|
+
return options.editionBasePath ? `${options.editionBasePath}${anchor}` : anchor;
|
|
103
|
+
}
|
|
104
|
+
if (options.editionBasePath) {
|
|
105
|
+
return `${options.editionBasePath}/section/${encodeURIComponent(section.key)}`;
|
|
106
|
+
}
|
|
107
|
+
if (options.editionDate) {
|
|
108
|
+
const datedPath = getEditionSectionPath(options.editionDate, section.key);
|
|
109
|
+
return datedPath.startsWith("//") ? `#${getSectionAnchorId(section.key)}` : datedPath;
|
|
110
|
+
}
|
|
111
|
+
return `#${getSectionAnchorId(section.key)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getSectionAnchorId(sectionKey: string): string {
|
|
115
|
+
return `section-${sectionKey}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function splitEyebrow(text: string): { lead: string; rest: string } {
|
|
119
|
+
const trimmed = text.trim();
|
|
120
|
+
const firstSpace = trimmed.indexOf(" ");
|
|
121
|
+
if (firstSpace === -1) return { lead: trimmed, rest: "" };
|
|
122
|
+
return { lead: trimmed.slice(0, firstSpace), rest: trimmed.slice(firstSpace + 1) };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function formatMastheadDate(value: string): string {
|
|
126
|
+
const normalized = value?.trim();
|
|
127
|
+
if (!normalized) return value;
|
|
128
|
+
const date = new Date(`${normalized}T00:00:00`);
|
|
129
|
+
if (Number.isNaN(date.getTime())) return value;
|
|
130
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
131
|
+
month: "long",
|
|
132
|
+
day: "numeric",
|
|
133
|
+
year: "numeric",
|
|
134
|
+
}).format(date);
|
|
135
|
+
}
|