@anthusai/papyrus 0.0.0 → 1.0.0-next.2

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 (254) 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 +2037 -0
  5. package/amplify/data/resource.ts +2148 -0
  6. package/amplify/data/schema.js +1850 -0
  7. package/amplify/data/schema.ts +1953 -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/email-submission-processor/handler.py +46 -0
  22. package/amplify/functions/email-submission-processor/requirements.txt +2 -0
  23. package/amplify/functions/email-submission-processor/resource.js +56 -0
  24. package/amplify/functions/email-submission-processor/resource.ts +69 -0
  25. package/amplify/functions/graphql-jwt-authorizer/handler.ts +193 -0
  26. package/amplify/functions/graphql-jwt-authorizer/resource.js +14 -0
  27. package/amplify/functions/graphql-jwt-authorizer/resource.ts +15 -0
  28. package/amplify/functions/knowledge-query/handler.py +1 -0
  29. package/amplify/functions/knowledge-query/requirements.txt +3 -0
  30. package/amplify/functions/knowledge-query/resource.js +50 -0
  31. package/amplify/functions/knowledge-query/resource.ts +61 -0
  32. package/amplify/functions/manage-user-role/handler.ts +799 -0
  33. package/amplify/functions/manage-user-role/resource.js +8 -0
  34. package/amplify/functions/manage-user-role/resource.ts +9 -0
  35. package/amplify/functions/model-attachment-upload/handler.ts +387 -0
  36. package/amplify/functions/model-attachment-upload/resource.js +8 -0
  37. package/amplify/functions/model-attachment-upload/resource.ts +9 -0
  38. package/amplify/functions/newsroom-summary/handler.ts +321 -0
  39. package/amplify/functions/newsroom-summary/resource.js +8 -0
  40. package/amplify/functions/newsroom-summary/resource.ts +9 -0
  41. package/amplify/functions/procedure-action/handler.ts +621 -0
  42. package/amplify/functions/procedure-action/resource.js +8 -0
  43. package/amplify/functions/procedure-action/resource.ts +9 -0
  44. package/amplify/functions/reader-settings/handler.ts +322 -0
  45. package/amplify/functions/reader-settings/resource.js +8 -0
  46. package/amplify/functions/reader-settings/resource.ts +9 -0
  47. package/amplify/functions/ses-inbound-receive/handler.ts +493 -0
  48. package/amplify/functions/ses-inbound-receive/resource.js +8 -0
  49. package/amplify/functions/ses-inbound-receive/resource.ts +9 -0
  50. package/amplify/functions/shared/amplify-secrets.ts +65 -0
  51. package/amplify/functions/shared/console-chat-enqueue.ts +117 -0
  52. package/amplify/functions/shared/email-mime-intake.ts +196 -0
  53. package/amplify/functions/shared/email-submission.ts +415 -0
  54. package/amplify/functions/shared/inbound-email-intake.ts +37 -0
  55. package/amplify/functions/shared/lambda-data-client.ts +21 -0
  56. package/amplify/functions/shared/model-payloads.ts +186 -0
  57. package/amplify/functions/shared/python-bundle.js +115 -0
  58. package/amplify/functions/shared/python-bundle.ts +141 -0
  59. package/amplify/functions/shared/slack-events.ts +111 -0
  60. package/amplify/functions/slack-delivery/handler.py +112 -0
  61. package/amplify/functions/slack-delivery/requirements.txt +2 -0
  62. package/amplify/functions/slack-delivery/resource.js +38 -0
  63. package/amplify/functions/slack-delivery/resource.ts +51 -0
  64. package/amplify/functions/slack-events/handler.ts +155 -0
  65. package/amplify/functions/slack-events/resource.js +11 -0
  66. package/amplify/functions/slack-events/resource.ts +12 -0
  67. package/amplify/inbound-email/stack.js +46 -0
  68. package/amplify/inbound-email/stack.ts +61 -0
  69. package/amplify/package.json +3 -0
  70. package/amplify/site-backend-config.js +1 -0
  71. package/amplify/site-backend-config.ts +30 -0
  72. package/amplify/site-backend.js +587 -0
  73. package/amplify/site-backend.ts +704 -0
  74. package/amplify/storage/resource.js +18 -0
  75. package/amplify/storage/resource.ts +19 -0
  76. package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +62 -0
  77. package/app/[year]/[month]/[day]/edition-route-page.tsx +53 -0
  78. package/app/[year]/[month]/[day]/page/[pageNumber]/page.tsx +32 -0
  79. package/app/[year]/[month]/[day]/page.tsx +22 -0
  80. package/app/[year]/[month]/[day]/section/[sectionKey]/page.tsx +32 -0
  81. package/app/api/archive/editions/route.ts +18 -0
  82. package/app/api/media/[...path]/route.ts +35 -0
  83. package/app/api/revalidate/route.ts +94 -0
  84. package/app/archive/page.tsx +24 -0
  85. package/app/articles/[slug]/page.tsx +35 -0
  86. package/app/globals.css +10329 -0
  87. package/app/layout.tsx +146 -0
  88. package/app/newsroom/[section]/[...selection]/page.tsx +50 -0
  89. package/app/newsroom/[section]/page.tsx +47 -0
  90. package/app/newsroom/forum/[threadId]/page.tsx +35 -0
  91. package/app/newsroom/insights/[threadId]/page.tsx +22 -0
  92. package/app/newsroom/insights/page.tsx +14 -0
  93. package/app/newsroom/layout.tsx +12 -0
  94. package/app/newsroom/page.tsx +31 -0
  95. package/app/newsroom/sections/[sectionId]/page.tsx +17 -0
  96. package/app/page.tsx +272 -0
  97. package/app/settings/page.tsx +14 -0
  98. package/app/tailwind.css +65 -0
  99. package/bin/papyrus-app.mjs +10 -0
  100. package/bin/papyrus-infra.mjs +39 -0
  101. package/components/ai-elements/conversation.tsx +168 -0
  102. package/components/ai-elements/message.tsx +360 -0
  103. package/components/ai-elements/model-selector.tsx +30 -0
  104. package/components/ai-elements/prompt-input.tsx +1461 -0
  105. package/components/ai-elements/shimmer.tsx +76 -0
  106. package/components/ai-elements/suggestion.tsx +54 -0
  107. package/components/ai-elements/tool.tsx +173 -0
  108. package/components/amplify-client-provider.tsx +140 -0
  109. package/components/archive-grid.tsx +89 -0
  110. package/components/archive-shell.tsx +41 -0
  111. package/components/article-video.tsx +47 -0
  112. package/components/blog-page-background.tsx +20 -0
  113. package/components/dev-sandbox-editor-sign-in.tsx +60 -0
  114. package/components/news-desk-client-provider.tsx +211 -0
  115. package/components/news-desk-page.tsx +119 -0
  116. package/components/news-desk-taxonomy-client.ts +3684 -0
  117. package/components/newspaper.tsx +1929 -0
  118. package/components/newsroom-assignments-view.tsx +664 -0
  119. package/components/newsroom-ops-overview.tsx +226 -0
  120. package/components/newsroom-ops-shell.tsx +319 -0
  121. package/components/newsroom-references-view.tsx +598 -0
  122. package/components/newsroom-topics-view.tsx +425 -0
  123. package/components/papyrus-console-shell.tsx +1912 -0
  124. package/components/pictogram-figure.tsx +65 -0
  125. package/components/presentation-footer.tsx +146 -0
  126. package/components/reader-auth-control.tsx +162 -0
  127. package/components/reader-auth-state.ts +122 -0
  128. package/components/reader-settings.ts +332 -0
  129. package/components/reader-theme-control.tsx +85 -0
  130. package/components/reference-source-preview.tsx +163 -0
  131. package/components/settings-page.tsx +239 -0
  132. package/components/topic-steering-workspace.tsx +18063 -0
  133. package/components/ui/badge.tsx +30 -0
  134. package/components/ui/button-group.tsx +87 -0
  135. package/components/ui/button.tsx +58 -0
  136. package/components/ui/card.tsx +32 -0
  137. package/components/ui/command.tsx +194 -0
  138. package/components/ui/dialog.tsx +158 -0
  139. package/components/ui/dropdown-menu.tsx +266 -0
  140. package/components/ui/hover-card.tsx +51 -0
  141. package/components/ui/input-group.tsx +158 -0
  142. package/components/ui/input.tsx +20 -0
  143. package/components/ui/scroll-area.tsx +16 -0
  144. package/components/ui/select.tsx +199 -0
  145. package/components/ui/separator.tsx +23 -0
  146. package/components/ui/sheet.tsx +92 -0
  147. package/components/ui/spinner.tsx +10 -0
  148. package/components/ui/tabs.tsx +113 -0
  149. package/components/ui/textarea.tsx +18 -0
  150. package/components/ui/tooltip.tsx +66 -0
  151. package/components/use-resolved-papyrus-theme.ts +47 -0
  152. package/infra/amplify-app-shell.js +109 -0
  153. package/infra/amplify-app-shell.ts +155 -0
  154. package/infra/index.js +12 -0
  155. package/lib/amplify-outputs-guard.ts +24 -0
  156. package/lib/amplify-outputs-path.ts +16 -0
  157. package/lib/amplify-server-runtime.ts +63 -0
  158. package/lib/archive-data.ts +30 -0
  159. package/lib/archive-types.ts +15 -0
  160. package/lib/articles.ts +320 -0
  161. package/lib/cached-content-repository.ts +89 -0
  162. package/lib/category-dashboard.ts +37 -0
  163. package/lib/category-repository.ts +1907 -0
  164. package/lib/console-chat-client.ts +1316 -0
  165. package/lib/console-web-context.ts +118 -0
  166. package/lib/content-load-timing.ts +23 -0
  167. package/lib/content-repository.ts +40 -0
  168. package/lib/content-types.ts +104 -0
  169. package/lib/define-site.js +3 -0
  170. package/lib/define-site.ts +21 -0
  171. package/lib/demo-amplify-outputs.ts +15 -0
  172. package/lib/dev-sandbox-editor-auth.ts +18 -0
  173. package/lib/doctrine.ts +128 -0
  174. package/lib/edition-routes.ts +155 -0
  175. package/lib/edition-sections.ts +111 -0
  176. package/lib/empty-edition-layout-plan.ts +109 -0
  177. package/lib/excerpts.ts +8 -0
  178. package/lib/graphql-content-repository.ts +986 -0
  179. package/lib/image-url.ts +10 -0
  180. package/lib/layout-plan.ts +981 -0
  181. package/lib/layout-scenarios.ts +564 -0
  182. package/lib/markus-body.ts +168 -0
  183. package/lib/markus-ir.ts +235 -0
  184. package/lib/markus-projection.ts +243 -0
  185. package/lib/markus-to-article.ts +93 -0
  186. package/lib/news-desk-session.ts +160 -0
  187. package/lib/newspaper-layout.ts +3459 -0
  188. package/lib/newsroom-assignments.ts +173 -0
  189. package/lib/newsroom-base-path.ts +48 -0
  190. package/lib/newsroom-card-layout.ts +102 -0
  191. package/lib/newsroom-category-drilldown.ts +133 -0
  192. package/lib/newsroom-demo-dashboard.ts +267 -0
  193. package/lib/newsroom-demo-profile.ts +46 -0
  194. package/lib/newsroom-forum-routes.ts +153 -0
  195. package/lib/newsroom-index-filters.ts +202 -0
  196. package/lib/newsroom-knowledge-query-request.ts +51 -0
  197. package/lib/newsroom-list-selection.ts +42 -0
  198. package/lib/newsroom-nav.ts +42 -0
  199. package/lib/newsroom-references.ts +116 -0
  200. package/lib/newsroom-sections.ts +52 -0
  201. package/lib/newsroom-topics.ts +118 -0
  202. package/lib/papyrus-object-kinds.ts +24 -0
  203. package/lib/papyrus-web-locations.ts +480 -0
  204. package/lib/pilobol-sample.ts +167 -0
  205. package/lib/presentation-footer.ts +92 -0
  206. package/lib/pretext-layout.ts +179 -0
  207. package/lib/public-placeholder-config.ts +102 -0
  208. package/lib/publication-items.ts +181 -0
  209. package/lib/reader-route-config.ts +1 -0
  210. package/lib/reader-static-params.ts +56 -0
  211. package/lib/reader-storage-url.ts +63 -0
  212. package/lib/reference-display.ts +74 -0
  213. package/lib/reference-policy.ts +97 -0
  214. package/lib/reference-source-preview.ts +175 -0
  215. package/lib/renderer-config.ts +50 -0
  216. package/lib/renderer.ts +87 -0
  217. package/lib/reporting-story-budget.ts +638 -0
  218. package/lib/semantic-graph.ts +554 -0
  219. package/lib/site-brand-tenant.ts +41 -0
  220. package/lib/site-brand.ts +179 -0
  221. package/lib/site-renderer.ts +17 -0
  222. package/lib/site-stack.ts +195 -0
  223. package/lib/themed-image.ts +30 -0
  224. package/lib/utils.ts +6 -0
  225. package/middleware.ts +78 -0
  226. package/package.json +296 -1
  227. package/publications/anth_us/brand.ts +68 -0
  228. package/publications/anth_us/theme.css +66 -0
  229. package/publications/papyrus/theme.css +11 -0
  230. package/publications/pilobol_us/brand.ts +37 -0
  231. package/publications/pilobol_us/theme.css +140 -0
  232. package/publications/threat_intelligence/blog-defense/graph.ts +636 -0
  233. package/publications/threat_intelligence/blog-defense/layout.ts +934 -0
  234. package/publications/threat_intelligence/blog-defense/page-background.tsx +590 -0
  235. package/publications/threat_intelligence/brand.ts +40 -0
  236. package/publications/threat_intelligence/pictograms/art.tsx +608 -0
  237. package/publications/threat_intelligence/pictograms/figure.tsx +82 -0
  238. package/publications/threat_intelligence/pictograms/registry.ts +30 -0
  239. package/publications/threat_intelligence/pictograms/system.tsx +305 -0
  240. package/publications/threat_intelligence/seed/seed-edition-content.json +629 -0
  241. package/publications/threat_intelligence/theme.css +1440 -0
  242. package/renderers/markus/index.ts +25 -0
  243. package/renderers/markus/stubs.tsx +33 -0
  244. package/renderers/pretext/article-page.tsx +174 -0
  245. package/renderers/pretext/index.ts +30 -0
  246. package/renderers/pretext/layouts.ts +16 -0
  247. package/renderers/pretext/presentation-shell.tsx +477 -0
  248. package/routes.manifest.json +182 -0
  249. package/scripts/fixtures/pilobol-sample.ast.json +234 -0
  250. package/src/backend.js +1 -0
  251. package/src/backend.ts +3 -0
  252. package/src/sync.mjs +87 -0
  253. package/src/with-papyrus.d.mts +2 -0
  254. package/src/with-papyrus.mjs +47 -0
