@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/auth/resource.js +17 -4
- package/amplify/auth/resource.ts +21 -4
- package/amplify/data/resource.js +52 -70
- package/amplify/data/resource.ts +57 -70
- package/amplify/data/schema.js +52 -58
- package/amplify/data/schema.ts +57 -58
- package/amplify/functions/console-chat-responder/py/execute_tactus_runner.py +0 -10
- package/amplify/functions/console-chat-responder/resource.js +0 -7
- package/amplify/functions/console-chat-responder/resource.ts +0 -8
- package/amplify/functions/console-chat-responder/src/main.rs +0 -9
- package/amplify/functions/content-actions/handler.py +149 -0
- package/amplify/functions/content-actions/requirements.txt +4 -0
- package/amplify/functions/content-actions/resource.js +40 -0
- package/amplify/functions/content-actions/resource.ts +54 -0
- package/amplify/functions/shared/python-bundle.js +17 -2
- package/amplify/functions/shared/python-bundle.ts +23 -2
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.ts +12 -1
- package/amplify/site-backend.js +51 -27
- package/amplify/site-backend.ts +66 -34
- package/amplify/storage/resource.js +3 -0
- package/amplify/storage/resource.ts +3 -0
- package/app/%5F_preview/[[...path]]/route.ts +8 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +4 -2
- package/app/[year]/[month]/[day]/edition-route-page.tsx +2 -0
- package/app/api/revalidate/route.ts +8 -7
- package/app/articles/[slug]/page.tsx +6 -4
- package/app/blog-rhythm.css +278 -0
- package/app/globals.css +22 -4
- package/app/layout.tsx +24 -12
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/app/page.tsx +12 -4
- package/app/robots.ts +11 -0
- package/bin/papyrus-app.mjs +12 -2
- package/bin/papyrus-infra.mjs +74 -18
- package/components/archive-shell.tsx +2 -22
- package/components/article-video.tsx +247 -3
- package/components/blog-page-background.tsx +23 -6
- package/components/markus-ir-preview.tsx +300 -0
- package/components/news-desk-page.tsx +1 -4
- package/components/newspaper.tsx +6 -2
- package/components/newsroom-access-gate.tsx +66 -0
- package/components/newsroom-article-editor.tsx +741 -0
- package/components/newsroom-articles-view.tsx +129 -0
- package/components/newsroom-ops-overview.tsx +28 -11
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/pictogram-figure.tsx +9 -2
- package/components/presentation-header.tsx +135 -0
- package/components/presentation-rhythm-hrule.tsx +17 -0
- package/components/settings-page.tsx +2 -1
- package/components/staging-banner.tsx +11 -0
- package/components/topic-steering-workspace.tsx +1 -59
- package/components/use-blog-header-obstacles.ts +81 -0
- package/components/use-brand-fonts-ready.ts +28 -0
- package/components/use-rhythm-overlay.ts +29 -0
- package/infra/amplify-app-shell.js +285 -80
- package/infra/amplify-app-shell.ts +326 -113
- package/infra/build-specs.js +94 -0
- package/infra/build-specs.ts +141 -0
- package/infra/github-oidc-provider.js +24 -0
- package/infra/github-oidc-provider.ts +30 -0
- package/infra/index.js +3 -0
- package/infra/site-config.js +273 -0
- package/infra/site-config.ts +336 -0
- package/lib/blog-feature-solver.ts +459 -0
- package/lib/blog-rhythm.ts +134 -0
- package/lib/brand-fonts-watch.ts +43 -0
- package/lib/brand-fonts.ts +112 -0
- package/lib/cached-content-repository.ts +14 -1
- package/lib/category-repository.ts +1 -93
- package/lib/content-actions-client.ts +98 -0
- package/lib/content-actions-demo.ts +190 -0
- package/lib/content-repository.ts +6 -0
- package/lib/content-source-context.ts +26 -0
- package/lib/content-types.ts +4 -0
- package/lib/edition-routes.ts +31 -29
- package/lib/empty-theme.css +1 -0
- package/lib/graphql-content-repository.ts +239 -43
- package/lib/layout-scenarios.ts +8 -8
- package/lib/newspaper-layout.ts +3 -2
- package/lib/newsroom-articles.ts +325 -0
- package/lib/newsroom-demo-articles.ts +90 -0
- package/lib/newsroom-media-backend.ts +142 -0
- package/lib/newsroom-media.ts +182 -0
- package/lib/newsroom-nav.ts +3 -0
- package/lib/papyrus-web-locations.ts +7 -6
- package/lib/presentation-footer.ts +3 -2
- package/lib/pretext-layout.ts +12 -3
- package/lib/reader-base-path.ts +24 -0
- package/lib/reader-static-params.ts +8 -0
- package/lib/renderer.ts +7 -0
- package/lib/revalidate-secret.ts +59 -0
- package/lib/root-route-redirect.ts +16 -0
- package/lib/site-brand-tenant.ts +3 -22
- package/lib/site-brand.ts +95 -42
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/lib/staging-gated-path.ts +22 -0
- package/lib/staging-preview-object.ts +98 -0
- package/lib/video-mode.ts +42 -0
- package/lib/video-script.ts +77 -0
- package/middleware.ts +56 -42
- package/package.json +40 -1
- package/renderers/pretext/article-page.tsx +40 -9
- package/renderers/pretext/presentation-shared.tsx +113 -0
- package/renderers/pretext/presentation-shell.tsx +18 -112
- package/renderers/pretext/rhythm-article-page.tsx +165 -0
- package/renderers/pretext/rhythm-blog-presentation.tsx +279 -0
- package/renderers/pretext/rhythm-shell-style.ts +35 -0
- package/routes.manifest.json +37 -2
- package/scripts/videoml/build-browser-bundle.mjs +22 -0
- package/scripts/videoml/build-preview-bundle.mjs +50 -0
- package/scripts/videoml/bundle-common.mjs +129 -0
- package/scripts/videoml/preview/preview-entry.tsx +381 -0
- package/scripts/videoml/preview/preview-stage-fit.ts +28 -0
- package/scripts/videoml/preview/preview-timing.ts +42 -0
- package/scripts/videoml/preview.html +77 -0
- package/scripts/videoml/standard-entry.tsx +46 -0
- package/src/sync.mjs +30 -6
- package/src/with-papyrus.mjs +15 -2
- package/amplify/functions/graphql-jwt-authorizer/handler.ts +0 -193
- package/amplify/functions/graphql-jwt-authorizer/resource.js +0 -14
- package/amplify/functions/graphql-jwt-authorizer/resource.ts +0 -15
- package/publications/anth_us/brand.ts +0 -68
- package/publications/anth_us/theme.css +0 -66
- package/publications/pilobol_us/brand.ts +0 -37
- package/publications/pilobol_us/theme.css +0 -140
- package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
- package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
- package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
- package/publications/threat_intelligence/brand.ts +0 -40
- package/publications/threat_intelligence/pictograms/art.tsx +0 -608
- package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
- package/publications/threat_intelligence/pictograms/registry.ts +0 -30
- package/publications/threat_intelligence/pictograms/system.tsx +0 -305
- package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
- package/publications/threat_intelligence/theme.css +0 -1440
- /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
|
@@ -0,0 +1,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
|
+
}
|