@anthusai/papyrus 1.0.0-next.5 → 1.0.0-next.7
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/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/content-actions/handler.py +44 -2
- package/amplify/rebuild-trigger-settings.js +22 -0
- package/amplify/rebuild-trigger-settings.ts +42 -0
- package/amplify/site-backend-config.ts +6 -0
- package/amplify/site-backend.js +18 -0
- package/amplify/site-backend.ts +20 -0
- package/app/newsroom/articles/[id]/page.tsx +15 -0
- package/app/newsroom/articles/page.tsx +13 -0
- package/components/markus-ir-preview.tsx +300 -0
- 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-shell.tsx +3 -1
- package/components/topic-steering-workspace.tsx +1 -59
- package/lib/content-actions-demo.ts +190 -0
- 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 +2 -0
- package/package.json +12 -1
- package/routes.manifest.json +17 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { PlusIcon } from "lucide-react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { Badge } from "./ui/badge";
|
|
7
|
+
import { buttonVariants } from "./ui/button";
|
|
8
|
+
import { Spinner } from "./ui/spinner";
|
|
9
|
+
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
|
10
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
11
|
+
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
12
|
+
import { newsroomHref } from "../lib/newsroom-base-path";
|
|
13
|
+
import {
|
|
14
|
+
resolveArticlesBackend,
|
|
15
|
+
rowStatusLabel,
|
|
16
|
+
type NewsroomArticleRow,
|
|
17
|
+
} from "../lib/newsroom-articles";
|
|
18
|
+
import { cn } from "../lib/utils";
|
|
19
|
+
import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
20
|
+
import { NewsDeskAccessGate } from "./newsroom-access-gate";
|
|
21
|
+
import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
|
|
22
|
+
|
|
23
|
+
type ArticleFilter = "all" | "drafts" | "published";
|
|
24
|
+
|
|
25
|
+
export function articlesAccessBlocked(phase: string | undefined): boolean {
|
|
26
|
+
return phase === undefined || phase === "checkingAccess" || phase === "signedOut" || phase === "forbidden" || phase === "error";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function NewsroomArticlesView({ demo }: { demo: boolean }) {
|
|
30
|
+
const session = useOptionalNewsDeskClient();
|
|
31
|
+
const blocked = !demo && articlesAccessBlocked(session?.shell.phase);
|
|
32
|
+
const [rows, setRows] = useState<NewsroomArticleRow[] | null>(null);
|
|
33
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
34
|
+
const [filter, setFilter] = useState<ArticleFilter>("all");
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (blocked) return;
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
resolveArticlesBackend(demo)
|
|
40
|
+
.listRows()
|
|
41
|
+
.then((loaded) => {
|
|
42
|
+
if (!cancelled) setRows(loaded);
|
|
43
|
+
})
|
|
44
|
+
.catch((error: unknown) => {
|
|
45
|
+
if (!cancelled) setLoadError(error instanceof Error ? error.message : "Could not load articles.");
|
|
46
|
+
});
|
|
47
|
+
return () => {
|
|
48
|
+
cancelled = true;
|
|
49
|
+
};
|
|
50
|
+
}, [blocked, demo]);
|
|
51
|
+
|
|
52
|
+
const visibleRows = useMemo(() => {
|
|
53
|
+
if (!rows) return [];
|
|
54
|
+
if (filter === "drafts") return rows.filter((row) => row.status === "draft");
|
|
55
|
+
if (filter === "published") return rows.filter((row) => row.status === "published");
|
|
56
|
+
return rows;
|
|
57
|
+
}, [filter, rows]);
|
|
58
|
+
|
|
59
|
+
if (blocked) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<NewsroomOpsShell
|
|
63
|
+
activeTab="articles"
|
|
64
|
+
appTitle={SITE_BRAND.appTitle}
|
|
65
|
+
backHref="/"
|
|
66
|
+
backLabel={SITE_BRAND.backToHomeLabel}
|
|
67
|
+
demo={demo}
|
|
68
|
+
headerActions={(
|
|
69
|
+
<>
|
|
70
|
+
{demo ? <Badge variant="outline">Demo</Badge> : null}
|
|
71
|
+
<Link
|
|
72
|
+
className={cn(buttonVariants({ size: "sm" }))}
|
|
73
|
+
data-newsroom-article-new
|
|
74
|
+
href={getNewsroomNavHref(newsroomHref("articles", "new"), demo)}
|
|
75
|
+
>
|
|
76
|
+
<PlusIcon aria-hidden="true" />
|
|
77
|
+
New article
|
|
78
|
+
</Link>
|
|
79
|
+
</>
|
|
80
|
+
)}
|
|
81
|
+
pageTitle="Articles"
|
|
82
|
+
>
|
|
83
|
+
<div className="space-y-4" data-newsroom-articles>
|
|
84
|
+
<Tabs defaultValue="all" onValueChange={(value) => setFilter(value as ArticleFilter)} value={filter}>
|
|
85
|
+
<TabsList>
|
|
86
|
+
<TabsTrigger className="min-w-[5.5rem]" value="all">All</TabsTrigger>
|
|
87
|
+
<TabsTrigger className="min-w-[5.5rem]" value="drafts">Drafts</TabsTrigger>
|
|
88
|
+
<TabsTrigger className="min-w-[5.5rem]" value="published">Published</TabsTrigger>
|
|
89
|
+
</TabsList>
|
|
90
|
+
</Tabs>
|
|
91
|
+
{loadError ? <NewsroomOpsStatusBanner tone="error">{loadError}</NewsroomOpsStatusBanner> : null}
|
|
92
|
+
{rows === null && !loadError ? (
|
|
93
|
+
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
94
|
+
<Spinner /> Loading articles…
|
|
95
|
+
</div>
|
|
96
|
+
) : null}
|
|
97
|
+
{rows !== null && visibleRows.length === 0 ? (
|
|
98
|
+
<p className="text-sm text-muted-foreground">Nothing here yet.</p>
|
|
99
|
+
) : null}
|
|
100
|
+
<ul className="divide-y divide-border rounded-xl border border-border bg-card" data-newsroom-articles-list>
|
|
101
|
+
{visibleRows.map((row) => (
|
|
102
|
+
<li key={row.id}>
|
|
103
|
+
<Link
|
|
104
|
+
className="flex flex-col gap-1 px-4 py-3 hover:bg-muted/40 sm:flex-row sm:items-center sm:justify-between sm:gap-4"
|
|
105
|
+
data-newsroom-article-row={row.slug}
|
|
106
|
+
href={getNewsroomNavHref(newsroomHref("articles", row.id), demo)}
|
|
107
|
+
>
|
|
108
|
+
<span className="min-w-0">
|
|
109
|
+
<span className="block truncate text-sm font-medium">{row.title}</span>
|
|
110
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
111
|
+
{row.slug}
|
|
112
|
+
{row.section ? ` · ${row.section}` : ""}
|
|
113
|
+
{row.type !== "article" ? ` · ${row.type}` : ""}
|
|
114
|
+
</span>
|
|
115
|
+
</span>
|
|
116
|
+
<Badge
|
|
117
|
+
data-newsroom-article-status
|
|
118
|
+
variant={row.status === "draft" ? "muted" : row.pending ? "outline" : "secondary"}
|
|
119
|
+
>
|
|
120
|
+
{rowStatusLabel(row)}
|
|
121
|
+
</Badge>
|
|
122
|
+
</Link>
|
|
123
|
+
</li>
|
|
124
|
+
))}
|
|
125
|
+
</ul>
|
|
126
|
+
</div>
|
|
127
|
+
</NewsroomOpsShell>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import {
|
|
4
4
|
BookOpenIcon,
|
|
5
5
|
ClipboardListIcon,
|
|
6
|
+
FileTextIcon,
|
|
6
7
|
LayoutGridIcon,
|
|
7
8
|
MenuIcon,
|
|
8
9
|
SearchIcon,
|
|
@@ -55,6 +56,7 @@ type NewsroomOpsShellProps = {
|
|
|
55
56
|
|
|
56
57
|
const MOBILE_TAB_ICONS: Record<string, typeof LayoutGridIcon> = {
|
|
57
58
|
overview: LayoutGridIcon,
|
|
59
|
+
articles: FileTextIcon,
|
|
58
60
|
assignments: ClipboardListIcon,
|
|
59
61
|
references: BookOpenIcon,
|
|
60
62
|
topics: TagsIcon,
|
|
@@ -198,7 +200,7 @@ export function NewsroomOpsShell({
|
|
|
198
200
|
className="shrink-0 border-t border-border bg-card/95 pb-[max(env(safe-area-inset-bottom),0.35rem)] pt-1 md:hidden"
|
|
199
201
|
data-newsroom-ops-bottom-nav
|
|
200
202
|
>
|
|
201
|
-
<ul className="grid grid-cols-
|
|
203
|
+
<ul className="grid grid-cols-6 gap-1 px-1">
|
|
202
204
|
{mobilePrimary.map((item) => {
|
|
203
205
|
const Icon = MOBILE_TAB_ICONS[item.id] ?? LayoutGridIcon;
|
|
204
206
|
const active = resolvedActiveTab === item.id;
|
|
@@ -81,7 +81,7 @@ import { cn } from "../lib/utils";
|
|
|
81
81
|
import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
82
82
|
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
83
83
|
import type { ReaderAuthSnapshot } from "./reader-auth-state";
|
|
84
|
-
import {
|
|
84
|
+
import { NewsDeskAccessGate } from "./newsroom-access-gate";
|
|
85
85
|
import { NewsroomOpsOverview } from "./newsroom-ops-overview";
|
|
86
86
|
import { NewsroomReferencesView } from "./newsroom-references-view";
|
|
87
87
|
import { NewsroomAssignmentsView } from "./newsroom-assignments-view";
|
|
@@ -169,7 +169,6 @@ import {
|
|
|
169
169
|
uniqueNeighborGroupsForCategoryContext,
|
|
170
170
|
type CategoryDrilldownContext,
|
|
171
171
|
} from "../lib/newsroom-category-drilldown";
|
|
172
|
-
import type { NewsDeskShellState } from "../lib/news-desk-session";
|
|
173
172
|
import {
|
|
174
173
|
resolveNewsroomCardTemplate,
|
|
175
174
|
type NewsroomCardSpan,
|
|
@@ -15399,63 +15398,6 @@ function readTextClaim(value: unknown): string | null {
|
|
|
15399
15398
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
15400
15399
|
}
|
|
15401
15400
|
|
|
15402
|
-
function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
|
|
15403
|
-
const accessPhase = shell?.phase ?? "checkingAccess";
|
|
15404
|
-
|
|
15405
|
-
return (
|
|
15406
|
-
<NewsroomOpsShell
|
|
15407
|
-
activeTab="overview"
|
|
15408
|
-
appTitle={SITE_BRAND.appTitle}
|
|
15409
|
-
backHref="/"
|
|
15410
|
-
backLabel={SITE_BRAND.backToHomeLabel}
|
|
15411
|
-
headerActions={(
|
|
15412
|
-
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
|
|
15413
|
-
)}
|
|
15414
|
-
pageTitle="Newsroom"
|
|
15415
|
-
showNavigation={showSectionTabs}
|
|
15416
|
-
>
|
|
15417
|
-
<section aria-live="polite" data-news-desk-access={accessPhase} data-news-desk-access-phase={accessPhase}>
|
|
15418
|
-
<div className="space-y-3 rounded-xl border border-border bg-card p-6">
|
|
15419
|
-
<p className="m-0 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Access</p>
|
|
15420
|
-
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight">{formatAccessTitle(shell)}</h2>
|
|
15421
|
-
<p className="m-0 text-sm text-muted-foreground">{formatAccessDetail(shell)}</p>
|
|
15422
|
-
{shell?.error ? <p className="text-sm text-destructive">{shell.error}</p> : null}
|
|
15423
|
-
<p className="m-0 text-sm text-foreground">{formatAccessActionDetail(shell)}</p>
|
|
15424
|
-
{shell?.phase === "signedOut" || shell?.phase === "error" ? (
|
|
15425
|
-
<div className="news-desk-access-panel__auth">
|
|
15426
|
-
<ReaderAuthControl postAuthPath="/newsroom" showIdentity />
|
|
15427
|
-
</div>
|
|
15428
|
-
) : null}
|
|
15429
|
-
</div>
|
|
15430
|
-
</section>
|
|
15431
|
-
</NewsroomOpsShell>
|
|
15432
|
-
);
|
|
15433
|
-
}
|
|
15434
|
-
|
|
15435
|
-
function formatAccessTitle(state: NewsDeskShellState | null): string {
|
|
15436
|
-
if (!state || state.phase === "checkingAccess") return "Checking Desk Credentials";
|
|
15437
|
-
if (state.phase === "loadingDesk") return "Loading Newsroom Records";
|
|
15438
|
-
if (state.phase === "forbidden") return "Editor Role Required";
|
|
15439
|
-
if (state.phase === "error") return "Newsroom Unavailable";
|
|
15440
|
-
return "Editor Sign-In Required";
|
|
15441
|
-
}
|
|
15442
|
-
|
|
15443
|
-
function formatAccessDetail(state: NewsDeskShellState | null): string {
|
|
15444
|
-
if (!state || state.phase === "checkingAccess") return `${SITE_BRAND.appTitle} is checking the current browser session before loading steering state.`;
|
|
15445
|
-
if (state.phase === "loadingDesk") return `${SITE_BRAND.appTitle} verified the browser session and is loading private Newsroom records.`;
|
|
15446
|
-
if (state.phase === "forbidden") return "This account is signed in, but the Cognito session does not include the editor or admin group.";
|
|
15447
|
-
if (state.phase === "error") return `${SITE_BRAND.appTitle} could not verify this editor session or load the private Newsroom data.`;
|
|
15448
|
-
return "Sign in with an editor or admin account to inspect category, category tree, ontology, and graph steering.";
|
|
15449
|
-
}
|
|
15450
|
-
|
|
15451
|
-
function formatAccessActionDetail(state: NewsDeskShellState | null): string {
|
|
15452
|
-
if (!state || state.phase === "checkingAccess") return "This should only take a moment. If it hangs, reload the page.";
|
|
15453
|
-
if (state.phase === "loadingDesk") return "Private records are loading from GraphQL. Large reference queues may take several seconds.";
|
|
15454
|
-
if (state.phase === "forbidden") return "Ask an admin to add this account to the editor or admin group, then sign out and back in.";
|
|
15455
|
-
if (state.phase === "error") return "Reload the page or check the local development console for the GraphQL error.";
|
|
15456
|
-
return "Use the login button above to authenticate with an editor or admin account.";
|
|
15457
|
-
}
|
|
15458
|
-
|
|
15459
15401
|
function SectionHeader({ title, detail }: { title: string; detail: string }) {
|
|
15460
15402
|
const id = title.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
15461
15403
|
return (
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { ContentActionFailure } from "./content-actions-client";
|
|
2
|
+
import type {
|
|
3
|
+
DeriveMarkusInput,
|
|
4
|
+
DeriveMarkusResult,
|
|
5
|
+
PublishItemResult,
|
|
6
|
+
SaveItemDraftInput,
|
|
7
|
+
SaveItemDraftResult,
|
|
8
|
+
UnpublishItemResult,
|
|
9
|
+
} from "./content-actions-client";
|
|
10
|
+
import {
|
|
11
|
+
createDemoArticleRecords,
|
|
12
|
+
demoContentHash,
|
|
13
|
+
type DemoArticleRecord,
|
|
14
|
+
} from "./newsroom-demo-articles";
|
|
15
|
+
import { sourceLineOffset, type ArticlesBackend, type EditableArticle, type NewsroomArticleRow } from "./newsroom-articles";
|
|
16
|
+
|
|
17
|
+
function titleFromYaml(frontMatterYaml: string | null, fallback: string): string {
|
|
18
|
+
for (const line of (frontMatterYaml ?? "").split("\n")) {
|
|
19
|
+
const match = /^title\s*:\s*(.*)$/.exec(line);
|
|
20
|
+
if (match && match[1].trim()) return match[1].trim().replace(/^["']|["']$/g, "");
|
|
21
|
+
}
|
|
22
|
+
return fallback;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const DEMO_IMAGE_DIRECTIVE = /^::image\{([^}]*)\}$/;
|
|
26
|
+
|
|
27
|
+
function demoAttribute(attributes: string, name: string): string {
|
|
28
|
+
const match = new RegExp(`${name}="([^"]*)"`).exec(attributes);
|
|
29
|
+
return match ? match[1] : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function demoEnvelope(bodyMarkus: string) {
|
|
33
|
+
const images: Record<string, Record<string, unknown>> = {};
|
|
34
|
+
const children = bodyMarkus
|
|
35
|
+
.split(/\n{2,}/)
|
|
36
|
+
.map((block) => block.trim())
|
|
37
|
+
.filter(Boolean)
|
|
38
|
+
.map((block) => {
|
|
39
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(block);
|
|
40
|
+
if (heading) {
|
|
41
|
+
return { type: "heading", level: heading[1].length, inline: [{ type: "text", text: heading[2] }], line: null };
|
|
42
|
+
}
|
|
43
|
+
const image = DEMO_IMAGE_DIRECTIVE.exec(block);
|
|
44
|
+
if (image) {
|
|
45
|
+
const token = `PAPYRUSMARKUP${String(Object.keys(images).length + 1).padStart(5, "0")}END`;
|
|
46
|
+
images[token] = {
|
|
47
|
+
src: demoAttribute(image[1], "src"),
|
|
48
|
+
alt: demoAttribute(image[1], "alt"),
|
|
49
|
+
layout: demoAttribute(image[1], "layout") || null,
|
|
50
|
+
};
|
|
51
|
+
return { type: "paragraph", inline: [{ type: "text", text: token }], line: null };
|
|
52
|
+
}
|
|
53
|
+
return { type: "paragraph", inline: [{ type: "text", text: block }], line: null };
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
markus: { version: "demo", irSchemaVersion: 1 },
|
|
58
|
+
document: { type: "document", schema_version: 1, front_matter: {}, children },
|
|
59
|
+
papyrus: { images, citations: {}, citationLists: {}, entries: {}, bibliography: [] },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createDemoArticlesBackend(): ArticlesBackend {
|
|
64
|
+
const records = new Map<string, DemoArticleRecord>(createDemoArticleRecords().map((record) => [record.id, record]));
|
|
65
|
+
let created = 0;
|
|
66
|
+
|
|
67
|
+
const requireRecord = (id: string): DemoArticleRecord => {
|
|
68
|
+
const record = records.get(id);
|
|
69
|
+
if (!record) throw new ContentActionFailure([{ code: "not-found", message: "That article was not found.", line: null }]);
|
|
70
|
+
return record;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
actions: {
|
|
75
|
+
async deriveMarkus(input: DeriveMarkusInput): Promise<DeriveMarkusResult> {
|
|
76
|
+
const lines = input.bodyMarkus.split("\n");
|
|
77
|
+
const invalidIndex = lines.findIndex((line) => line.includes(":::nope"));
|
|
78
|
+
if (invalidIndex >= 0) {
|
|
79
|
+
throw new ContentActionFailure([
|
|
80
|
+
{ code: "markus-validation", message: 'Unknown directive "nope".', line: invalidIndex + 1 + sourceLineOffset(input.frontMatterYaml) },
|
|
81
|
+
]);
|
|
82
|
+
}
|
|
83
|
+
const envelope = demoEnvelope(input.bodyMarkus);
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
bodyIrBytes: JSON.stringify(envelope).length,
|
|
87
|
+
...(input.includeIr ? { bodyIr: envelope } : {}),
|
|
88
|
+
errors: [],
|
|
89
|
+
};
|
|
90
|
+
},
|
|
91
|
+
async saveItemDraft(input: SaveItemDraftInput): Promise<SaveItemDraftResult> {
|
|
92
|
+
const existing = input.id ? requireRecord(input.id) : null;
|
|
93
|
+
if (existing && input.expectedContentHash !== null && input.expectedContentHash !== existing.contentHash) {
|
|
94
|
+
throw new ContentActionFailure([
|
|
95
|
+
{ code: "conflict", message: "The item changed since it was loaded.", line: null },
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
if (existing && existing.everPublished && existing.slug !== input.slug) {
|
|
99
|
+
throw new ContentActionFailure([
|
|
100
|
+
{ code: "slug-locked", message: "The slug cannot change once the item has been published.", line: null },
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
const id = existing?.id ?? `demo-article-new-${(created += 1)}`;
|
|
104
|
+
const title = titleFromYaml(input.frontMatterYaml, input.slug);
|
|
105
|
+
const frontMatterYaml = input.frontMatterYaml ?? "";
|
|
106
|
+
const record: DemoArticleRecord = {
|
|
107
|
+
id,
|
|
108
|
+
type: input.type,
|
|
109
|
+
slug: input.slug,
|
|
110
|
+
section: input.section,
|
|
111
|
+
title,
|
|
112
|
+
status: existing?.status ?? "draft",
|
|
113
|
+
frontMatterYaml,
|
|
114
|
+
bodyMarkus: input.bodyMarkus,
|
|
115
|
+
contentHash: demoContentHash(frontMatterYaml, input.bodyMarkus),
|
|
116
|
+
publishedContentHash: existing?.publishedContentHash ?? null,
|
|
117
|
+
versionNumber: existing?.versionNumber ?? null,
|
|
118
|
+
everPublished: existing?.everPublished ?? false,
|
|
119
|
+
updatedAt: new Date().toISOString(),
|
|
120
|
+
};
|
|
121
|
+
records.set(id, record);
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
item: {
|
|
125
|
+
id,
|
|
126
|
+
contentHash: record.contentHash,
|
|
127
|
+
status: record.status,
|
|
128
|
+
slug: record.slug,
|
|
129
|
+
versionNumber: record.versionNumber,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
async publishItem(id: string): Promise<PublishItemResult> {
|
|
134
|
+
const record = requireRecord(id);
|
|
135
|
+
const changed = record.status !== "published" || record.publishedContentHash !== record.contentHash;
|
|
136
|
+
const versionNumber = changed ? (record.versionNumber ?? 0) + 1 : record.versionNumber;
|
|
137
|
+
records.set(id, {
|
|
138
|
+
...record,
|
|
139
|
+
status: "published",
|
|
140
|
+
everPublished: true,
|
|
141
|
+
publishedContentHash: record.contentHash,
|
|
142
|
+
versionNumber,
|
|
143
|
+
updatedAt: new Date().toISOString(),
|
|
144
|
+
});
|
|
145
|
+
return { ok: true, changed, publishedId: `published-${id}`, versionNumber };
|
|
146
|
+
},
|
|
147
|
+
async unpublishItem(id: string): Promise<UnpublishItemResult> {
|
|
148
|
+
const record = requireRecord(id);
|
|
149
|
+
const changed = record.status === "published";
|
|
150
|
+
records.set(id, {
|
|
151
|
+
...record,
|
|
152
|
+
status: "draft",
|
|
153
|
+
publishedContentHash: null,
|
|
154
|
+
updatedAt: new Date().toISOString(),
|
|
155
|
+
});
|
|
156
|
+
return { ok: true, changed };
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
async listRows(): Promise<NewsroomArticleRow[]> {
|
|
160
|
+
return [...records.values()]
|
|
161
|
+
.map((record) => ({
|
|
162
|
+
id: record.id,
|
|
163
|
+
type: record.type,
|
|
164
|
+
slug: record.slug,
|
|
165
|
+
section: record.section,
|
|
166
|
+
title: record.title || record.slug,
|
|
167
|
+
status: record.status,
|
|
168
|
+
pending: record.status === "published" && record.publishedContentHash !== record.contentHash,
|
|
169
|
+
updatedAt: record.updatedAt,
|
|
170
|
+
}))
|
|
171
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
|
172
|
+
},
|
|
173
|
+
async loadArticle(id: string): Promise<EditableArticle> {
|
|
174
|
+
const record = requireRecord(id);
|
|
175
|
+
return {
|
|
176
|
+
id: record.id,
|
|
177
|
+
type: record.type,
|
|
178
|
+
section: record.section,
|
|
179
|
+
slug: record.slug,
|
|
180
|
+
frontMatterYaml: record.frontMatterYaml,
|
|
181
|
+
bodyMarkus: record.bodyMarkus,
|
|
182
|
+
contentHash: record.contentHash,
|
|
183
|
+
status: record.status,
|
|
184
|
+
versionNumber: record.versionNumber,
|
|
185
|
+
aliases: [],
|
|
186
|
+
everPublished: record.everPublished,
|
|
187
|
+
};
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|