@anthusai/papyrus 0.0.0 → 1.0.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. package/LICENSE +21 -0
  2. package/amplify/auth/resource.js +61 -0
  3. package/amplify/auth/resource.ts +65 -0
  4. package/amplify/data/resource.js +2069 -0
  5. package/amplify/data/resource.ts +2185 -0
  6. package/amplify/data/schema.js +1882 -0
  7. package/amplify/data/schema.ts +1990 -0
  8. package/amplify/functions/assignment-action/handler.ts +1416 -0
  9. package/amplify/functions/assignment-action/resource.js +8 -0
  10. package/amplify/functions/assignment-action/resource.ts +9 -0
  11. package/amplify/functions/category-action/handler.ts +2259 -0
  12. package/amplify/functions/category-action/resource.js +8 -0
  13. package/amplify/functions/category-action/resource.ts +9 -0
  14. package/amplify/functions/console-chat-responder/Cargo.lock +3456 -0
  15. package/amplify/functions/console-chat-responder/Cargo.toml +26 -0
  16. package/amplify/functions/console-chat-responder/Dockerfile +19 -0
  17. package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +116 -0
  18. package/amplify/functions/console-chat-responder/resource.js +153 -0
  19. package/amplify/functions/console-chat-responder/resource.ts +179 -0
  20. package/amplify/functions/console-chat-responder/src/main.rs +3242 -0
  21. package/amplify/functions/content-actions/handler.py +149 -0
  22. package/amplify/functions/content-actions/requirements.txt +4 -0
  23. package/amplify/functions/content-actions/resource.js +40 -0
  24. package/amplify/functions/content-actions/resource.ts +54 -0
  25. package/amplify/functions/email-submission-processor/handler.py +46 -0
  26. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  27. package/amplify/functions/email-submission-processor/resource.js +56 -0
  28. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  29. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  30. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  31. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  32. package/amplify/functions/knowledge-query/handler.py +1 -0
  33. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  34. package/amplify/functions/knowledge-query/resource.js +50 -0
  35. package/amplify/functions/knowledge-query/resource.ts +61 -0
  36. package/amplify/functions/manage-user-role/handler.ts +799 -0
  37. package/amplify/functions/manage-user-role/resource.js +8 -0
  38. package/amplify/functions/manage-user-role/resource.ts +9 -0
  39. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  40. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  41. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  42. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  43. package/amplify/functions/newsroom-summary/resource.js +8 -0
  44. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  45. package/amplify/functions/procedure-action/handler.ts +621 -0
  46. package/amplify/functions/procedure-action/resource.js +8 -0
  47. package/amplify/functions/procedure-action/resource.ts +9 -0
  48. package/amplify/functions/reader-settings/handler.ts +322 -0
  49. package/amplify/functions/reader-settings/resource.js +8 -0
  50. package/amplify/functions/reader-settings/resource.ts +9 -0
  51. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  52. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  53. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  54. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  55. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  56. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  57. package/amplify/functions/shared/email-submission.ts +415 -0
  58. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  59. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  60. package/amplify/functions/shared/model-payloads.ts +186 -0
  61. package/amplify/functions/shared/python-bundle.js +130 -0
  62. package/amplify/functions/shared/python-bundle.ts +162 -0
  63. package/amplify/functions/shared/slack-events.ts +111 -0
  64. package/amplify/functions/slack-delivery/handler.py +112 -0
  65. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  66. package/amplify/functions/slack-delivery/resource.js +38 -0
  67. package/amplify/functions/slack-delivery/resource.ts +51 -0
  68. package/amplify/functions/slack-events/handler.ts +155 -0
  69. package/amplify/functions/slack-events/resource.js +11 -0
  70. package/amplify/functions/slack-events/resource.ts +12 -0
  71. package/amplify/inbound-email/stack.js +46 -0
  72. package/amplify/inbound-email/stack.ts +61 -0
  73. package/amplify/package.json +3 -0
  74. package/amplify/rebuild-trigger-settings.js +22 -0
  75. package/amplify/rebuild-trigger-settings.ts +42 -0
  76. package/amplify/site-backend-config.js +1 -0
  77. package/amplify/site-backend-config.ts +36 -0
  78. package/amplify/site-backend.js +615 -0
  79. package/amplify/site-backend.ts +739 -0
  80. package/amplify/storage/resource.js +21 -0
  81. package/amplify/storage/resource.ts +22 -0
  82. package/app/%5F_preview/[[...path]]/route.ts +8 -0
  83. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  84. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  85. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  86. package/app/[year]/[month]/[day]/page.tsx +22 -0
  87. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  88. package/app/api/archive/editions/route.ts +18 -0
  89. package/app/api/media/[...path]/route.ts +35 -0
  90. package/app/api/revalidate/route.ts +94 -0
  91. package/app/archive/page.tsx +24 -0
  92. package/app/articles/[slug]/page.tsx +35 -0
  93. package/app/globals.css +10345 -0
  94. package/app/layout.tsx +142 -0
  95. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  96. package/app/newsroom/[section]/page.tsx +47 -0
  97. package/app/newsroom/articles/[id]/page.tsx +15 -0
  98. package/app/newsroom/articles/page.tsx +13 -0
  99. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  100. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  101. package/app/newsroom/insights/page.tsx +14 -0
  102. package/app/newsroom/layout.tsx +12 -0
  103. package/app/newsroom/page.tsx +31 -0
  104. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  105. package/app/page.tsx +272 -0
  106. package/app/papyrus-theme.css +11 -0
  107. package/app/robots.ts +11 -0
  108. package/app/settings/page.tsx +14 -0
  109. package/app/tailwind.css +65 -0
  110. package/bin/papyrus-app.mjs +20 -0
  111. package/bin/papyrus-infra.mjs +95 -0
  112. package/components/ai-elements/conversation.tsx +168 -0
  113. package/components/ai-elements/message.tsx +360 -0
  114. package/components/ai-elements/model-selector.tsx +30 -0
  115. package/components/ai-elements/prompt-input.tsx +1461 -0
  116. package/components/ai-elements/shimmer.tsx +76 -0
  117. package/components/ai-elements/suggestion.tsx +54 -0
  118. package/components/ai-elements/tool.tsx +173 -0
  119. package/components/amplify-client-provider.tsx +140 -0
  120. package/components/archive-grid.tsx +89 -0
  121. package/components/archive-shell.tsx +41 -0
  122. package/components/article-video.tsx +156 -0
  123. package/components/blog-page-background.tsx +20 -0
  124. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  125. package/components/markus-ir-preview.tsx +300 -0
  126. package/components/news-desk-client-provider.tsx +211 -0
  127. package/components/news-desk-page.tsx +116 -0
  128. package/components/news-desk-taxonomy-client.ts +3684 -0
  129. package/components/newspaper.tsx +1929 -0
  130. package/components/newsroom-access-gate.tsx +66 -0
  131. package/components/newsroom-article-editor.tsx +741 -0
  132. package/components/newsroom-articles-view.tsx +129 -0
  133. package/components/newsroom-assignments-view.tsx +664 -0
  134. package/components/newsroom-ops-overview.tsx +226 -0
  135. package/components/newsroom-ops-shell.tsx +321 -0
  136. package/components/newsroom-references-view.tsx +598 -0
  137. package/components/newsroom-topics-view.tsx +425 -0
  138. package/components/papyrus-console-shell.tsx +1912 -0
  139. package/components/pictogram-figure.tsx +65 -0
  140. package/components/presentation-footer.tsx +146 -0
  141. package/components/reader-auth-control.tsx +162 -0
  142. package/components/reader-auth-state.ts +122 -0
  143. package/components/reader-settings.ts +332 -0
  144. package/components/reader-theme-control.tsx +85 -0
  145. package/components/reference-source-preview.tsx +163 -0
  146. package/components/settings-page.tsx +239 -0
  147. package/components/staging-banner.tsx +11 -0
  148. package/components/topic-steering-workspace.tsx +18005 -0
  149. package/components/ui/badge.tsx +30 -0
  150. package/components/ui/button-group.tsx +87 -0
  151. package/components/ui/button.tsx +58 -0
  152. package/components/ui/card.tsx +32 -0
  153. package/components/ui/command.tsx +194 -0
  154. package/components/ui/dialog.tsx +158 -0
  155. package/components/ui/dropdown-menu.tsx +266 -0
  156. package/components/ui/hover-card.tsx +51 -0
  157. package/components/ui/input-group.tsx +158 -0
  158. package/components/ui/input.tsx +20 -0
  159. package/components/ui/scroll-area.tsx +16 -0
  160. package/components/ui/select.tsx +199 -0
  161. package/components/ui/separator.tsx +23 -0
  162. package/components/ui/sheet.tsx +92 -0
  163. package/components/ui/spinner.tsx +10 -0
  164. package/components/ui/tabs.tsx +113 -0
  165. package/components/ui/textarea.tsx +18 -0
  166. package/components/ui/tooltip.tsx +66 -0
  167. package/components/use-resolved-papyrus-theme.ts +47 -0
  168. package/infra/amplify-app-shell.js +234 -0
  169. package/infra/amplify-app-shell.ts +284 -0
  170. package/infra/build-specs.js +85 -0
  171. package/infra/build-specs.ts +131 -0
  172. package/infra/github-oidc-provider.js +24 -0
  173. package/infra/github-oidc-provider.ts +30 -0
  174. package/infra/index.js +15 -0
  175. package/infra/site-config.js +186 -0
  176. package/infra/site-config.ts +235 -0
  177. package/lib/amplify-outputs-guard.ts +24 -0
  178. package/lib/amplify-outputs-path.ts +16 -0
  179. package/lib/amplify-server-runtime.ts +63 -0
  180. package/lib/archive-data.ts +30 -0
  181. package/lib/archive-types.ts +15 -0
  182. package/lib/articles.ts +320 -0
  183. package/lib/cached-content-repository.ts +90 -0
  184. package/lib/category-dashboard.ts +37 -0
  185. package/lib/category-repository.ts +1907 -0
  186. package/lib/console-chat-client.ts +1316 -0
  187. package/lib/console-web-context.ts +118 -0
  188. package/lib/content-actions-client.ts +98 -0
  189. package/lib/content-actions-demo.ts +190 -0
  190. package/lib/content-load-timing.ts +23 -0
  191. package/lib/content-repository.ts +43 -0
  192. package/lib/content-source-context.ts +26 -0
  193. package/lib/content-types.ts +104 -0
  194. package/lib/define-site.js +3 -0
  195. package/lib/define-site.ts +21 -0
  196. package/lib/demo-amplify-outputs.ts +15 -0
  197. package/lib/dev-sandbox-editor-auth.ts +18 -0
  198. package/lib/doctrine.ts +128 -0
  199. package/lib/edition-routes.ts +155 -0
  200. package/lib/edition-sections.ts +111 -0
  201. package/lib/empty-edition-layout-plan.ts +109 -0
  202. package/lib/empty-theme.css +1 -0
  203. package/lib/excerpts.ts +8 -0
  204. package/lib/graphql-content-repository.ts +1140 -0
  205. package/lib/image-url.ts +10 -0
  206. package/lib/layout-plan.ts +981 -0
  207. package/lib/layout-scenarios.ts +564 -0
  208. package/lib/markus-body.ts +168 -0
  209. package/lib/markus-ir.ts +235 -0
  210. package/lib/markus-projection.ts +243 -0
  211. package/lib/markus-to-article.ts +93 -0
  212. package/lib/news-desk-session.ts +160 -0
  213. package/lib/newspaper-layout.ts +3459 -0
  214. package/lib/newsroom-articles.ts +325 -0
  215. package/lib/newsroom-assignments.ts +173 -0
  216. package/lib/newsroom-base-path.ts +48 -0
  217. package/lib/newsroom-card-layout.ts +102 -0
  218. package/lib/newsroom-category-drilldown.ts +133 -0
  219. package/lib/newsroom-demo-articles.ts +90 -0
  220. package/lib/newsroom-demo-dashboard.ts +267 -0
  221. package/lib/newsroom-demo-profile.ts +46 -0
  222. package/lib/newsroom-forum-routes.ts +153 -0
  223. package/lib/newsroom-index-filters.ts +202 -0
  224. package/lib/newsroom-knowledge-query-request.ts +51 -0
  225. package/lib/newsroom-list-selection.ts +42 -0
  226. package/lib/newsroom-media-backend.ts +142 -0
  227. package/lib/newsroom-media.ts +182 -0
  228. package/lib/newsroom-nav.ts +44 -0
  229. package/lib/newsroom-references.ts +116 -0
  230. package/lib/newsroom-sections.ts +52 -0
  231. package/lib/newsroom-topics.ts +118 -0
  232. package/lib/papyrus-object-kinds.ts +24 -0
  233. package/lib/papyrus-web-locations.ts +480 -0
  234. package/lib/pilobol-sample.ts +167 -0
  235. package/lib/presentation-footer.ts +92 -0
  236. package/lib/pretext-layout.ts +179 -0
  237. package/lib/public-placeholder-config.ts +102 -0
  238. package/lib/publication-items.ts +181 -0
  239. package/lib/reader-route-config.ts +1 -0
  240. package/lib/reader-static-params.ts +56 -0
  241. package/lib/reader-storage-url.ts +63 -0
  242. package/lib/reference-display.ts +74 -0
  243. package/lib/reference-policy.ts +97 -0
  244. package/lib/reference-source-preview.ts +175 -0
  245. package/lib/renderer-config.ts +50 -0
  246. package/lib/renderer.ts +87 -0
  247. package/lib/reporting-story-budget.ts +638 -0
  248. package/lib/semantic-graph.ts +554 -0
  249. package/lib/site-brand-tenant.ts +22 -0
  250. package/lib/site-brand.ts +194 -0
  251. package/lib/site-env.ts +52 -0
  252. package/lib/site-renderer.ts +17 -0
  253. package/lib/site-stack.ts +195 -0
  254. package/lib/staging-gate.ts +37 -0
  255. package/lib/staging-preview-object.ts +98 -0
  256. package/lib/themed-image.ts +30 -0
  257. package/lib/utils.ts +6 -0
  258. package/lib/video-mode.ts +42 -0
  259. package/lib/video-script.ts +63 -0
  260. package/middleware.ts +103 -0
  261. package/package.json +318 -1
  262. package/renderers/markus/index.ts +25 -0
  263. package/renderers/markus/stubs.tsx +33 -0
  264. package/renderers/pretext/article-page.tsx +169 -0
  265. package/renderers/pretext/index.ts +30 -0
  266. package/renderers/pretext/layouts.ts +16 -0
  267. package/renderers/pretext/presentation-shell.tsx +470 -0
  268. package/routes.manifest.json +208 -0
  269. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  270. package/scripts/videoml/build-browser-bundle.mjs +22 -0
  271. package/scripts/videoml/build-preview-bundle.mjs +50 -0
  272. package/scripts/videoml/bundle-common.mjs +129 -0
  273. package/scripts/videoml/preview/preview-entry.tsx +381 -0
  274. package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
  275. package/scripts/videoml/preview/preview-timing.ts +42 -0
  276. package/scripts/videoml/preview.html +77 -0
  277. package/scripts/videoml/standard-entry.tsx +46 -0
  278. package/src/backend.js +1 -0
  279. package/src/backend.ts +3 -0
  280. package/src/sync.mjs +87 -0
  281. package/src/with-papyrus.d.mts +2 -0
  282. package/src/with-papyrus.mjs +60 -0
