@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.21
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/resource.js +17 -4
- package/amplify/auth/resource.ts +21 -4
- package/amplify/data/resource.js +52 -70
- package/amplify/data/resource.ts +57 -70
- package/amplify/data/schema.js +52 -58
- package/amplify/data/schema.ts +57 -58
- 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 +149 -0
- package/amplify/functions/content-actions/requirements.txt +4 -0
- package/amplify/functions/content-actions/resource.js +40 -0
- package/amplify/functions/content-actions/resource.ts +54 -0
- package/amplify/functions/shared/python-bundle.js +17 -2
- package/amplify/functions/shared/python-bundle.ts +23 -2
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.ts +12 -1
- package/amplify/site-backend.js +51 -27
- package/amplify/site-backend.ts +66 -34
- package/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- 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 +22 -4
- package/app/layout.tsx +24 -12
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/app/page.tsx +12 -4
- package/app/robots.ts +11 -0
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +74 -18
- 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-page.tsx +1 -4
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +66 -0
- package/components/newsroom-article-editor.tsx +741 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/settings-page.tsx +2 -1
- package/components/staging-banner.tsx +11 -0
- package/components/topic-steering-workspace.tsx +1 -59
- 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 +285 -80
- package/infra/amplify-app-shell.ts +326 -113
- package/infra/build-specs.js +94 -0
- package/infra/build-specs.ts +141 -0
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +3 -0
- package/infra/site-config.js +273 -0
- package/infra/site-config.ts +336 -0
- 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 +14 -1
- package/lib/category-repository.ts +1 -93
- package/lib/content-actions-client.ts +98 -0
- package/lib/content-actions-demo.ts +190 -0
- package/lib/content-repository.ts +6 -0
- package/lib/content-source-context.ts +26 -0
- package/lib/content-types.ts +4 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/empty-theme.css +1 -0
- package/lib/graphql-content-repository.ts +239 -43
- package/lib/layout-scenarios.ts +8 -8
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-media-backend.ts +142 -0
- package/lib/newsroom-media.ts +182 -0
- package/lib/newsroom-nav.ts +3 -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 +8 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/site-brand-tenant.ts +3 -22
- package/lib/site-brand.ts +95 -42
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/lib/staging-gated-path.ts +22 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +77 -0
- package/middleware.ts +56 -42
- package/package.json +40 -1
- package/renderers/pretext/article-page.tsx +40 -9
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +18 -112
- 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 +37 -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 +15 -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
- package/publications/anth_us/brand.ts +0 -68
- package/publications/anth_us/theme.css +0 -66
- package/publications/pilobol_us/brand.ts +0 -37
- package/publications/pilobol_us/theme.css +0 -140
- package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
- package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
- package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
- package/publications/threat_intelligence/brand.ts +0 -40
- package/publications/threat_intelligence/pictograms/art.tsx +0 -608
- package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
- package/publications/threat_intelligence/pictograms/registry.ts +0 -30
- package/publications/threat_intelligence/pictograms/system.tsx +0 -305
- package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
- package/publications/threat_intelligence/theme.css +0 -1440
- /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
|
@@ -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
|
+
}
|
|
@@ -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;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
type PresentationRhythmHRuleProps = {
|
|
2
|
+
/** Hide in wide 2-column secondary pairs (items 1+2 side-by-side). */
|
|
3
|
+
hideInSecondaryPair?: boolean;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export function PresentationRhythmHRule({ hideInSecondaryPair = false }: PresentationRhythmHRuleProps) {
|
|
7
|
+
return (
|
|
8
|
+
<div
|
|
9
|
+
aria-hidden="true"
|
|
10
|
+
className="presentation-rhythm-hrule"
|
|
11
|
+
data-hide-in-secondary-pair={hideInSecondaryPair ? "true" : undefined}
|
|
12
|
+
role="presentation"
|
|
13
|
+
>
|
|
14
|
+
<div className="presentation-rhythm-hrule__line" />
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { toPublicReaderPath } from "../lib/reader-base-path";
|
|
3
4
|
import { signOut } from "aws-amplify/auth";
|
|
4
5
|
import Link from "next/link";
|
|
5
6
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
@@ -114,7 +115,7 @@ export function SettingsPage() {
|
|
|
114
115
|
return (
|
|
115
116
|
<main className="settings-page">
|
|
116
117
|
<nav className="edition-progress edition-progress--newsroom settings-progress" aria-label="Settings navigation">
|
|
117
|
-
<Link className="edition-progress__button edition-progress__button--previous" href="/">
|
|
118
|
+
<Link className="edition-progress__button edition-progress__button--previous" href={toPublicReaderPath("/")}>
|
|
118
119
|
<svg aria-hidden="true" className="edition-progress__icon" focusable="false" viewBox="0 0 10 10">
|
|
119
120
|
<path d="M7.5 1 2.5 5 7.5 9Z" fill="currentColor" />
|
|
120
121
|
</svg>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function StagingBanner() {
|
|
2
|
+
return (
|
|
3
|
+
<div
|
|
4
|
+
data-staging-banner
|
|
5
|
+
role="status"
|
|
6
|
+
className="sticky top-0 z-50 bg-amber-300 px-3 py-1 text-center text-xs font-semibold uppercase tracking-wide text-black"
|
|
7
|
+
>
|
|
8
|
+
STAGING: drafts shown as published
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -81,7 +81,7 @@ import { cn } from "../lib/utils";
|
|
|
81
81
|
import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
82
82
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
83
83
|
import type { ReaderAuthSnapshot } from "./reader-auth-state";
|
|
84
|
-
import {
|
|
84
|
+
import { NewsDeskAccessGate } from "./newsroom-access-gate";
|
|
85
85
|
import { NewsroomOpsOverview } from "./newsroom-ops-overview";
|
|
86
86
|
import { NewsroomReferencesView } from "./newsroom-references-view";
|
|
87
87
|
import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
|
|
@@ -169,7 +169,6 @@ import {
|
|
|
169
169
|
uniqueNeighborGroupsForCategoryContext,
|
|
170
170
|
type CategoryDrilldownContext,
|
|
171
171
|
} from "../lib/newsroom-category-drilldown";
|
|
172
|
-
import type { NewsDeskShellState } from "../lib/news-desk-session";
|
|
173
172
|
import {
|
|
174
173
|
resolveNewsroomCardTemplate,
|
|
175
174
|
type NewsroomCardSpan,
|
|
@@ -15399,63 +15398,6 @@ function readTextClaim(value: unknown): string | null {
|
|
|
15399
15398
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
15400
15399
|
}
|
|
15401
15400
|
|
|
15402
|
-
function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
|
|
15403
|
-
const accessPhase = shell?.phase ?? "checkingAccess";
|
|
15404
|
-
|
|
15405
|
-
return (
|
|
15406
|
-
<NewsroomOpsShell
|
|
15407
|
-
activeTab="overview"
|
|
15408
|
-
appTitle={SITE_BRAND.appTitle}
|
|
15409
|
-
backHref="/"
|
|
15410
|
-
backLabel={SITE_BRAND.backToHomeLabel}
|
|
15411
|
-
headerActions={(
|
|
15412
|
-
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
|
|
15413
|
-
)}
|
|
15414
|
-
pageTitle="Newsroom"
|
|
15415
|
-
showNavigation={showSectionTabs}
|
|
15416
|
-
>
|
|
15417
|
-
<section aria-live="polite" data-news-desk-access={accessPhase} data-news-desk-access-phase={accessPhase}>
|
|
15418
|
-
<div className="space-y-3 rounded-xl border border-border bg-card p-6">
|
|
15419
|
-
<p className="m-0 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Access</p>
|
|
15420
|
-
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight">{formatAccessTitle(shell)}</h2>
|
|
15421
|
-
<p className="m-0 text-sm text-muted-foreground">{formatAccessDetail(shell)}</p>
|
|
15422
|
-
{shell?.error ? <p className="text-sm text-destructive">{shell.error}</p> : null}
|
|
15423
|
-
<p className="m-0 text-sm text-foreground">{formatAccessActionDetail(shell)}</p>
|
|
15424
|
-
{shell?.phase === "signedOut" || shell?.phase === "error" ? (
|
|
15425
|
-
<div className="news-desk-access-panel__auth">
|
|
15426
|
-
<ReaderAuthControl postAuthPath="/newsroom" showIdentity />
|
|
15427
|
-
</div>
|
|
15428
|
-
) : null}
|
|
15429
|
-
</div>
|
|
15430
|
-
</section>
|
|
15431
|
-
</NewsroomOpsShell>
|
|
15432
|
-
);
|
|
15433
|
-
}
|
|
15434
|
-
|
|
15435
|
-
function formatAccessTitle(state: NewsDeskShellState | null): string {
|
|
15436
|
-
if (!state || state.phase === "checkingAccess") return "Checking Desk Credentials";
|
|
15437
|
-
if (state.phase === "loadingDesk") return "Loading Newsroom Records";
|
|
15438
|
-
if (state.phase === "forbidden") return "Editor Role Required";
|
|
15439
|
-
if (state.phase === "error") return "Newsroom Unavailable";
|
|
15440
|
-
return "Editor Sign-In Required";
|
|
15441
|
-
}
|
|
15442
|
-
|
|
15443
|
-
function formatAccessDetail(state: NewsDeskShellState | null): string {
|
|
15444
|
-
if (!state || state.phase === "checkingAccess") return `${SITE_BRAND.appTitle} is checking the current browser session before loading steering state.`;
|
|
15445
|
-
if (state.phase === "loadingDesk") return `${SITE_BRAND.appTitle} verified the browser session and is loading private Newsroom records.`;
|
|
15446
|
-
if (state.phase === "forbidden") return "This account is signed in, but the Cognito session does not include the editor or admin group.";
|
|
15447
|
-
if (state.phase === "error") return `${SITE_BRAND.appTitle} could not verify this editor session or load the private Newsroom data.`;
|
|
15448
|
-
return "Sign in with an editor or admin account to inspect category, category tree, ontology, and graph steering.";
|
|
15449
|
-
}
|
|
15450
|
-
|
|
15451
|
-
function formatAccessActionDetail(state: NewsDeskShellState | null): string {
|
|
15452
|
-
if (!state || state.phase === "checkingAccess") return "This should only take a moment. If it hangs, reload the page.";
|
|
15453
|
-
if (state.phase === "loadingDesk") return "Private records are loading from GraphQL. Large reference queues may take several seconds.";
|
|
15454
|
-
if (state.phase === "forbidden") return "Ask an admin to add this account to the editor or admin group, then sign out and back in.";
|
|
15455
|
-
if (state.phase === "error") return "Reload the page or check the local development console for the GraphQL error.";
|
|
15456
|
-
return "Use the login button above to authenticate with an editor or admin account.";
|
|
15457
|
-
}
|
|
15458
|
-
|
|
15459
15401
|
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
|
15460
15402
|
const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
15461
15403
|
return (
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, type RefObject } from "react";
|
|
4
|
+
import type { BlogHeaderObstacle } from "./blog-page-background";
|
|
5
|
+
|
|
6
|
+
const HEADER_OBSTACLE_SELECTORS = [
|
|
7
|
+
".presentation-header__eyebrow",
|
|
8
|
+
".presentation-header__word",
|
|
9
|
+
".presentation-header__meta",
|
|
10
|
+
".presentation-header__subtitle",
|
|
11
|
+
".presentation-header__date",
|
|
12
|
+
".presentation-header__tagline",
|
|
13
|
+
".presentation-section-nav a",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function measureObstacleRect(element: HTMLElement): DOMRect {
|
|
17
|
+
const range = document.createRange();
|
|
18
|
+
range.selectNodeContents(element);
|
|
19
|
+
const contentRect = range.getBoundingClientRect();
|
|
20
|
+
range.detach();
|
|
21
|
+
if (contentRect.width > 0 && contentRect.height > 0) return contentRect;
|
|
22
|
+
return element.getBoundingClientRect();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Measures the rendered header text (eyebrow, masthead words, meta, tagline,
|
|
27
|
+
* section links) as rectangles in the coordinates of `.blog-page-background`,
|
|
28
|
+
* so a brand's background artwork can keep clear of them. Block-level header
|
|
29
|
+
* elements span the full page width even when their text ends mid-line, so
|
|
30
|
+
* the text range is measured instead of the element box.
|
|
31
|
+
*/
|
|
32
|
+
export function useBlogHeaderObstacles(
|
|
33
|
+
pageRef: RefObject<HTMLElement | null>,
|
|
34
|
+
paintBuffer: number,
|
|
35
|
+
): BlogHeaderObstacle[] {
|
|
36
|
+
const [obstacles, setObstacles] = useState<BlogHeaderObstacle[]>([]);
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
const page = pageRef.current;
|
|
40
|
+
if (!page) return;
|
|
41
|
+
|
|
42
|
+
const update = () => {
|
|
43
|
+
const background = page.querySelector<HTMLElement>(".blog-page-background");
|
|
44
|
+
const backgroundRect = background?.getBoundingClientRect();
|
|
45
|
+
if (!backgroundRect) return;
|
|
46
|
+
|
|
47
|
+
const next: BlogHeaderObstacle[] = [];
|
|
48
|
+
for (const selector of HEADER_OBSTACLE_SELECTORS) {
|
|
49
|
+
for (const element of Array.from(page.querySelectorAll<HTMLElement>(selector))) {
|
|
50
|
+
const rect = measureObstacleRect(element);
|
|
51
|
+
const intersectionTop = Math.max(rect.top, backgroundRect.top);
|
|
52
|
+
const intersectionBottom = Math.min(rect.bottom, backgroundRect.bottom);
|
|
53
|
+
const intersectionLeft = Math.max(rect.left, backgroundRect.left);
|
|
54
|
+
const intersectionRight = Math.min(rect.right, backgroundRect.right);
|
|
55
|
+
const width = intersectionRight - intersectionLeft;
|
|
56
|
+
const height = intersectionBottom - intersectionTop;
|
|
57
|
+
if (width <= 0 || height <= 0) continue;
|
|
58
|
+
next.push({
|
|
59
|
+
x: intersectionLeft - backgroundRect.left,
|
|
60
|
+
y: intersectionTop - backgroundRect.top,
|
|
61
|
+
width,
|
|
62
|
+
height: height + paintBuffer * 2,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
setObstacles(next);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
update();
|
|
70
|
+
const observer = new ResizeObserver(update);
|
|
71
|
+
observer.observe(page);
|
|
72
|
+
window.addEventListener("resize", update);
|
|
73
|
+
void document.fonts?.ready.then(update);
|
|
74
|
+
return () => {
|
|
75
|
+
observer.disconnect();
|
|
76
|
+
window.removeEventListener("resize", update);
|
|
77
|
+
};
|
|
78
|
+
}, [pageRef, paintBuffer]);
|
|
79
|
+
|
|
80
|
+
return obstacles;
|
|
81
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { listBrandFontFamilies } from "../lib/brand-fonts";
|
|
5
|
+
import { watchBrandFontsLoaded } from "../lib/brand-fonts-watch";
|
|
6
|
+
import { clearPretextMeasurementCache } from "../lib/pretext-layout";
|
|
7
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Returns a number that increments each time the brand's declared font
|
|
11
|
+
* families finish loading. Pretext's width cache is cleared first, so
|
|
12
|
+
* measurements taken with fallback metrics are re-run with the real glyph
|
|
13
|
+
* widths. Stays 0 for brands without a font slot, and on the server.
|
|
14
|
+
*/
|
|
15
|
+
export function useBrandFontsReadyVersion(): number {
|
|
16
|
+
const [version, setVersion] = useState(0);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
return watchBrandFontsLoaded({
|
|
20
|
+
fontFaceSet: typeof document === "undefined" ? undefined : document.fonts,
|
|
21
|
+
families: listBrandFontFamilies(SITE_BRAND.fonts),
|
|
22
|
+
clearMeasurementCaches: clearPretextMeasurementCache,
|
|
23
|
+
onFontsSettled: () => setVersion((current) => current + 1),
|
|
24
|
+
});
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
return version;
|
|
28
|
+
}
|