@@ -0,0 +1,332 @@
1
+ "use client";
2
+
3
+ import { generateClient } from "aws-amplify/data";
4
+ import type { Schema } from "../amplify/data/resource";
5
+ import type { EditionPresentationFormat } from "../lib/content-types";
6
+ import { enforcePresentation, getDefaultPretextLayout, getPresentationChoices } from "../lib/site-brand";
7
+ import { configureAmplifyClient } from "./amplify-client-provider";
8
+ import { isUnauthenticatedError, loadReaderSessionSnapshot } from "./reader-auth-state";
9
+
10
+ export type ReaderThemeSetting = "system" | "light" | "dark";
11
+ export type ReaderMotionSetting = "standard" | "low";
12
+
13
+ export type ReaderSettings = {
14
+ presentation: EditionPresentationFormat;
15
+ theme: ReaderThemeSetting;
16
+ motion: ReaderMotionSetting;
17
+ };
18
+
19
+ export type ReaderSettingsSource = "local" | "cloud";
20
+
21
+ export type ReaderSettingsResolution = {
22
+ settings: ReaderSettings;
23
+ source: ReaderSettingsSource;
24
+ signedIn: boolean;
25
+ email?: string | null;
26
+ userProfileId?: string | null;
27
+ avatarHash?: string | null;
28
+ };
29
+
30
+ type DataClientErrors = Array<{ message?: string | null } | string | null> | null | undefined;
31
+ type ReaderSettingsResult = {
32
+ email?: string | null;
33
+ userProfileId?: string | null;
34
+ avatarHash?: string | null;
35
+ settings?: ReaderSettings | null;
36
+ };
37
+ type RawReaderSettingsResult = {
38
+ email?: string | null;
39
+ userProfileId?: string | null;
40
+ avatarHash?: string | null;
41
+ settings?: unknown;
42
+ };
43
+
44
+ const USER_POOL_AUTH_MODE = "userPool";
45
+ const SETTINGS_STORAGE_KEY = "papyrus:reader-settings";
46
+ const LEGACY_PRESENTATION_STORAGE_KEY = "papyrus:presentation";
47
+ const LEGACY_PRESENTATION_COOKIE = "papyrus-presentation";
48
+ const LEGACY_COOKIE_CLEANUP_STORAGE_KEY = "papyrus:legacy-presentation-cookie-cleaned";
49
+ const SETTINGS_EVENT = "papyrus:settings-changed";
50
+
51
+ export const DEFAULT_READER_SETTINGS: ReaderSettings = {
52
+ presentation: enforcePresentation(getDefaultPretextLayout()),
53
+ theme: "system",
54
+ motion: "standard",
55
+ };
56
+
57
+ const ALL_PRESENTATION_OPTIONS: Array<{
58
+ value: EditionPresentationFormat;
59
+ label: string;
60
+ description: string;
61
+ }> = [
62
+ {
63
+ value: "newsprint",
64
+ label: "Newspaper",
65
+ description: "Planned pages, feature headlines, and exact continuations.",
66
+ },
67
+ {
68
+ value: "blog",
69
+ label: "Blog",
70
+ description: "Continuous sections with measured Pretext text and no page jumps.",
71
+ },
72
+ {
73
+ value: "magazine",
74
+ label: "Magazine",
75
+ description: "Feature-led section spreads using the same edition items.",
76
+ },
77
+ ];
78
+
79
+ const ALLOWED_PRESENTATION_CHOICES = new Set<EditionPresentationFormat>(getPresentationChoices());
80
+
81
+ export const PRESENTATION_OPTIONS = ALL_PRESENTATION_OPTIONS.filter((option) =>
82
+ ALLOWED_PRESENTATION_CHOICES.has(option.value),
83
+ );
84
+
85
+ export const THEME_OPTIONS: Array<{
86
+ value: ReaderThemeSetting;
87
+ label: string;
88
+ }> = [
89
+ { value: "system", label: "System" },
90
+ { value: "light", label: "Light" },
91
+ { value: "dark", label: "Dark" },
92
+ ];
93
+
94
+ export const MOTION_OPTIONS: Array<{
95
+ value: ReaderMotionSetting;
96
+ label: string;
97
+ }> = [
98
+ { value: "standard", label: "Standard" },
99
+ { value: "low", label: "Low Motion" },
100
+ ];
101
+
102
+ export function readLocalReaderSettings(): ReaderSettings {
103
+ if (typeof window === "undefined") return DEFAULT_READER_SETTINGS;
104
+ cleanupLegacyPresentationCookie();
105
+ const parsed = parseStoredSettings(window.localStorage.getItem(SETTINGS_STORAGE_KEY));
106
+ const legacyPresentation = readPresentationValue(window.localStorage.getItem(LEGACY_PRESENTATION_STORAGE_KEY));
107
+ return normalizeReaderSettings({
108
+ ...parsed,
109
+ presentation: parsed?.presentation ?? legacyPresentation ?? DEFAULT_READER_SETTINGS.presentation,
110
+ });
111
+ }
112
+
113
+ export function writeLocalReaderSettings(settings: ReaderSettings) {
114
+ if (typeof window === "undefined") return;
115
+ cleanupLegacyPresentationCookie();
116
+ const normalized = normalizeReaderSettings(settings);
117
+ window.localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(normalized));
118
+ window.localStorage.setItem(LEGACY_PRESENTATION_STORAGE_KEY, normalized.presentation);
119
+ applyReaderTheme(normalized.theme);
120
+ window.dispatchEvent(new CustomEvent(SETTINGS_EVENT, { detail: normalized }));
121
+ }
122
+
123
+ export function applyReaderTheme(theme: ReaderThemeSetting) {
124
+ if (typeof document === "undefined") return;
125
+ document.documentElement.dataset.papyrusTheme = theme;
126
+ applyReaderThemeClass(theme);
127
+ updateThemeMeta(theme);
128
+ }
129
+
130
+ export function subscribeReaderSettingsChanges(listener: (settings: ReaderSettings) => void): () => void {
131
+ if (typeof window === "undefined") return () => {};
132
+ const notify = () => listener(readLocalReaderSettings());
133
+ const handleSettingsEvent = (event: Event) => {
134
+ const detail = "detail" in event ? (event as CustomEvent<unknown>).detail : null;
135
+ listener(normalizeReaderSettings(detail));
136
+ };
137
+ const handleStorageEvent = (event: StorageEvent) => {
138
+ if (event.key === SETTINGS_STORAGE_KEY || event.key === LEGACY_PRESENTATION_STORAGE_KEY) notify();
139
+ };
140
+ window.addEventListener(SETTINGS_EVENT, handleSettingsEvent);
141
+ window.addEventListener("storage", handleStorageEvent);
142
+ return () => {
143
+ window.removeEventListener(SETTINGS_EVENT, handleSettingsEvent);
144
+ window.removeEventListener("storage", handleStorageEvent);
145
+ };
146
+ }
147
+
148
+ export async function resolveReaderSettings(): Promise<ReaderSettingsResolution> {
149
+ const localSettings = readLocalReaderSettings();
150
+ writeLocalReaderSettings(localSettings);
151
+ const session = await loadReaderSessionSnapshot();
152
+ if (!session.hasSession) {
153
+ return { settings: localSettings, source: "local", signedIn: false };
154
+ }
155
+
156
+ try {
157
+ const remote = await getRemoteReaderSettings();
158
+ if (remote.settings) {
159
+ writeLocalReaderSettings(remote.settings);
160
+ return {
161
+ settings: remote.settings,
162
+ source: "cloud",
163
+ signedIn: true,
164
+ email: remote.email,
165
+ userProfileId: remote.userProfileId,
166
+ avatarHash: remote.avatarHash,
167
+ };
168
+ }
169
+ const saved = await saveRemoteReaderSettings(localSettings);
170
+ return {
171
+ settings: saved.settings ?? localSettings,
172
+ source: "cloud",
173
+ signedIn: true,
174
+ email: saved.email,
175
+ userProfileId: saved.userProfileId,
176
+ avatarHash: saved.avatarHash,
177
+ };
178
+ } catch (error) {
179
+ if (isUnauthenticatedError(error)) {
180
+ return { settings: localSettings, source: "local", signedIn: false };
181
+ }
182
+ throw error;
183
+ }
184
+ }
185
+
186
+ export async function saveReaderSettings(settings: ReaderSettings): Promise<ReaderSettingsResolution> {
187
+ const normalized = normalizeReaderSettings(settings);
188
+ writeLocalReaderSettings(normalized);
189
+ const session = await loadReaderSessionSnapshot();
190
+ if (!session.hasSession) {
191
+ return { settings: normalized, source: "local", signedIn: false };
192
+ }
193
+ const saved = await saveRemoteReaderSettings(normalized);
194
+ const savedSettings = saved.settings ?? normalized;
195
+ writeLocalReaderSettings(savedSettings);
196
+ return {
197
+ settings: savedSettings,
198
+ source: "cloud",
199
+ signedIn: true,
200
+ email: saved.email,
201
+ userProfileId: saved.userProfileId,
202
+ avatarHash: saved.avatarHash,
203
+ };
204
+ }
205
+
206
+ async function getRemoteReaderSettings(): Promise<ReaderSettingsResult> {
207
+ configureAmplifyClient();
208
+ const client = generateClient<Schema>() as unknown as {
209
+ queries: {
210
+ getReaderSettings(options: { authMode: typeof USER_POOL_AUTH_MODE }): Promise<{
211
+ data?: RawReaderSettingsResult | null;
212
+ errors?: DataClientErrors;
213
+ }>;
214
+ };
215
+ };
216
+ const response = await client.queries.getReaderSettings({ authMode: USER_POOL_AUTH_MODE });
217
+ assertNoDataErrors(response.errors, "get reader settings");
218
+ return normalizeReaderSettingsResult(response.data);
219
+ }
220
+
221
+ async function saveRemoteReaderSettings(settings: ReaderSettings): Promise<ReaderSettingsResult> {
222
+ configureAmplifyClient();
223
+ const client = generateClient<Schema>() as unknown as {
224
+ mutations: {
225
+ updateReaderSettings(
226
+ input: { settings: ReaderSettings },
227
+ options: { authMode: typeof USER_POOL_AUTH_MODE },
228
+ ): Promise<{
229
+ data?: RawReaderSettingsResult | null;
230
+ errors?: DataClientErrors;
231
+ }>;
232
+ };
233
+ };
234
+ const response = await client.mutations.updateReaderSettings({ settings }, { authMode: USER_POOL_AUTH_MODE });
235
+ assertNoDataErrors(response.errors, "update reader settings");
236
+ return normalizeReaderSettingsResult(response.data);
237
+ }
238
+
239
+ function normalizeReaderSettingsResult(value: RawReaderSettingsResult | null | undefined): ReaderSettingsResult {
240
+ if (!value) return {};
241
+ return {
242
+ email: typeof value.email === "string" && value.email.trim() ? value.email.trim() : null,
243
+ userProfileId: value.userProfileId ?? null,
244
+ avatarHash: typeof value.avatarHash === "string" && value.avatarHash.trim() ? value.avatarHash.trim() : null,
245
+ settings: value.settings ? normalizeReaderSettings(value.settings) : null,
246
+ };
247
+ }
248
+
249
+ export function normalizeReaderSettings(value: unknown): ReaderSettings {
250
+ const candidate = value && typeof value === "object" ? value as Record<string, unknown> : {};
251
+ return {
252
+ presentation: normalizePresentation(candidate.presentation),
253
+ theme: normalizeTheme(candidate.theme),
254
+ motion: normalizeMotion(candidate.motion),
255
+ };
256
+ }
257
+
258
+ function parseStoredSettings(value: string | null): Partial<ReaderSettings> | null {
259
+ if (!value) return null;
260
+ try {
261
+ const parsed = JSON.parse(value);
262
+ if (!parsed || typeof parsed !== "object") return null;
263
+ return parsed as Partial<ReaderSettings>;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
268
+
269
+ function normalizePresentation(value: unknown): EditionPresentationFormat {
270
+ const normalized =
271
+ value === "blog" || value === "magazine" || value === "newsprint"
272
+ ? value
273
+ : DEFAULT_READER_SETTINGS.presentation;
274
+ return enforcePresentation(normalized);
275
+ }
276
+
277
+ function normalizeTheme(value: unknown): ReaderThemeSetting {
278
+ return value === "light" || value === "dark" || value === "system" ? value : DEFAULT_READER_SETTINGS.theme;
279
+ }
280
+
281
+ function normalizeMotion(value: unknown): ReaderMotionSetting {
282
+ return value === "low" || value === "standard" ? value : DEFAULT_READER_SETTINGS.motion;
283
+ }
284
+
285
+ function readPresentationValue(value: string | null | undefined): EditionPresentationFormat | null {
286
+ if (value !== "newsprint" && value !== "blog" && value !== "magazine") return null;
287
+ return enforcePresentation(value);
288
+ }
289
+
290
+ function cleanupLegacyPresentationCookie() {
291
+ if (typeof document === "undefined" || typeof window === "undefined") return;
292
+ if (window.localStorage.getItem(LEGACY_COOKIE_CLEANUP_STORAGE_KEY) === "true") return;
293
+ document.cookie = `${LEGACY_PRESENTATION_COOKIE}=; path=/; max-age=0; samesite=lax`;
294
+ window.localStorage.setItem(LEGACY_COOKIE_CLEANUP_STORAGE_KEY, "true");
295
+ }
296
+
297
+ function updateThemeMeta(theme: ReaderThemeSetting) {
298
+ const colorScheme = theme === "dark" ? "dark" : theme === "light" ? "light" : "light dark";
299
+ let meta = document.querySelector<HTMLMetaElement>('meta[name="color-scheme"][data-papyrus-theme-meta="true"]');
300
+ if (!meta) {
301
+ meta = document.createElement("meta");
302
+ meta.name = "color-scheme";
303
+ meta.setAttribute("data-papyrus-theme-meta", "true");
304
+ document.head.appendChild(meta);
305
+ }
306
+ meta.content = colorScheme;
307
+ }
308
+
309
+ function applyReaderThemeClass(theme: ReaderThemeSetting) {
310
+ if (typeof document === "undefined") return;
311
+ const isDark = resolveReaderThemeIsDark(theme);
312
+ document.documentElement.classList.toggle("dark", isDark);
313
+ document.documentElement.classList.toggle("dark-theme", isDark);
314
+ document.documentElement.classList.toggle("light", !isDark);
315
+ document.documentElement.classList.toggle("light-theme", !isDark);
316
+ }
317
+
318
+ function resolveReaderThemeIsDark(theme: ReaderThemeSetting) {
319
+ if (theme === "dark") return true;
320
+ if (theme === "light") return false;
321
+ return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches;
322
+ }
323
+
324
+ function assertNoDataErrors(errors: DataClientErrors, operation: string): void {
325
+ if (!errors?.length) return;
326
+ throw new Error(`${operation} failed: ${errors.map(formatDataError).join("; ")}`);
327
+ }
328
+
329
+ function formatDataError(error: { message?: string | null } | string | null): string {
330
+ if (typeof error === "string") return error;
331
+ return error?.message ?? "GraphQL data operation failed.";
332
+ }
@@ -0,0 +1,85 @@
1
+ "use client";
2
+
3
+ import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
4
+ import { useCallback, useEffect, useState } from "react";
5
+ import {
6
+ THEME_OPTIONS,
7
+ applyReaderTheme,
8
+ readLocalReaderSettings,
9
+ saveReaderSettings,
10
+ subscribeReaderSettingsChanges,
11
+ type ReaderThemeSetting,
12
+ } from "./reader-settings";
13
+
14
+ type ReaderThemeControlProps = {
15
+ className?: string;
16
+ };
17
+
18
+ const THEME_ICON_BY_VALUE: Record<ReaderThemeSetting, typeof MonitorIcon> = {
19
+ system: MonitorIcon,
20
+ light: SunIcon,
21
+ dark: MoonIcon,
22
+ };
23
+
24
+ const THEME_LABEL_BY_VALUE: Record<ReaderThemeSetting, string> = {
25
+ system: "Use system theme",
26
+ light: "Use light theme",
27
+ dark: "Use dark theme",
28
+ };
29
+
30
+ export function ReaderThemeControl({ className }: ReaderThemeControlProps) {
31
+ const [theme, setTheme] = useState<ReaderThemeSetting>("system");
32
+ const [pendingTheme, setPendingTheme] = useState<ReaderThemeSetting | null>(null);
33
+
34
+ useEffect(() => {
35
+ const localSettings = readLocalReaderSettings();
36
+ setTheme(localSettings.theme);
37
+ applyReaderTheme(localSettings.theme);
38
+ return subscribeReaderSettingsChanges((settings) => {
39
+ setTheme(settings.theme);
40
+ });
41
+ }, []);
42
+
43
+ const updateTheme = useCallback(async (nextTheme: ReaderThemeSetting) => {
44
+ const nextSettings = { ...readLocalReaderSettings(), theme: nextTheme };
45
+ setTheme(nextTheme);
46
+ setPendingTheme(nextTheme);
47
+ applyReaderTheme(nextTheme);
48
+ try {
49
+ const resolution = await saveReaderSettings(nextSettings);
50
+ setTheme(resolution.settings.theme);
51
+ } catch {
52
+ setTheme(nextTheme);
53
+ } finally {
54
+ setPendingTheme(null);
55
+ }
56
+ }, []);
57
+
58
+ const rootClassName = className ? `reader-theme-control ${className}` : "reader-theme-control";
59
+
60
+ return (
61
+ <div aria-label="Theme mode" className={rootClassName} role="radiogroup">
62
+ {THEME_OPTIONS.map((option) => {
63
+ const selected = theme === option.value;
64
+ const ThemeIcon = THEME_ICON_BY_VALUE[option.value];
65
+ const label = THEME_LABEL_BY_VALUE[option.value];
66
+ return (
67
+ <button
68
+ aria-checked={selected}
69
+ aria-label={label}
70
+ className="reader-theme-control__button"
71
+ data-selected={selected ? "true" : "false"}
72
+ disabled={pendingTheme !== null}
73
+ key={option.value}
74
+ onClick={() => void updateTheme(option.value)}
75
+ role="radio"
76
+ title={label}
77
+ type="button"
78
+ >
79
+ <ThemeIcon aria-hidden="true" className="reader-theme-control__icon" focusable="false" />
80
+ </button>
81
+ );
82
+ })}
83
+ </div>
84
+ );
85
+ }
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ import { FullscreenIcon, XIcon } from "lucide-react";
4
+ import { useEffect, useId, useState } from "react";
5
+ import type { ReferenceAttachmentRecord } from "../lib/category-repository";
6
+ import {
7
+ isPdfAttachment,
8
+ resolveReferenceSourcePreview,
9
+ type ReferenceSourcePreview,
10
+ } from "../lib/reference-source-preview";
11
+
12
+ function PdfSourcePreview({ preview }: { preview: Extract<ReferenceSourcePreview, { kind: "pdf" }> }) {
13
+ const viewportTitleId = useId();
14
+ const [expanded, setExpanded] = useState(false);
15
+
16
+ useEffect(() => {
17
+ if (!expanded) return undefined;
18
+ const previousOverflow = document.body.style.overflow;
19
+ document.body.style.overflow = "hidden";
20
+ const onKeyDown = (event: KeyboardEvent) => {
21
+ if (event.key === "Escape") setExpanded(false);
22
+ };
23
+ window.addEventListener("keydown", onKeyDown);
24
+ return () => {
25
+ document.body.style.overflow = previousOverflow;
26
+ window.removeEventListener("keydown", onKeyDown);
27
+ };
28
+ }, [expanded]);
29
+
30
+ return (
31
+ <>
32
+ <div
33
+ className="news-desk-reference-source-preview news-desk-reference-source-preview--pdf"
34
+ data-news-desk-reference-source-preview="pdf"
35
+ >
36
+ <div className="news-desk-reference-source-preview__pdf-card">
37
+ <button
38
+ aria-label="Expand PDF preview"
39
+ className="news-desk-reference-source-preview__pdf-expand"
40
+ onClick={() => setExpanded(true)}
41
+ type="button"
42
+ >
43
+ <FullscreenIcon aria-hidden size={18} strokeWidth={2} />
44
+ </button>
45
+ <div className="news-desk-reference-source-preview__frame news-desk-reference-source-preview__frame--pdf">
46
+ <iframe
47
+ loading="lazy"
48
+ referrerPolicy="strict-origin-when-cross-origin"
49
+ src={preview.embedUrl}
50
+ tabIndex={-1}
51
+ title={preview.label}
52
+ />
53
+ </div>
54
+ </div>
55
+ </div>
56
+ {expanded ? (
57
+ <div
58
+ aria-labelledby={viewportTitleId}
59
+ aria-modal="true"
60
+ className="news-desk-reference-source-preview__viewport"
61
+ data-news-desk-reference-source-preview-viewport="open"
62
+ role="dialog"
63
+ >
64
+ <header className="news-desk-reference-source-preview__viewport-strip">
65
+ <span className="sr-only" id={viewportTitleId}>
66
+ {preview.label}
67
+ </span>
68
+ <button
69
+ aria-label="Close expanded PDF preview"
70
+ className="news-desk-reference-source-preview__viewport-close"
71
+ onClick={() => setExpanded(false)}
72
+ type="button"
73
+ >
74
+ <XIcon aria-hidden size={18} strokeWidth={2} />
75
+ </button>
76
+ </header>
77
+ <div className="news-desk-reference-source-preview__viewport-frame">
78
+ <iframe
79
+ referrerPolicy="strict-origin-when-cross-origin"
80
+ src={preview.embedUrl}
81
+ title={`${preview.label} (expanded)`}
82
+ />
83
+ </div>
84
+ </div>
85
+ ) : null}
86
+ </>
87
+ );
88
+ }
89
+
90
+ export function ReferenceSourcePreview({
91
+ attachments = [],
92
+ sourceUri,
93
+ }: {
94
+ attachments?: ReferenceAttachmentRecord[];
95
+ sourceUri?: string | null;
96
+ }) {
97
+ const preview = resolveReferenceSourcePreview(sourceUri, attachments);
98
+ const pendingPdfPreview = !preview && attachments.some(
99
+ (attachment) => isPdfAttachment(attachment) && Boolean(attachment.storagePath),
100
+ );
101
+ if (!preview) {
102
+ if (!pendingPdfPreview) return null;
103
+ return (
104
+ <p
105
+ className="news-desk-reference-source-preview__hint news-desk-reference-source-preview__hint--pdf-loading"
106
+ data-news-desk-reference-source-preview="pdf-loading"
107
+ >
108
+ Loading PDF preview…
109
+ </p>
110
+ );
111
+ }
112
+
113
+ if (preview.kind === "youtube") {
114
+ return (
115
+ <div
116
+ className="news-desk-reference-source-preview news-desk-reference-source-preview--youtube"
117
+ data-news-desk-reference-source-preview="youtube"
118
+ >
119
+ <div className="news-desk-reference-source-preview__frame">
120
+ <iframe
121
+ allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
122
+ allowFullScreen
123
+ loading="lazy"
124
+ referrerPolicy="strict-origin-when-cross-origin"
125
+ src={preview.embedUrl}
126
+ title={`YouTube preview ${preview.videoId}`}
127
+ />
128
+ </div>
129
+ <p className="news-desk-reference-source-preview__actions">
130
+ <a href={preview.watchUrl} rel="noopener noreferrer" target="_blank">
131
+ Open on YouTube
132
+ </a>
133
+ </p>
134
+ </div>
135
+ );
136
+ }
137
+
138
+ if (preview.kind === "pdf") {
139
+ return <PdfSourcePreview preview={preview} />;
140
+ }
141
+
142
+ return (
143
+ <div
144
+ className="news-desk-reference-source-preview news-desk-reference-source-preview--html"
145
+ data-news-desk-reference-source-preview="html"
146
+ >
147
+ <div className="news-desk-reference-source-preview__frame">
148
+ <iframe
149
+ loading="lazy"
150
+ referrerPolicy="strict-origin-when-cross-origin"
151
+ sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
152
+ src={preview.href}
153
+ title={preview.label}
154
+ />
155
+ </div>
156
+ <p className="news-desk-reference-source-preview__actions">
157
+ <a href={preview.href} rel="noopener noreferrer" target="_blank">
158
+ {preview.label}
159
+ </a>
160
+ </p>
161
+ </div>
162
+ );
163
+ }