@anthusai/papyrus 1.2.0 → 1.3.0

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.
@@ -39,7 +39,8 @@ import type {
39
39
  UserDirectoryEntry,
40
40
  } from "../lib/category-repository";
41
41
  import { relationTypeKey } from "../lib/semantic-graph";
42
- import { CONTENT_ITEM_STAGES, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
42
+ import { contentItemStageKeys, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
43
+ import { getContentItemStageDefinitions } from "../lib/site-brand";
43
44
  import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
44
45
  import { configureAmplifyClient } from "./amplify-client-provider";
45
46
  import { isUnauthenticatedError, loadReaderSessionSnapshot, type ReaderAuthSnapshot } from "./reader-auth-state";
@@ -2173,7 +2174,7 @@ export async function loadNewsroomContentItemVersions(): Promise<ContentItemVers
2173
2174
  graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ContentItemVersionRecord>>;
2174
2175
  };
2175
2176
  const field = "listItemsByStageAndUpdatedAt";
2176
- const versionsPerStage = await Promise.all(CONTENT_ITEM_STAGES.map(async (stage) => {
2177
+ const versionsPerStage = await Promise.all(contentItemStageKeys(getContentItemStageDefinitions()).map(async (stage) => {
2177
2178
  const versions: ContentItemVersionRecord[] = [];
2178
2179
  let nextToken: string | null = null;
2179
2180
  do {
@@ -2193,6 +2194,62 @@ export async function loadNewsroomContentItemVersions(): Promise<ContentItemVers
2193
2194
  return versionsPerStage.flat();
2194
2195
  }
2195
2196
 
2197
+ const PUBLISHED_ITEMS_QUERY = `
2198
+ query ListPublishedItems($limit: Int, $nextToken: String) {
2199
+ listPublishedItems(limit: $limit, nextToken: $nextToken) {
2200
+ items { sourceItemId itemLineageId versionNumber type status title headline slug section publishedAt }
2201
+ nextToken
2202
+ }
2203
+ }
2204
+ `;
2205
+
2206
+ type PublishedItemBoardRow = {
2207
+ sourceItemId: string;
2208
+ itemLineageId: string;
2209
+ versionNumber: number;
2210
+ type: string;
2211
+ status: string;
2212
+ title?: string | null;
2213
+ headline?: string | null;
2214
+ slug: string;
2215
+ section?: string | null;
2216
+ publishedAt?: string | null;
2217
+ };
2218
+
2219
+ export async function loadNewsroomPublishedContentItemVersions(): Promise<ContentItemVersionRecord[]> {
2220
+ configureAmplifyClient();
2221
+ const client = generateClient<Schema>() as unknown as {
2222
+ graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<PublishedItemBoardRow>>;
2223
+ };
2224
+ const rows: PublishedItemBoardRow[] = [];
2225
+ let nextToken: string | null = null;
2226
+ do {
2227
+ const response: GraphQLConnectionResponse<PublishedItemBoardRow> = await client.graphql({
2228
+ query: PUBLISHED_ITEMS_QUERY,
2229
+ variables: { limit: NEWSROOM_PAGE_LIMIT, nextToken },
2230
+ authMode: USER_POOL_AUTH_MODE,
2231
+ });
2232
+ assertNoGraphQLErrors(response.errors);
2233
+ const connection = response.data?.listPublishedItems;
2234
+ if (!connection) throw new Error("Missing GraphQL connection payload for listPublishedItems.");
2235
+ rows.push(...((connection.items ?? []).filter(Boolean) as PublishedItemBoardRow[]));
2236
+ nextToken = connection.nextToken ?? null;
2237
+ } while (nextToken);
2238
+ return rows.map(row => ({
2239
+ id: row.sourceItemId,
2240
+ lineageId: row.itemLineageId,
2241
+ versionNumber: row.versionNumber,
2242
+ stage: "published",
2243
+ type: row.type,
2244
+ status: row.status,
2245
+ title: row.title,
2246
+ headline: row.headline,
2247
+ slug: row.slug,
2248
+ section: row.section,
2249
+ updatedAt: row.publishedAt,
2250
+ }));
2251
+ }
2252
+
2196
2253
  export async function loadModelPayloadsForOwner(
2197
2254
  ownerKind: string,
2198
2255
  ownerId: string,
@@ -6,9 +6,10 @@ import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter
6
6
  import "@kanbus/ui/styles/kanban.css";
7
7
  import "./newsroom-newsprint-board.css";
8
8
  import { canReadNewsroomBoard, contentItemBoardType, newsroomBoardConfig, projectNewsroomBoard, type NativeBoardIssue } from "../lib/newsroom-board";
9
+ import type { AssignmentRecord } from "../lib/category-repository";
9
10
  import { getContentItemName, SITE_BRAND } from "../lib/site-brand";
10
11
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
11
- import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions, loadNewsroomReferencePage } from "./news-desk-taxonomy-client";
12
+ import { loadNewsroomAssignmentPage, loadNewsroomContentItemVersions, loadNewsroomPublishedContentItemVersions } from "./news-desk-taxonomy-client";
12
13
  import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
13
14
  import { NewsDeskAccessGate } from "./newsroom-access-gate";
14
15
  import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
@@ -17,6 +18,17 @@ import { readLocalReaderSettings, subscribeReaderSettingsChanges } from "./reade
17
18
 
18
19
  const STATIC_MOTION = { mode: "static" } as const;
19
20
 
21
+ async function loadEveryNewsroomAssignment() {
22
+ const assignments: AssignmentRecord[] = [];
23
+ let nextToken: string | null | undefined;
24
+ do {
25
+ const page = await loadNewsroomAssignmentPage({ limit: 100, nextToken });
26
+ assignments.push(...page.items);
27
+ nextToken = page.nextToken;
28
+ } while (nextToken);
29
+ return assignments;
30
+ }
31
+
20
32
  /** Read-only shared board over authenticated CMS records; existing workspaces own every mutation. */
21
33
  export function NewsroomBoardView() {
22
34
  const session = useOptionalNewsDeskClient();
@@ -30,47 +42,27 @@ export function NewsroomBoardView() {
30
42
  const [selected, setSelected] = useState<string | null>(null);
31
43
  const [loading, setLoading] = useState(false);
32
44
  const [error, setError] = useState<string | null>(null);
33
- const [tokens, setTokens] = useState<{ reference?: string | null; assignment?: string | null }>({});
34
45
  const generation = useRef(0);
35
- const busy = useRef(false);
36
46
  useEffect(() => {
37
47
  setLowMotion(readLocalReaderSettings().motion === "low");
38
48
  return subscribeReaderSettingsChanges(settings => setLowMotion(settings.motion === "low"));
39
49
  }, []);
40
50
  useEffect(() => {
41
51
  const request = ++generation.current;
42
- busy.current = false;
43
- setIssues([]); setSelected(null); setTokens({}); setError(null);
52
+ setIssues([]); setSelected(null); setError(null);
44
53
  if (!allowed) { setLoading(false); return; }
45
- setLoading(true); busy.current = true;
54
+ setLoading(true);
46
55
  Promise.all([
47
- loadNewsroomContentItemVersions(),
48
- loadNewsroomReferencePage({ limit: 50 }),
49
- loadNewsroomAssignmentPage({ limit: 50 }),
50
- ]).then(([contentItemVersions, references, assignments]) => {
56
+ Promise.all([loadNewsroomContentItemVersions(), loadNewsroomPublishedContentItemVersions()]).then(([workingVersions, publishedVersions]) => [...workingVersions, ...publishedVersions]),
57
+ loadEveryNewsroomAssignment(),
58
+ ]).then(([contentItemVersions, assignments]) => {
51
59
  if (generation.current !== request) return;
52
- setIssues(projectNewsroomBoard({ contentItemVersions, references: references.items, assignments: assignments.items }));
53
- setTokens({ reference: references.nextToken, assignment: assignments.nextToken });
60
+ setIssues(projectNewsroomBoard({ contentItemVersions, assignments }));
54
61
  }).catch((reason: unknown) => {
55
62
  if (generation.current === request) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
56
- }).finally(() => { if (generation.current === request) { setLoading(false); busy.current = false; } });
63
+ }).finally(() => { if (generation.current === request) { setLoading(false); } });
57
64
  return () => { generation.current = request + 1; };
58
65
  }, [allowed, revision]);
59
- async function loadMore(kind: "reference" | "assignment") {
60
- if (!allowed || busy.current || !tokens[kind]) return;
61
- const request = generation.current;
62
- busy.current = true; setLoading(true); setError(null);
63
- try {
64
- const added = kind === "reference"
65
- ? await loadNewsroomReferencePage({ limit: 50, nextToken: tokens.reference }).then(page => ({ issues: projectNewsroomBoard({ references: page.items }), token: page.nextToken }))
66
- : await loadNewsroomAssignmentPage({ limit: 50, nextToken: tokens.assignment }).then(page => ({ issues: projectNewsroomBoard({ assignments: page.items }), token: page.nextToken }));
67
- if (generation.current !== request) return;
68
- setIssues(current => [...new Map([...current, ...added.issues].map(issue => [issue.id, issue])).values()]);
69
- setTokens(current => ({ ...current, [kind]: added.token }));
70
- } catch (reason) {
71
- if (generation.current === request) setError(reason instanceof Error ? reason.message : "Could not load more records.");
72
- } finally { if (generation.current === request) { setLoading(false); busy.current = false; } }
73
- }
74
66
  const config = useMemo(() => newsroomBoardConfig(issues), [issues]);
75
67
  const issue = issues.find(item => item.id === selected);
76
68
  if (!allowed) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
@@ -82,7 +74,7 @@ export function NewsroomBoardView() {
82
74
  </div>
83
75
  <p className="organizer-board-note">{`Each ${getContentItemName().singular.toLowerCase()} appears once, in the column for its stage. Open a record to use its existing editor or workflow controls.`}</p>
84
76
  {error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
85
- <p role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded. References and assignments load in pages.`}</p>
77
+ <p role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded.`}</p>
86
78
  <div className="organizer-board-body" data-detail-open={Boolean(issue)}>
87
79
  <IssueCollection issues={issues} config={config} query={{ type: issueTypeFromFilter(filter) }} view={view}
88
80
  motion={lowMotion ? STATIC_MOTION : undefined}
@@ -93,7 +85,6 @@ export function NewsroomBoardView() {
93
85
  <Link href={getNewsroomNavHref(issue.href)}>Open {issue.type}</Link>
94
86
  </IssueDetailPanel>}
95
87
  </div>
96
- <div className="organizer-board-pages">{tokens.reference && <button disabled={loading} type="button" onClick={() => void loadMore("reference")}>Load more references</button>}{tokens.assignment && <button disabled={loading} type="button" onClick={() => void loadMore("assignment")}>Load more assignments</button>}</div>
97
88
  </div>
98
89
  </NewsroomOpsShell>;
99
90
  }
@@ -37,8 +37,8 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
37
37
  .organizer-main:has(.organizer-native-board){display:flex;overflow:hidden}
38
38
  .organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:12px}
39
39
  .organizer-board-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-width:0;flex-wrap:wrap}
40
- .organizer-board-toolbar [role=group],.organizer-board-pages{display:flex;gap:8px;flex-wrap:wrap}
41
- .organizer-board-toolbar button,.organizer-board-pages button{min-height:44px;padding:8px 12px;border:1px solid var(--org-line)}
40
+ .organizer-board-toolbar [role=group]{display:flex;gap:8px;flex-wrap:wrap}
41
+ .organizer-board-toolbar button{min-height:44px;padding:8px 12px;border:1px solid var(--org-line)}
42
42
  .organizer-board-toolbar button[aria-pressed=true]{background:var(--org-ink);color:var(--org-paper)}
43
43
  .organizer-board-note{font-size:11px;line-height:1.5;margin:0;color:var(--org-muted)}
44
44
  .organizer-board-body{position:relative;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-width:0;min-height:0;gap:16px}
@@ -1,36 +1,35 @@
1
1
  /**
2
2
  * One content item, one lifecycle: the stages an Item lineage moves through.
3
- * Mirrors CONTENT_ITEM_STAGES in src/papyrus_content/content_item_lifecycle.py.
3
+ * The default stages mirror CONTENT_ITEM_STAGES in src/papyrus_content/content_item_lifecycle.py;
4
+ * a publication overrides them with SiteBrand.contentItemStages.
4
5
  * See docs/content-item-lifecycle.md.
5
6
  */
6
- export const CONTENT_ITEM_STAGES = [
7
- "idea",
8
- "assignment",
9
- "research",
10
- "report",
11
- "editor_select",
12
- "copywriting",
13
- "illustration",
14
- "typesetting",
15
- "published",
16
- ] as const;
7
+ export type ContentItemStageDefinition = {
8
+ key: string;
9
+ label: string;
10
+ };
17
11
 
18
- export type ContentItemStage = (typeof CONTENT_ITEM_STAGES)[number];
12
+ export const DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS: readonly ContentItemStageDefinition[] = [
13
+ { key: "idea", label: "Idea" },
14
+ { key: "assignment", label: "Assignment" },
15
+ { key: "research", label: "Research" },
16
+ { key: "report", label: "Report" },
17
+ { key: "editor_select", label: "Editor select" },
18
+ { key: "copywriting", label: "Copywriting" },
19
+ { key: "illustration", label: "Illustration" },
20
+ { key: "typesetting", label: "Typesetting" },
21
+ { key: "published", label: "Published" },
22
+ ];
19
23
 
20
- const CONTENT_ITEM_STAGE_LABELS: Record<ContentItemStage, string> = {
21
- idea: "Idea",
22
- assignment: "Assignment",
23
- research: "Research",
24
- report: "Report",
25
- editor_select: "Editor select",
26
- copywriting: "Copywriting",
27
- illustration: "Illustration",
28
- typesetting: "Typesetting",
29
- published: "Published",
30
- };
24
+ export function contentItemStageKeys(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
25
+ return stageDefinitions.map(definition => definition.key);
26
+ }
31
27
 
32
- export function contentItemStageLabel(stage: string): string {
33
- return (CONTENT_ITEM_STAGE_LABELS as Record<string, string>)[stage] ?? stage;
28
+ export function contentItemStageLabel(
29
+ stage: string,
30
+ stageDefinitions: readonly ContentItemStageDefinition[] = DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS,
31
+ ): string {
32
+ return stageDefinitions.find(definition => definition.key === stage)?.label ?? stage;
34
33
  }
35
34
 
36
35
  export type ContentItemVersionRecord = {
@@ -1,14 +1,14 @@
1
1
  import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
2
- import type { AssignmentRecord, ReferenceRecord } from "./category-repository";
2
+ import type { AssignmentRecord } from "./category-repository";
3
3
  import {
4
- CONTENT_ITEM_STAGES,
4
+ contentItemStageKeys,
5
5
  contentItemStageLabel,
6
6
  latestVersionPerContentItem,
7
7
  type ContentItemVersionRecord,
8
8
  } from "./content-item-lifecycle";
9
- import { getContentItemName } from "./site-brand";
9
+ import { getContentItemName, getContentItemStageDefinitions } from "./site-brand";
10
10
 
11
- export type NativeBoardKind = "contentItem" | "reference" | "assignment";
11
+ export type NativeBoardKind = "contentItem" | "assignment";
12
12
  export interface NativeBoardIssue extends KanbanIssue {
13
13
  kind: NativeBoardKind;
14
14
  nativeId: string;
@@ -26,12 +26,11 @@ function boardTypeForKind(kind: NativeBoardKind): string {
26
26
 
27
27
  /**
28
28
  * One card per content item, in the column for its lifecycle stage. Assignments that are work on a content item
29
- * are counted on that item's card rather than shown as separate cards. References and other work keep their own
30
- * identity and native status.
29
+ * are counted on that item's card rather than shown as separate cards. Other assignments keep their own identity and
30
+ * native status. References are not shown on the board.
31
31
  */
32
32
  export function projectNewsroomBoard(records: {
33
33
  contentItemVersions?: readonly ContentItemVersionRecord[];
34
- references?: readonly ReferenceRecord[];
35
34
  assignments?: readonly AssignmentRecord[];
36
35
  }): NativeBoardIssue[] {
37
36
  const assignmentCountByContentItem = new Map<string, number>();
@@ -52,14 +51,6 @@ export function projectNewsroomBoard(records: {
52
51
  updated_at: version.updatedAt ?? undefined, href: `/newsroom/articles/${encodeURIComponent(version.id)}`,
53
52
  };
54
53
  }),
55
- ...(records.references ?? []).filter(row => !row.versionState || row.versionState === "current").map(row => ({
56
- id: `reference:${row.id}`, nativeId: row.id, kind: "reference" as const, type: "reference",
57
- title: row.title || row.externalItemId || row.id,
58
- nativeStatus: row.curationStatus || "pending", status: `reference/${row.curationStatus || "pending"}`,
59
- priority: 2, description: row.curationStatusReason || undefined,
60
- updated_at: row.updatedAt || row.importedAt || undefined,
61
- href: `/newsroom/references/${encodeURIComponent(row.lineageId || row.id)}`,
62
- })),
63
54
  ...(records.assignments ?? []).filter(row => !row.itemLineageId).map(row => ({
64
55
  id: `assignment:${row.id}`, nativeId: row.id, kind: "assignment" as const, type: "assignment",
65
56
  title: row.title, nativeStatus: row.status, status: `assignment/${row.status}`,
@@ -71,18 +62,21 @@ export function projectNewsroomBoard(records: {
71
62
  ];
72
63
  }
73
64
 
74
- export function contentItemColumnOrder(stagesPresentOnBoard: readonly string[]): string[] {
75
- const lastStage = CONTENT_ITEM_STAGES[CONTENT_ITEM_STAGES.length - 1];
76
- const unrecognizedStages = [...new Set(stagesPresentOnBoard)].filter(stage => !(CONTENT_ITEM_STAGES as readonly string[]).includes(stage));
77
- return [...CONTENT_ITEM_STAGES.filter(stage => stage !== lastStage), ...unrecognizedStages, lastStage];
65
+ export function contentItemColumnOrder(
66
+ stagesPresentOnBoard: readonly string[],
67
+ stageKeys: readonly string[] = contentItemStageKeys(getContentItemStageDefinitions()),
68
+ ): string[] {
69
+ const lastStage = stageKeys[stageKeys.length - 1];
70
+ const unrecognizedStages = [...new Set(stagesPresentOnBoard)].filter(stage => !stageKeys.includes(stage));
71
+ return [...stageKeys.filter(stage => stage !== lastStage), ...unrecognizedStages, lastStage];
78
72
  }
79
73
 
80
74
  /** Content items read left to right through the lifecycle with the final stage last; other record kinds follow it. */
81
75
  export function newsroomBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
82
76
  const contentItemType = contentItemBoardType();
83
- const kinds: NativeBoardKind[] = ["contentItem", "reference", "assignment"];
77
+ const kinds: NativeBoardKind[] = ["contentItem", "assignment"];
84
78
  const contentItemStatuses = contentItemColumnOrder(issues.filter(issue => issue.kind === "contentItem").map(issue => issue.nativeStatus))
85
- .map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage), category: "native" }));
79
+ .map(stage => ({ key: `${contentItemType}/${stage}`, name: contentItemStageLabel(stage, getContentItemStageDefinitions()), category: "native" }));
86
80
  const otherStatuses = [...new Set(issues.filter(issue => issue.kind !== "contentItem").map(issue => issue.status))]
87
81
  .map(key => ({ key, name: key.replace("/", " · "), category: "native" }));
88
82
  const statuses = [...contentItemStatuses, ...otherStatuses];
package/lib/site-brand.ts CHANGED
@@ -5,6 +5,7 @@ import type { BlogPageBackgroundProps } from "../components/blog-page-background
5
5
  import type { SiteBrandFont } from "./brand-fonts";
6
6
  import type { PictogramFigureProps } from "../components/pictogram-figure";
7
7
  import papyrusSite from "papyrus-site";
8
+ import { DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS, type ContentItemStageDefinition } from "./content-item-lifecycle";
8
9
  import {
9
10
  DEFAULT_THEME_PACK_TOKENS,
10
11
  type HostingConfig,
@@ -92,6 +93,8 @@ export type SiteBrand = {
92
93
  articleTitleSuffix: string;
93
94
  /** Defaults to Story / Stories, the newsroom term. */
94
95
  contentItemName?: ContentItemName;
96
+ /** The lifecycle stages in board order, last one being publication. Defaults to the standard newsroom lifecycle. */
97
+ contentItemStages?: readonly ContentItemStageDefinition[];
95
98
  placeholderByline: string;
96
99
  defaultPresentation: EditionPresentationFormat;
97
100
  forcedPresentation?: EditionPresentationFormat;
@@ -223,6 +226,10 @@ export function getContentItemName(brand: SiteBrand = SITE_BRAND): ContentItemNa
223
226
  return brand.contentItemName ?? DEFAULT_CONTENT_ITEM_NAME;
224
227
  }
225
228
 
229
+ export function getContentItemStageDefinitions(brand: SiteBrand = SITE_BRAND): readonly ContentItemStageDefinition[] {
230
+ return brand.contentItemStages?.length ? brand.contentItemStages : DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS;
231
+ }
232
+
226
233
  /** Root-route config for a brand, defaulting to `{ kind: "reader" }`. */
227
234
  export function getRootRoute(brand: SiteBrand = SITE_BRAND): RootRouteConfig {
228
235
  return brand.rootRoute ?? { kind: "reader" };
package/package.json CHANGED
@@ -339,7 +339,7 @@
339
339
  "optional": true
340
340
  }
341
341
  },
342
- "version": "1.2.0",
342
+ "version": "1.3.0",
343
343
  "dependencies": {
344
344
  "@aws-amplify/adapter-nextjs": "^1.7.3",
345
345
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.2.0",
2
+ "version": "1.3.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",