@anthusai/papyrus 1.0.0-next.5 → 1.0.0-next.6

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