@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,741 @@
1
+ "use client";
2
+
3
+ import { ArrowLeftIcon, ExternalLinkIcon, ImagePlusIcon } from "lucide-react";
4
+ import Link from "next/link";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { Badge } from "./ui/badge";
7
+ import { Button, buttonVariants } from "./ui/button";
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ } from "./ui/dialog";
16
+ import { Input } from "./ui/input";
17
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
18
+ import { Spinner } from "./ui/spinner";
19
+ import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
20
+ import { Textarea } from "./ui/textarea";
21
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
22
+ import { ContentActionFailure, type ContentActionError } from "../lib/content-actions-client";
23
+ import { newsroomHref } from "../lib/newsroom-base-path";
24
+ import {
25
+ ARTICLE_TYPES,
26
+ emptyArticle,
27
+ locateError,
28
+ resolveArticlesBackend,
29
+ stagingPreviewUrl,
30
+ suggestSlug,
31
+ uniqueSections,
32
+ type ArticlesBackend,
33
+ type EditableArticle,
34
+ type ErrorLocation,
35
+ type NewsroomArticleEditorState,
36
+ } from "../lib/newsroom-articles";
37
+ import { resolveMediaBackend } from "../lib/newsroom-media-backend";
38
+ import {
39
+ insertDirectiveAtSelection,
40
+ readImageDimensions,
41
+ uploadArticleImage,
42
+ } from "../lib/newsroom-media";
43
+ import { getNewsroomNavHref } from "../lib/newsroom-nav";
44
+ import { SITE_BRAND } from "../lib/site-brand";
45
+ import { cn } from "../lib/utils";
46
+ import { MarkusIrPreview } from "./markus-ir-preview";
47
+ import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
48
+ import { NewsDeskAccessGate } from "./newsroom-access-gate";
49
+ import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
50
+ import { articlesAccessBlocked } from "./newsroom-articles-view";
51
+
52
+ const VALIDATION_DEBOUNCE_MS = 600;
53
+ const TEXTAREA_CLASS = "field-sizing-fixed font-mono text-sm md:text-sm";
54
+
55
+ type ValidationState =
56
+ | { status: "checking" }
57
+ | { status: "valid" }
58
+ | { status: "invalid"; errors: ContentActionError[] }
59
+ | { status: "unavailable"; message: string };
60
+
61
+ type Notice = { tone: "ok" | "error" | "info"; text: string };
62
+ type MobileTab = "edit" | "preview";
63
+
64
+ function lineStartIndex(text: string, line: number): number {
65
+ let index = 0;
66
+ for (let current = 1; current < line; current += 1) {
67
+ const next = text.indexOf("\n", index);
68
+ if (next === -1) return text.length;
69
+ index = next + 1;
70
+ }
71
+ return index;
72
+ }
73
+
74
+ function stateFromArticle(article: EditableArticle): NewsroomArticleEditorState {
75
+ return {
76
+ id: article.id,
77
+ type: article.type,
78
+ section: article.section ?? "",
79
+ slug: article.slug,
80
+ frontMatterYaml: article.frontMatterYaml,
81
+ bodyMarkus: article.bodyMarkus,
82
+ contentHash: article.contentHash,
83
+ status: article.status,
84
+ versionNumber: article.versionNumber,
85
+ dirty: false,
86
+ };
87
+ }
88
+
89
+ function failureMessage(error: unknown): string {
90
+ if (error instanceof ContentActionFailure) return error.message;
91
+ return error instanceof Error ? error.message : "The request failed.";
92
+ }
93
+
94
+ export function NewsroomArticleEditor({ articleId, demo }: { articleId: string; demo: boolean }) {
95
+ const session = useOptionalNewsDeskClient();
96
+ const blocked = !demo && articlesAccessBlocked(session?.shell.phase);
97
+ const backend: ArticlesBackend = useMemo(() => resolveArticlesBackend(demo), [demo]);
98
+ const isNew = articleId === "new";
99
+
100
+ const [editor, setEditor] = useState<NewsroomArticleEditorState | null>(isNew ? emptyArticle() : null);
101
+ const [slugEdited, setSlugEdited] = useState(!isNew);
102
+ const [everPublished, setEverPublished] = useState(false);
103
+ const [aliases, setAliases] = useState<string[]>([]);
104
+ const [loadError, setLoadError] = useState<string | null>(null);
105
+ const [validation, setValidation] = useState<ValidationState>({ status: "checking" });
106
+ const [bodyIr, setBodyIr] = useState<unknown>(null);
107
+ const [busy, setBusy] = useState<"save" | "publish" | "unpublish" | null>(null);
108
+ const [notice, setNotice] = useState<Notice | null>(null);
109
+ const [conflictOpen, setConflictOpen] = useState(false);
110
+ const [unpublishOpen, setUnpublishOpen] = useState(false);
111
+ const [mobileTab, setMobileTab] = useState<MobileTab>("edit");
112
+ const [sections, setSections] = useState<string[]>([]);
113
+ const [reloadToken, setReloadToken] = useState(0);
114
+ const [imageUrls, setImageUrls] = useState<Record<string, string>>({});
115
+ const [imageUploading, setImageUploading] = useState(false);
116
+ const [imageError, setImageError] = useState<string | null>(null);
117
+
118
+ const frontMatterRef = useRef<HTMLTextAreaElement | null>(null);
119
+ const bodyRef = useRef<HTMLTextAreaElement | null>(null);
120
+ const imageInputRef = useRef<HTMLInputElement | null>(null);
121
+ const mediaBackend = useMemo(() => resolveMediaBackend(demo), [demo]);
122
+ const validationSequence = useRef(0);
123
+
124
+ useEffect(() => {
125
+ if (blocked || isNew) return;
126
+ let cancelled = false;
127
+ backend
128
+ .loadArticle(articleId)
129
+ .then((article) => {
130
+ if (cancelled) return;
131
+ setEditor(stateFromArticle(article));
132
+ setAliases(article.aliases);
133
+ setEverPublished(article.everPublished);
134
+ setSlugEdited(true);
135
+ setLoadError(null);
136
+ })
137
+ .catch((error: unknown) => {
138
+ if (!cancelled) setLoadError(failureMessage(error));
139
+ });
140
+ return () => {
141
+ cancelled = true;
142
+ };
143
+ }, [articleId, backend, blocked, isNew, reloadToken]);
144
+
145
+ useEffect(() => {
146
+ if (blocked) return;
147
+ let cancelled = false;
148
+ backend
149
+ .listRows()
150
+ .then((rows) => {
151
+ if (!cancelled) setSections(uniqueSections(rows));
152
+ })
153
+ .catch(() => {
154
+ if (!cancelled) setSections([]);
155
+ });
156
+ return () => {
157
+ cancelled = true;
158
+ };
159
+ }, [backend, blocked]);
160
+
161
+ const savedItemId = editor?.id ?? null;
162
+ useEffect(() => {
163
+ if (blocked || savedItemId === null) return;
164
+ let cancelled = false;
165
+ mediaBackend
166
+ .listMediaAssets(savedItemId)
167
+ .then(async (assets) => {
168
+ const entries = await Promise.all(
169
+ assets.map(async (asset) => {
170
+ if (!asset.srcPath || !asset.storagePath) return null;
171
+ try {
172
+ return [asset.srcPath, await mediaBackend.imageUrl(asset.storagePath)] as const;
173
+ } catch {
174
+ return null;
175
+ }
176
+ }),
177
+ );
178
+ if (cancelled) return;
179
+ const loaded = Object.fromEntries(entries.filter((entry) => entry !== null));
180
+ setImageUrls((current) => ({ ...loaded, ...current }));
181
+ })
182
+ .catch(() => undefined);
183
+ return () => {
184
+ cancelled = true;
185
+ };
186
+ }, [blocked, mediaBackend, savedItemId]);
187
+
188
+ const frontMatterYaml = editor?.frontMatterYaml ?? null;
189
+ const bodyMarkus = editor?.bodyMarkus ?? null;
190
+
191
+ useEffect(() => {
192
+ if (frontMatterYaml === null || bodyMarkus === null) return;
193
+ const sequence = ++validationSequence.current;
194
+ setValidation({ status: "checking" });
195
+ const timer = window.setTimeout(() => {
196
+ backend.actions
197
+ .deriveMarkus({ frontMatterYaml, bodyMarkus, includeIr: true })
198
+ .then((result) => {
199
+ if (validationSequence.current !== sequence) return;
200
+ setBodyIr(result.bodyIr ?? null);
201
+ setValidation({ status: "valid" });
202
+ })
203
+ .catch((error: unknown) => {
204
+ if (validationSequence.current !== sequence) return;
205
+ if (error instanceof ContentActionFailure && error.errors.length > 0) {
206
+ setValidation({ status: "invalid", errors: error.errors });
207
+ } else {
208
+ setValidation({ status: "unavailable", message: failureMessage(error) });
209
+ }
210
+ });
211
+ }, VALIDATION_DEBOUNCE_MS);
212
+ return () => window.clearTimeout(timer);
213
+ }, [backend, frontMatterYaml, bodyMarkus]);
214
+
215
+ const dirty = editor?.dirty ?? false;
216
+ useEffect(() => {
217
+ if (!dirty) return;
218
+ const warn = (event: BeforeUnloadEvent) => {
219
+ event.preventDefault();
220
+ event.returnValue = "";
221
+ };
222
+ window.addEventListener("beforeunload", warn);
223
+ return () => window.removeEventListener("beforeunload", warn);
224
+ }, [dirty]);
225
+
226
+ const patchEditor = useCallback((patch: Partial<NewsroomArticleEditorState>) => {
227
+ setEditor((current) => (current ? { ...current, ...patch, dirty: true } : current));
228
+ setNotice(null);
229
+ }, []);
230
+
231
+ const changeFrontMatter = (value: string) => {
232
+ const patch: Partial<NewsroomArticleEditorState> = { frontMatterYaml: value };
233
+ if (!slugEdited && !everPublished) patch.slug = suggestSlug(value);
234
+ patchEditor(patch);
235
+ };
236
+
237
+ const goToLine = (location: ErrorLocation) => {
238
+ setMobileTab("edit");
239
+ window.setTimeout(() => {
240
+ const target = location.field === "frontMatterYaml" ? frontMatterRef.current : bodyRef.current;
241
+ if (!target) return;
242
+ const index = location.line ? lineStartIndex(target.value, location.line) : 0;
243
+ target.focus();
244
+ target.setSelectionRange(index, index);
245
+ }, 0);
246
+ };
247
+
248
+ const insertImage = async (file: File) => {
249
+ if (!editor || editor.id === null) return;
250
+ setImageUploading(true);
251
+ setImageError(null);
252
+ try {
253
+ const uploaded = await uploadArticleImage(mediaBackend, readImageDimensions, {
254
+ itemId: editor.id,
255
+ slug: editor.slug,
256
+ file,
257
+ });
258
+ const textarea = bodyRef.current;
259
+ const start = textarea?.selectionStart ?? editor.bodyMarkus.length;
260
+ const end = textarea?.selectionEnd ?? editor.bodyMarkus.length;
261
+ const inserted = insertDirectiveAtSelection(editor.bodyMarkus, start, end, uploaded.directive);
262
+ setImageUrls((current) => ({ ...current, [uploaded.srcPath]: uploaded.imageUrl }));
263
+ patchEditor({ bodyMarkus: inserted.body });
264
+ window.setTimeout(() => {
265
+ const target = bodyRef.current;
266
+ if (!target) return;
267
+ target.focus();
268
+ target.setSelectionRange(inserted.altSelectionStart, inserted.altSelectionStart);
269
+ }, 0);
270
+ } catch (error) {
271
+ setImageError(failureMessage(error));
272
+ } finally {
273
+ setImageUploading(false);
274
+ }
275
+ };
276
+
277
+ const saveDraft = useCallback(async (): Promise<string | null> => {
278
+ if (!editor) return null;
279
+ setBusy("save");
280
+ try {
281
+ const result = await backend.actions.saveItemDraft({
282
+ id: editor.id,
283
+ type: editor.type,
284
+ slug: editor.slug,
285
+ section: editor.section.trim() ? editor.section.trim() : null,
286
+ frontMatterYaml: editor.frontMatterYaml,
287
+ bodyMarkus: editor.bodyMarkus,
288
+ aliases,
289
+ expectedContentHash: editor.contentHash,
290
+ });
291
+ setEditor((current) =>
292
+ current
293
+ ? {
294
+ ...current,
295
+ id: result.item.id,
296
+ contentHash: result.item.contentHash,
297
+ status: result.item.status === "published" ? "published" : "draft",
298
+ versionNumber: result.item.versionNumber,
299
+ slug: result.item.slug,
300
+ dirty: false,
301
+ }
302
+ : current,
303
+ );
304
+ if (editor.id === null) {
305
+ window.history.replaceState(
306
+ null,
307
+ "",
308
+ getNewsroomNavHref(newsroomHref("articles", result.item.id), demo),
309
+ );
310
+ }
311
+ setNotice({ tone: "ok", text: "Draft saved." });
312
+ return result.item.id;
313
+ } catch (error) {
314
+ if (error instanceof ContentActionFailure && error.errors.some((entry) => entry.code === "conflict")) {
315
+ setConflictOpen(true);
316
+ } else if (error instanceof ContentActionFailure && error.errors.some((entry) => entry.line !== null || entry.code.startsWith("markus"))) {
317
+ setValidation({ status: "invalid", errors: error.errors });
318
+ } else {
319
+ setNotice({ tone: "error", text: failureMessage(error) });
320
+ }
321
+ return null;
322
+ } finally {
323
+ setBusy(null);
324
+ }
325
+ }, [aliases, backend, demo, editor]);
326
+
327
+ const publish = async () => {
328
+ if (!editor) return;
329
+ let id = editor.id;
330
+ if (editor.dirty || id === null) {
331
+ id = await saveDraft();
332
+ if (id === null) return;
333
+ }
334
+ setBusy("publish");
335
+ try {
336
+ const result = await backend.actions.publishItem(id);
337
+ setEditor((current) =>
338
+ current ? { ...current, id, status: "published", versionNumber: result.versionNumber } : current,
339
+ );
340
+ setEverPublished(true);
341
+ setNotice({
342
+ tone: "ok",
343
+ text: result.changed
344
+ ? `Published as version ${result.versionNumber ?? "?"}.`
345
+ : `Already published; no changes to publish (version ${result.versionNumber ?? "?"}).`,
346
+ });
347
+ } catch (error) {
348
+ setNotice({ tone: "error", text: failureMessage(error) });
349
+ } finally {
350
+ setBusy(null);
351
+ }
352
+ };
353
+
354
+ const unpublish = async () => {
355
+ if (!editor?.id) return;
356
+ setUnpublishOpen(false);
357
+ setBusy("unpublish");
358
+ try {
359
+ await backend.actions.unpublishItem(editor.id);
360
+ setEditor((current) => (current ? { ...current, status: "draft" } : current));
361
+ setNotice({ tone: "ok", text: "Unpublished. The article is a draft again." });
362
+ } catch (error) {
363
+ setNotice({ tone: "error", text: failureMessage(error) });
364
+ } finally {
365
+ setBusy(null);
366
+ }
367
+ };
368
+
369
+ const reloadAfterConflict = () => {
370
+ setConflictOpen(false);
371
+ setLoadError(null);
372
+ setReloadToken((token) => token + 1);
373
+ };
374
+
375
+ if (blocked) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
376
+
377
+ const listHref = getNewsroomNavHref(newsroomHref("articles"), demo);
378
+ const pageTitle = editor
379
+ ? (isNew && !editor.id ? "New article" : editor.slug || "Article")
380
+ : "Article";
381
+
382
+ const body = (() => {
383
+ if (loadError) {
384
+ return (
385
+ <div className="space-y-3">
386
+ <NewsroomOpsStatusBanner tone="error">{loadError}</NewsroomOpsStatusBanner>
387
+ <Link className={cn(buttonVariants({ variant: "outline", size: "sm" }))} href={listHref}>
388
+ Back to articles
389
+ </Link>
390
+ </div>
391
+ );
392
+ }
393
+ if (!editor) {
394
+ return (
395
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
396
+ <Spinner /> Loading article…
397
+ </div>
398
+ );
399
+ }
400
+
401
+ const slugLocked = everPublished;
402
+ const invalid = validation.status === "invalid" || validation.status === "unavailable";
403
+ const publishBlocked = invalid || validation.status === "checking" || busy !== null || !editor.slug.trim();
404
+ const saveBlocked = invalid || busy !== null || !editor.slug.trim() || (!editor.dirty && editor.id !== null);
405
+ const stagingUrl = stagingPreviewUrl(
406
+ process.env.NEXT_PUBLIC_PAPYRUS_STAGING_URL,
407
+ editor.type,
408
+ editor.slug,
409
+ process.env.NEXT_PUBLIC_PAPYRUS_STAGING_PREVIEW === "static",
410
+ );
411
+ const errors = validation.status === "invalid" ? validation.errors : [];
412
+ const statusLabel =
413
+ editor.status === "published" ? "Published" : "Draft";
414
+
415
+ const slugInput = (
416
+ <Input
417
+ aria-label="Slug"
418
+ data-newsroom-article-slug
419
+ disabled={slugLocked}
420
+ onChange={(event) => {
421
+ setSlugEdited(true);
422
+ patchEditor({ slug: event.target.value });
423
+ }}
424
+ value={editor.slug}
425
+ />
426
+ );
427
+
428
+ return (
429
+ <TooltipProvider>
430
+ <div className="space-y-4" data-newsroom-article-editor>
431
+ <div className="flex flex-wrap items-center gap-2" data-newsroom-article-toolbar>
432
+ <Link
433
+ aria-label="Back to articles"
434
+ className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "px-2")}
435
+ href={listHref}
436
+ >
437
+ <ArrowLeftIcon aria-hidden="true" />
438
+ Articles
439
+ </Link>
440
+ <Badge data-newsroom-article-status variant={editor.status === "published" ? "secondary" : "muted"}>
441
+ {statusLabel}
442
+ </Badge>
443
+ {editor.versionNumber !== null && editor.status === "published" ? (
444
+ <Badge data-newsroom-article-version variant="outline">{`Version ${editor.versionNumber}`}</Badge>
445
+ ) : null}
446
+ <Badge
447
+ data-newsroom-article-validity={validation.status}
448
+ variant={validation.status === "valid" ? "secondary" : invalid ? "outline" : "muted"}
449
+ >
450
+ {validation.status === "valid"
451
+ ? "Valid"
452
+ : validation.status === "invalid"
453
+ ? `${errors.length} ${errors.length === 1 ? "error" : "errors"}`
454
+ : validation.status === "unavailable"
455
+ ? "Validation unavailable"
456
+ : "Checking…"}
457
+ </Badge>
458
+ {editor.dirty ? <Badge variant="outline">Unsaved changes</Badge> : null}
459
+ <div className="ml-auto flex flex-wrap items-center gap-2">
460
+ {stagingUrl ? (
461
+ <a
462
+ className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}
463
+ data-newsroom-article-staging-link
464
+ href={stagingUrl}
465
+ rel="noopener noreferrer"
466
+ target="_blank"
467
+ >
468
+ Preview on staging
469
+ <ExternalLinkIcon aria-hidden="true" />
470
+ </a>
471
+ ) : null}
472
+ <Button
473
+ data-newsroom-article-save
474
+ disabled={saveBlocked}
475
+ onClick={() => void saveDraft()}
476
+ size="sm"
477
+ type="button"
478
+ variant="outline"
479
+ >
480
+ {busy === "save" ? <Spinner /> : null}
481
+ Save draft
482
+ </Button>
483
+ <Button
484
+ data-newsroom-article-publish
485
+ disabled={publishBlocked}
486
+ onClick={() => void publish()}
487
+ size="sm"
488
+ type="button"
489
+ >
490
+ {busy === "publish" ? <Spinner /> : null}
491
+ Publish
492
+ </Button>
493
+ {editor.status === "published" ? (
494
+ <Button
495
+ data-newsroom-article-unpublish
496
+ disabled={busy !== null}
497
+ onClick={() => setUnpublishOpen(true)}
498
+ size="sm"
499
+ type="button"
500
+ variant="outline"
501
+ >
502
+ Unpublish
503
+ </Button>
504
+ ) : null}
505
+ </div>
506
+ </div>
507
+
508
+ {notice ? (
509
+ <div data-newsroom-article-notice>
510
+ <NewsroomOpsStatusBanner tone={notice.tone}>{notice.text}</NewsroomOpsStatusBanner>
511
+ </div>
512
+ ) : null}
513
+
514
+ <Tabs className="lg:hidden" defaultValue="edit" onValueChange={(value) => setMobileTab(value as MobileTab)} value={mobileTab}>
515
+ <TabsList>
516
+ <TabsTrigger className="min-w-[6rem]" value="edit">Edit</TabsTrigger>
517
+ <TabsTrigger className="min-w-[6rem]" value="preview">Preview</TabsTrigger>
518
+ </TabsList>
519
+ </Tabs>
520
+
521
+ <div className="grid gap-6 lg:grid-cols-2">
522
+ <div
523
+ className={cn("min-w-0 space-y-4", mobileTab === "edit" ? "block" : "hidden", "lg:block")}
524
+ data-newsroom-article-edit-column
525
+ >
526
+ <div className="grid gap-3 sm:grid-cols-3">
527
+ <label className="space-y-1 text-sm font-medium">
528
+ <span>Type</span>
529
+ <Select
530
+ onValueChange={(value) => {
531
+ if (typeof value === "string") patchEditor({ type: value });
532
+ }}
533
+ value={editor.type}
534
+ >
535
+ <SelectTrigger aria-label="Type" className="w-full" data-newsroom-article-type>
536
+ <SelectValue />
537
+ </SelectTrigger>
538
+ <SelectContent>
539
+ {ARTICLE_TYPES.map((type) => (
540
+ <SelectItem key={type} value={type}>{type}</SelectItem>
541
+ ))}
542
+ </SelectContent>
543
+ </Select>
544
+ </label>
545
+ <label className="space-y-1 text-sm font-medium">
546
+ <span>Section</span>
547
+ <Input
548
+ data-newsroom-article-section
549
+ list="newsroom-article-sections"
550
+ onChange={(event) => patchEditor({ section: event.target.value })}
551
+ value={editor.section}
552
+ />
553
+ <datalist id="newsroom-article-sections">
554
+ {sections.map((section) => (
555
+ <option key={section} value={section} />
556
+ ))}
557
+ </datalist>
558
+ </label>
559
+ <label className="space-y-1 text-sm font-medium">
560
+ <span>Slug</span>
561
+ {slugLocked ? (
562
+ <Tooltip>
563
+ <TooltipTrigger render={<span className="block" data-newsroom-article-slug-locked />}>
564
+ {slugInput}
565
+ </TooltipTrigger>
566
+ <TooltipContent>The slug cannot change once the article has been published.</TooltipContent>
567
+ </Tooltip>
568
+ ) : (
569
+ slugInput
570
+ )}
571
+ </label>
572
+ </div>
573
+
574
+ <label className="block space-y-1 text-sm font-medium">
575
+ <span>Front matter (YAML)</span>
576
+ <Textarea
577
+ className={TEXTAREA_CLASS}
578
+ data-newsroom-article-front-matter
579
+ onChange={(event) => changeFrontMatter(event.target.value)}
580
+ ref={frontMatterRef}
581
+ rows={8}
582
+ spellCheck={false}
583
+ value={editor.frontMatterYaml}
584
+ />
585
+ </label>
586
+
587
+ <div className="space-y-1 text-sm font-medium">
588
+ <div className="flex flex-wrap items-center justify-between gap-2">
589
+ <label htmlFor="newsroom-article-body">Body (Markus)</label>
590
+ <Button
591
+ data-newsroom-article-insert-image
592
+ disabled={editor.id === null || imageUploading || busy !== null}
593
+ onClick={() => imageInputRef.current?.click()}
594
+ size="xs"
595
+ type="button"
596
+ variant="outline"
597
+ >
598
+ {imageUploading ? <Spinner /> : <ImagePlusIcon aria-hidden="true" />}
599
+ Insert image
600
+ </Button>
601
+ <input
602
+ accept="image/*"
603
+ aria-label="Image file"
604
+ className="hidden"
605
+ data-newsroom-article-image-input
606
+ onChange={(event) => {
607
+ const file = event.target.files?.[0];
608
+ event.target.value = "";
609
+ if (file) void insertImage(file);
610
+ }}
611
+ ref={imageInputRef}
612
+ type="file"
613
+ />
614
+ </div>
615
+ {editor.id === null ? (
616
+ <p className="text-xs font-normal text-muted-foreground" data-newsroom-article-image-hint>
617
+ Save the draft once to enable image upload.
618
+ </p>
619
+ ) : null}
620
+ {imageError ? (
621
+ <p className="text-xs font-normal text-destructive" data-newsroom-article-image-error role="alert">
622
+ {imageError}
623
+ </p>
624
+ ) : null}
625
+ <Textarea
626
+ className={TEXTAREA_CLASS}
627
+ data-newsroom-article-body
628
+ id="newsroom-article-body"
629
+ onChange={(event) => patchEditor({ bodyMarkus: event.target.value })}
630
+ ref={bodyRef}
631
+ rows={28}
632
+ spellCheck={false}
633
+ value={editor.bodyMarkus}
634
+ />
635
+ </div>
636
+
637
+ {validation.status === "unavailable" ? (
638
+ <NewsroomOpsStatusBanner tone="error">{validation.message}</NewsroomOpsStatusBanner>
639
+ ) : null}
640
+ {errors.length > 0 ? (
641
+ <ul
642
+ aria-label="Validation errors"
643
+ className="space-y-2 rounded-xl border border-destructive/30 bg-destructive/10 p-3 text-sm"
644
+ data-newsroom-article-errors
645
+ >
646
+ {errors.map((error, index) => {
647
+ const location = locateError(error, editor.frontMatterYaml);
648
+ return (
649
+ <li className="flex flex-wrap items-baseline gap-x-2 gap-y-1" data-newsroom-article-error={error.code} key={`${error.code}-${index}`}>
650
+ <code className="font-mono text-xs">{error.code}</code>
651
+ <span className="min-w-0 flex-1 break-words">{error.message}</span>
652
+ <Button
653
+ onClick={() => goToLine(location)}
654
+ size="xs"
655
+ type="button"
656
+ variant="outline"
657
+ >
658
+ {location.line
659
+ ? `${location.field === "frontMatterYaml" ? "front matter " : ""}line ${location.line}`
660
+ : location.field === "frontMatterYaml" ? "front matter" : "body"}
661
+ </Button>
662
+ </li>
663
+ );
664
+ })}
665
+ </ul>
666
+ ) : null}
667
+ </div>
668
+
669
+ <div
670
+ className={cn("min-w-0", mobileTab === "preview" ? "block" : "hidden", "lg:block")}
671
+ data-newsroom-article-preview-column
672
+ >
673
+ <div className="rounded-xl border border-border bg-card p-4" data-newsroom-article-preview>
674
+ <p className="mb-2 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
675
+ Preview
676
+ </p>
677
+ {invalid && bodyIr ? (
678
+ <p className="mb-2 text-xs text-muted-foreground">Showing the last valid version.</p>
679
+ ) : null}
680
+ {bodyIr ? (
681
+ <MarkusIrPreview bodyIr={bodyIr} imageUrls={imageUrls} />
682
+ ) : (
683
+ <p className="text-sm text-muted-foreground">
684
+ {invalid ? "Fix the errors to see a preview." : "The preview appears once the markup is valid."}
685
+ </p>
686
+ )}
687
+ </div>
688
+ </div>
689
+ </div>
690
+ </div>
691
+
692
+ <Dialog onOpenChange={setConflictOpen} open={conflictOpen}>
693
+ <DialogContent>
694
+ <DialogHeader>
695
+ <DialogTitle>Someone else saved this article</DialogTitle>
696
+ <DialogDescription>
697
+ The article changed since you opened it. Reload to get the latest version; your unsaved edits here will be replaced.
698
+ </DialogDescription>
699
+ </DialogHeader>
700
+ <DialogFooter>
701
+ <Button data-newsroom-article-conflict-reload onClick={reloadAfterConflict} type="button">
702
+ Reload
703
+ </Button>
704
+ </DialogFooter>
705
+ </DialogContent>
706
+ </Dialog>
707
+
708
+ <Dialog onOpenChange={setUnpublishOpen} open={unpublishOpen}>
709
+ <DialogContent>
710
+ <DialogHeader>
711
+ <DialogTitle>Unpublish this article?</DialogTitle>
712
+ <DialogDescription>
713
+ The article is removed from the published site and becomes a draft again.
714
+ </DialogDescription>
715
+ </DialogHeader>
716
+ <DialogFooter>
717
+ <Button onClick={() => setUnpublishOpen(false)} type="button" variant="outline">Cancel</Button>
718
+ <Button data-newsroom-article-unpublish-confirm onClick={() => void unpublish()} type="button">
719
+ Unpublish
720
+ </Button>
721
+ </DialogFooter>
722
+ </DialogContent>
723
+ </Dialog>
724
+ </TooltipProvider>
725
+ );
726
+ })();
727
+
728
+ return (
729
+ <NewsroomOpsShell
730
+ activeTab="articles"
731
+ appTitle={SITE_BRAND.appTitle}
732
+ backHref="/"
733
+ backLabel={SITE_BRAND.backToHomeLabel}
734
+ demo={demo}
735
+ headerActions={demo ? <Badge variant="outline">Demo</Badge> : null}
736
+ pageTitle={pageTitle}
737
+ >
738
+ {body}
739
+ </NewsroomOpsShell>
740
+ );
741
+ }