@@ -0,0 +1,65 @@
1
+ "use client";
2
+
3
+ import Image from "next/image";
4
+ import type { ArticleImageLayout, ArticleImageThemeVariants } from "../lib/articles";
5
+ import { shouldBypassImageOptimization } from "../lib/image-url";
6
+ import { resolveThemedImageSrc } from "../lib/themed-image";
7
+ import { useResolvedPapyrusTheme } from "./use-resolved-papyrus-theme";
8
+
9
+ export type PictogramFigureProps = {
10
+ alt: string;
11
+ caption?: string;
12
+ credit: string;
13
+ figureClassName: string;
14
+ height: number;
15
+ layout?: ArticleImageLayout;
16
+ priority?: boolean;
17
+ sizes: string;
18
+ slug: string;
19
+ src?: string;
20
+ themeVariants?: ArticleImageThemeVariants;
21
+ width: number;
22
+ };
23
+
24
+ /**
25
+ * Generic media figure for publications without a bespoke pictogram system.
26
+ * This is the default `renderers/pretext` falls back to for any publication
27
+ * that isn't Threat Intelligence -- it mirrors the plain-image branch of
28
+ * `publications/threat_intelligence/pictograms/figure.tsx` (the branch that
29
+ * component itself already uses whenever a slug isn't a registered TI
30
+ * pictogram), so behavior for a plain themed image is identical either way.
31
+ */
32
+ export function PictogramFigure({
33
+ alt,
34
+ caption,
35
+ credit,
36
+ figureClassName,
37
+ height,
38
+ priority = false,
39
+ sizes,
40
+ src = "",
41
+ themeVariants,
42
+ width,
43
+ }: PictogramFigureProps) {
44
+ const resolvedTheme = useResolvedPapyrusTheme();
45
+ const themedImageSrc = resolveThemedImageSrc(src, themeVariants, resolvedTheme);
46
+
47
+ if (!themedImageSrc) {
48
+ return null;
49
+ }
50
+
51
+ return (
52
+ <figure className={figureClassName}>
53
+ <Image
54
+ src={themedImageSrc}
55
+ alt={alt}
56
+ width={width}
57
+ height={height}
58
+ sizes={sizes}
59
+ priority={priority}
60
+ unoptimized={shouldBypassImageOptimization(themedImageSrc)}
61
+ />
62
+ <figcaption>{caption ?? credit}</figcaption>
63
+ </figure>
64
+ );
65
+ }
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import type { CSSProperties, MouseEvent as ReactMouseEvent } from "react";
5
+ import {
6
+ PRESENTATION_FOOTER_UTILITIES,
7
+ type PresentationFooterEntry,
8
+ type PresentationFooterGeometry,
9
+ type PresentationFooterUtilityEntry,
10
+ } from "../lib/presentation-footer";
11
+ import { ReaderAuthControl } from "./reader-auth-control";
12
+ import { ReaderThemeControl } from "./reader-theme-control";
13
+
14
+ type PresentationFooterProps = {
15
+ disableLinks?: boolean;
16
+ editionBasePath?: string;
17
+ entries: PresentationFooterEntry[];
18
+ geometry?: PresentationFooterGeometry;
19
+ onSectionClick?: (event: ReactMouseEvent<HTMLAnchorElement>, entry: PresentationFooterEntry, href: string) => void;
20
+ resolveSectionHref: (entry: PresentationFooterEntry) => string;
21
+ subtitle: string;
22
+ title?: string;
23
+ utilityEntries?: PresentationFooterUtilityEntry[];
24
+ variant?: "newspaper" | "presentation";
25
+ };
26
+
27
+ export function PresentationFooter({
28
+ disableLinks = false,
29
+ editionBasePath,
30
+ entries,
31
+ geometry,
32
+ onSectionClick,
33
+ resolveSectionHref,
34
+ subtitle,
35
+ title,
36
+ utilityEntries = PRESENTATION_FOOTER_UTILITIES,
37
+ variant = "presentation",
38
+ }: PresentationFooterProps) {
39
+ const className = variant === "newspaper" ? "front-footer" : "front-footer front-footer--presentation";
40
+
41
+ return (
42
+ <footer
43
+ aria-label="Publication footer"
44
+ className={className}
45
+ data-front-footer="true"
46
+ data-presentation-footer={variant === "presentation" ? "true" : undefined}
47
+ style={geometry ? getFooterGeometryStyle(geometry) : getPresentationFooterStyle(entries, utilityEntries)}
48
+ >
49
+ <div className="front-footer__heading">
50
+ {title ? (
51
+ <span className="front-footer__brand">
52
+ <span className="front-footer__brand-title">{title}</span>
53
+ {subtitle ? <span className="front-footer__brand-subtitle">{subtitle}</span> : null}
54
+ </span>
55
+ ) : (
56
+ <span>{subtitle}</span>
57
+ )}
58
+ <span className="front-footer__meta">{entries.length} sections</span>
59
+ </div>
60
+ {entries.length > 0 ? (
61
+ <nav className="front-footer__sections" aria-label="Edition sections">
62
+ {entries.map((entry) => {
63
+ const href = resolveSectionHref(entry);
64
+ if (disableLinks) {
65
+ return (
66
+ <span className="front-footer__section-link" data-footer-section={entry.section} key={`${entry.section}-${entry.articleSlug}`}>
67
+ <span className="front-footer__section-name">{entry.section}</span>
68
+ <span className="front-footer__section-title">{entry.articleTitle}</span>
69
+ </span>
70
+ );
71
+ }
72
+ return (
73
+ <a
74
+ className="front-footer__section-link"
75
+ data-footer-section={entry.section}
76
+ href={href}
77
+ key={`${entry.section}-${entry.articleSlug}`}
78
+ onClick={onSectionClick ? (event) => onSectionClick(event, entry, href) : undefined}
79
+ >
80
+ <span className="front-footer__section-name">{entry.section}</span>
81
+ <span className="front-footer__section-title">{entry.articleTitle}</span>
82
+ </a>
83
+ );
84
+ })}
85
+ </nav>
86
+ ) : null}
87
+ <div className="front-footer__utilities" aria-label="Publication utilities">
88
+ {!disableLinks ? <ReaderThemeControl className="front-footer__theme-control" /> : null}
89
+ {utilityEntries.map((entry) => {
90
+ if (entry.id === "newsDesk" && disableLinks) return null;
91
+
92
+ if (entry.id === "login" && !disableLinks) {
93
+ return (
94
+ <ReaderAuthControl className="front-footer__utility-link" dataFooterUtility={entry.id} key={entry.id} postAuthPath={editionBasePath} />
95
+ );
96
+ }
97
+
98
+ return entry.disabled || disableLinks ? (
99
+ <span
100
+ aria-disabled="true"
101
+ className="front-footer__utility-link"
102
+ data-footer-utility={entry.id}
103
+ key={entry.id}
104
+ role="link"
105
+ >
106
+ {entry.label}
107
+ </span>
108
+ ) : (
109
+ <Link className="front-footer__utility-link" data-footer-utility={entry.id} href={entry.href} key={entry.id}>
110
+ {entry.label}
111
+ </Link>
112
+ );
113
+ })}
114
+ </div>
115
+ </footer>
116
+ );
117
+ }
118
+
119
+ function getFooterGeometryStyle(geometry: PresentationFooterGeometry): CSSProperties {
120
+ return {
121
+ "--front-footer-height": `${geometry.height}px`,
122
+ "--front-footer-margin-top": `${geometry.marginTop}px`,
123
+ "--front-footer-row-height": `${geometry.rowHeight}px`,
124
+ "--front-footer-section-columns": geometry.sectionColumns,
125
+ "--front-footer-section-rows": geometry.sectionRows,
126
+ } as CSSProperties;
127
+ }
128
+
129
+ function getPresentationFooterStyle(
130
+ entries: PresentationFooterEntry[],
131
+ utilityEntries: PresentationFooterUtilityEntry[],
132
+ ): CSSProperties {
133
+ const { sectionColumns, sectionRows } = getPresentationFooterGrid(entries.length, utilityEntries.length);
134
+ return {
135
+ "--front-footer-section-columns": sectionColumns,
136
+ "--front-footer-section-rows": sectionRows,
137
+ "--front-footer-utility-rows": utilityEntries.length,
138
+ } as CSSProperties;
139
+ }
140
+
141
+ function getPresentationFooterGrid(entryCount: number, utilityCount: number) {
142
+ const maxColumns = Math.max(1, Math.min(4, Math.max(entryCount, utilityCount)));
143
+ const sectionColumns = entryCount === 0 ? 1 : Math.min(entryCount, maxColumns);
144
+ const sectionRows = entryCount === 0 ? 0 : Math.ceil(entryCount / sectionColumns);
145
+ return { sectionColumns, sectionRows };
146
+ }
@@ -0,0 +1,162 @@
1
+ "use client";
2
+
3
+ import { signInWithRedirect, signOut } from "aws-amplify/auth";
4
+ import { Hub } from "aws-amplify/utils";
5
+ import { useCallback, useEffect, useState } from "react";
6
+ import { configureAmplifyClient } from "./amplify-client-provider";
7
+ import { loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
8
+
9
+ type ReaderAuthControlProps = {
10
+ className?: string;
11
+ dataFooterUtility?: string;
12
+ postAuthPath?: string;
13
+ showIdentity?: boolean;
14
+ authState?: ReaderAuthSnapshot;
15
+ };
16
+
17
+ const POST_AUTH_PATH_KEY = "papyrus:reader-auth:post-auth-path";
18
+
19
+ export function ReaderAuthControl({
20
+ className,
21
+ dataFooterUtility,
22
+ postAuthPath,
23
+ showIdentity = false,
24
+ authState: externalAuthState,
25
+ }: ReaderAuthControlProps) {
26
+ const [authState, setAuthState] = useState<ReaderAuthSnapshot>(externalAuthState ?? { status: "loading", label: "Checking sign-in" });
27
+ const [busy, setBusy] = useState(false);
28
+ const [error, setError] = useState<string | null>(null);
29
+ const isExternallyManaged = Boolean(externalAuthState);
30
+
31
+ const replaceWithPostAuthPath = useCallback(() => {
32
+ const storedPath = typeof window === "undefined" ? null : window.sessionStorage.getItem(POST_AUTH_PATH_KEY);
33
+ const nextPath = postAuthPath ?? storedPath;
34
+ if (!nextPath) return;
35
+ if (window.location.pathname !== "/" || window.location.search || window.location.hash) return;
36
+ if (storedPath) window.sessionStorage.removeItem(POST_AUTH_PATH_KEY);
37
+ window.history.replaceState(window.history.state, "", nextPath);
38
+ }, [postAuthPath]);
39
+
40
+ const refreshAuthState = useCallback(async () => {
41
+ const snapshot = await loadReaderSessionSnapshot();
42
+ setAuthState(snapshot.auth);
43
+ setError(null);
44
+ if (snapshot.auth.status === "signedIn") replaceWithPostAuthPath();
45
+ }, [replaceWithPostAuthPath]);
46
+
47
+ useEffect(() => {
48
+ if (externalAuthState) {
49
+ setAuthState(externalAuthState);
50
+ setBusy(false);
51
+ return;
52
+ }
53
+
54
+ let active = true;
55
+
56
+ const refreshIfActive = async () => {
57
+ await refreshAuthState();
58
+ if (!active) return;
59
+ setBusy(false);
60
+ };
61
+
62
+ void refreshIfActive();
63
+ const unsubscribe = Hub.listen("auth", ({ payload }) => {
64
+ if (
65
+ payload.event === "signedIn" ||
66
+ payload.event === "signedOut" ||
67
+ payload.event === "signInWithRedirect" ||
68
+ payload.event === "signInWithRedirect_failure"
69
+ ) {
70
+ void refreshIfActive();
71
+ }
72
+ if (payload.event === "signInWithRedirect_failure") {
73
+ setError(formatAuthFailure(payload.data));
74
+ }
75
+ });
76
+
77
+ return () => {
78
+ active = false;
79
+ unsubscribe();
80
+ };
81
+ }, [externalAuthState, refreshAuthState]);
82
+
83
+ const signIn = async () => {
84
+ configureAmplifyClient();
85
+ setBusy(true);
86
+ setError(null);
87
+ try {
88
+ if (typeof window !== "undefined") {
89
+ const intendedPath = postAuthPath ?? `${window.location.pathname}${window.location.search}${window.location.hash}`;
90
+ if (intendedPath && intendedPath !== "/") {
91
+ window.sessionStorage.setItem(POST_AUTH_PATH_KEY, intendedPath);
92
+ } else {
93
+ window.sessionStorage.removeItem(POST_AUTH_PATH_KEY);
94
+ }
95
+ if (window.location.pathname !== "/" || window.location.search || window.location.hash) {
96
+ window.history.replaceState(window.history.state, "", "/");
97
+ }
98
+ }
99
+ await signInWithRedirect({ provider: "Google" });
100
+ } catch (error) {
101
+ setBusy(false);
102
+ setError(formatAuthFailure(error));
103
+ }
104
+ };
105
+
106
+ const signOutCurrentUser = async () => {
107
+ configureAmplifyClient();
108
+ setBusy(true);
109
+ setError(null);
110
+ try {
111
+ await signOut();
112
+ await refreshAuthState();
113
+ } catch {
114
+ setError("Sign-out failed");
115
+ } finally {
116
+ setBusy(false);
117
+ }
118
+ };
119
+
120
+ useEffect(() => {
121
+ if (!externalAuthState) return;
122
+ setAuthState(externalAuthState);
123
+ }, [externalAuthState]);
124
+
125
+ const signedIn = authState.status === "signedIn";
126
+ const actionLabel = signedIn ? "LOGOUT" : "LOGIN";
127
+ return (
128
+ <span className={className ? `reader-auth ${className}` : "reader-auth"} aria-live="polite" data-footer-utility={dataFooterUtility}>
129
+ {signedIn && showIdentity ? <span className="reader-auth__identity">{authState.label}</span> : null}
130
+ {error ? <span className="reader-auth__error">{error}</span> : null}
131
+ <button
132
+ className="reader-auth__button"
133
+ disabled={authState.status === "loading" || busy}
134
+ onClick={signedIn ? signOutCurrentUser : signIn}
135
+ type="button"
136
+ >
137
+ {actionLabel}
138
+ </button>
139
+ </span>
140
+ );
141
+ }
142
+
143
+ function formatAuthFailure(error: unknown): string {
144
+ const detail = extractAuthFailureDetail(error);
145
+ return detail ? `Sign-in failed: ${detail}` : "Sign-in failed";
146
+ }
147
+
148
+ function extractAuthFailureDetail(error: unknown): string | null {
149
+ if (!error) return null;
150
+ if (typeof error === "string") return error;
151
+ if (typeof error !== "object") return null;
152
+
153
+ const record = error as Record<string, unknown>;
154
+ const message = record.message;
155
+ if (typeof message === "string" && message.trim().length > 0) return message;
156
+ const name = record.name;
157
+ if (typeof name === "string" && name.trim().length > 0) return name;
158
+
159
+ const nested = record.error;
160
+ if (nested && nested !== error) return extractAuthFailureDetail(nested);
161
+ return null;
162
+ }
@@ -0,0 +1,122 @@
1
+ "use client";
2
+
3
+ import { fetchAuthSession } from "aws-amplify/auth";
4
+ import { configureAmplifyClient } from "./amplify-client-provider";
5
+
6
+ export type ReaderAuthSnapshot =
7
+ | { status: "loading"; label: string; email?: null }
8
+ | { status: "signedOut"; label: string; email?: null }
9
+ | { status: "signedIn"; label: string; email?: string | null };
10
+
11
+ export type ReaderSessionSnapshot = {
12
+ auth: ReaderAuthSnapshot;
13
+ groups: string[];
14
+ hasSession: boolean;
15
+ };
16
+
17
+ export async function loadReaderSessionSnapshot(options?: { forceRefresh?: boolean }): Promise<ReaderSessionSnapshot> {
18
+ configureAmplifyClient();
19
+ try {
20
+ const session = await fetchAuthSession({ forceRefresh: options?.forceRefresh === true });
21
+ const accessPayload = session.tokens?.accessToken?.payload ?? {};
22
+ const idPayload = session.tokens?.idToken?.payload ?? {};
23
+ const email = readTextClaim(idPayload.email ?? accessPayload.email);
24
+ const groups = dedupeGroups([
25
+ ...readGroups(accessPayload["cognito:groups"]),
26
+ ...readGroups(accessPayload.groups),
27
+ ...readGroups(idPayload["cognito:groups"]),
28
+ ...readGroups(idPayload.groups),
29
+ ]);
30
+ const label = readIdentityLabel({
31
+ email,
32
+ name: idPayload.name ?? accessPayload.name,
33
+ username: idPayload["cognito:username"] ?? accessPayload["cognito:username"] ?? accessPayload.username,
34
+ sub: idPayload.sub ?? accessPayload.sub,
35
+ });
36
+
37
+ if (!session.tokens?.accessToken) {
38
+ return {
39
+ auth: { status: "signedOut", label: "Signed out" },
40
+ groups,
41
+ hasSession: false,
42
+ };
43
+ }
44
+
45
+ return {
46
+ auth: { status: "signedIn", label, email },
47
+ groups,
48
+ hasSession: true,
49
+ };
50
+ } catch (error) {
51
+ if (isUnauthenticatedError(error)) {
52
+ return {
53
+ auth: { status: "signedOut", label: "Signed out" },
54
+ groups: [],
55
+ hasSession: false,
56
+ };
57
+ }
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ export function isUnauthenticatedError(error: unknown): boolean {
63
+ const message = error instanceof Error ? error.message : String(error);
64
+ return /unauth|not authenticated|no current user|not signed in/i.test(message);
65
+ }
66
+
67
+ function readIdentityLabel(claims: {
68
+ email: unknown;
69
+ name: unknown;
70
+ username: unknown;
71
+ sub: unknown;
72
+ }): string {
73
+ return readTextClaim(claims.email)
74
+ ?? readTextClaim(claims.name)
75
+ ?? readTextClaim(claims.username)
76
+ ?? readTextClaim(claims.sub)
77
+ ?? "Signed in";
78
+ }
79
+
80
+ function readTextClaim(value: unknown): string | null {
81
+ return typeof value === "string" && value.trim() ? value.trim() : null;
82
+ }
83
+
84
+ function readGroups(value: unknown): string[] {
85
+ if (Array.isArray(value)) return value.map((entry) => normalizeGroupName(String(entry))).filter(Boolean) as string[];
86
+ if (typeof value === "string") {
87
+ const trimmed = value.trim();
88
+ if (!trimmed) return [];
89
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
90
+ try {
91
+ const parsed = JSON.parse(trimmed);
92
+ if (Array.isArray(parsed)) {
93
+ return parsed
94
+ .map((entry) => normalizeGroupName(String(entry)))
95
+ .filter(Boolean) as string[];
96
+ }
97
+ } catch {
98
+ }
99
+ }
100
+ return trimmed
101
+ .split(/[,\s]+/)
102
+ .map((entry) => normalizeGroupName(entry))
103
+ .filter(Boolean) as string[];
104
+ }
105
+ return [];
106
+ }
107
+
108
+ function dedupeGroups(groups: string[]): string[] {
109
+ const seen = new Set<string>();
110
+ const values: string[] = [];
111
+ for (const group of groups) {
112
+ const normalized = normalizeGroupName(group);
113
+ if (!normalized || seen.has(normalized)) continue;
114
+ seen.add(normalized);
115
+ values.push(normalized);
116
+ }
117
+ return values;
118
+ }
119
+
120
+ function normalizeGroupName(value: string): string {
121
+ return value.trim().replace(/^["'[\](){}]+|["'[\](){}]+$/g, "").toLowerCase();
122
+ }