@anthusai/papyrus 1.4.0-next.1 → 1.4.0-next.3
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/functions/shared/python-bundle.js +3 -3
- package/amplify/functions/shared/python-bundle.ts +3 -3
- package/app/newsroom/[section]/page.tsx +3 -1
- package/app/newsroom/articles/page.tsx +8 -1
- package/app/newsroom/sections/[sectionId]/page.tsx +3 -0
- package/components/news-desk-taxonomy-client.ts +36 -21
- package/components/newsroom-board-view.tsx +90 -46
- package/components/newsroom-organizer-shell.tsx +1 -1
- package/components/newsroom-organizer.css +8 -7
- package/components/topic-steering-workspace.tsx +1 -1
- package/components/use-paged-content-item-versions.ts +151 -0
- package/lib/blog-feature-solver.ts +1 -109
- package/lib/content-item-lifecycle.ts +32 -5
- package/lib/local-preview-seed.ts +2 -4
- package/lib/newsroom-board.ts +48 -7
- package/lib/newsroom-organizer-nav.ts +3 -2
- package/lib/prospectus-account-client.ts +64 -0
- package/lib/site-stack.ts +1 -1
- package/package.json +6 -1
- package/renderers/pretext/rhythm-article-page.tsx +1 -18
- package/routes.manifest.json +1 -1
|
@@ -126,9 +126,9 @@ function bundleFromPip(outputDir, options) {
|
|
|
126
126
|
}
|
|
127
127
|
export function bundleFromCheckout(outputDir, options) {
|
|
128
128
|
fs.copyFileSync(path.join(packageRoot, "amplify/functions", options.functionDir, "handler.py"), path.join(outputDir, "handler.py"));
|
|
129
|
-
for (const
|
|
130
|
-
fs.mkdirSync(path.join(outputDir,
|
|
131
|
-
fs.cpSync(path.join(packageRoot, "src",
|
|
129
|
+
for (const moduleName of options.modules) {
|
|
130
|
+
fs.mkdirSync(path.join(outputDir, moduleName), { recursive: true });
|
|
131
|
+
fs.cpSync(path.join(packageRoot, "src", moduleName), path.join(outputDir, moduleName), { recursive: true });
|
|
132
132
|
}
|
|
133
133
|
options.checkoutExtras?.(outputDir);
|
|
134
134
|
copySiteFiles(outputDir, options.siteFiles);
|
|
@@ -164,9 +164,9 @@ export function bundleFromCheckout(outputDir: string, options: PythonBundleOptio
|
|
|
164
164
|
path.join(packageRoot, "amplify/functions", options.functionDir, "handler.py"),
|
|
165
165
|
path.join(outputDir, "handler.py"),
|
|
166
166
|
);
|
|
167
|
-
for (const
|
|
168
|
-
fs.mkdirSync(path.join(outputDir,
|
|
169
|
-
fs.cpSync(path.join(packageRoot, "src",
|
|
167
|
+
for (const moduleName of options.modules) {
|
|
168
|
+
fs.mkdirSync(path.join(outputDir, moduleName), { recursive: true });
|
|
169
|
+
fs.cpSync(path.join(packageRoot, "src", moduleName), path.join(outputDir, moduleName), { recursive: true });
|
|
170
170
|
}
|
|
171
171
|
options.checkoutExtras?.(outputDir);
|
|
172
172
|
copySiteFiles(outputDir, options.siteFiles);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { resolveActiveSiteBrand } from "../../../lib/site-brand-tenant";
|
|
2
2
|
import { notFound, redirect } from "next/navigation";
|
|
3
3
|
import { toPublicNewsroomPath } from "../../../lib/newsroom-base-path";
|
|
4
|
+
import { contentItemBoardPathSegment } from "../../../lib/newsroom-board";
|
|
4
5
|
import { NewsroomBoardView } from "../../../components/newsroom-board-view";
|
|
5
6
|
import { NewsDeskPage } from "../../../components/news-desk-page";
|
|
6
7
|
import type { NewsDeskTab } from "../../../components/topic-steering-workspace";
|
|
@@ -43,10 +44,11 @@ const NEWS_DESK_ROUTE_SECTIONS = new Set<NewsDeskTab>([
|
|
|
43
44
|
|
|
44
45
|
export default async function NewsDeskSectionPage({ params, searchParams }: NewsDeskSectionPageProps) {
|
|
45
46
|
const { section } = await params;
|
|
46
|
-
if (section === "work") redirect(toPublicNewsroomPath(
|
|
47
|
+
if (section === "work") redirect(toPublicNewsroomPath(`/newsroom/${contentItemBoardPathSegment()}`));
|
|
47
48
|
if (section === "users") redirect(toPublicNewsroomPath("/newsroom/administration/users"));
|
|
48
49
|
if (section === "doctrine") redirect(toPublicNewsroomPath("/newsroom/administration/policies"));
|
|
49
50
|
if (section === "desks" && resolveActiveSiteBrand().newsroomLayout !== "organizer") redirect(toPublicNewsroomPath("/newsroom/topics"));
|
|
51
|
+
if (section === contentItemBoardPathSegment() && resolveActiveSiteBrand().newsroomLayout === "organizer") return <NewsroomBoardView subject="contentItem" />;
|
|
50
52
|
if (!NEWS_DESK_ROUTE_SECTIONS.has(section as NewsDeskTab)) notFound();
|
|
51
53
|
if (section === "assignments" && resolveActiveSiteBrand().newsroomLayout === "organizer") {
|
|
52
54
|
const resolved = await searchParams;
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
import { contentItemBoardPathSegment } from "../../../lib/newsroom-board";
|
|
3
|
+
import { toPublicNewsroomPath } from "../../../lib/newsroom-base-path";
|
|
1
4
|
import { NewsroomBoardView } from "../../../components/newsroom-board-view";
|
|
2
5
|
import { resolveActiveSiteBrand } from "../../../lib/site-brand-tenant";
|
|
3
6
|
import { NewsroomArticlesView } from "../../../components/newsroom-articles-view";
|
|
@@ -11,6 +14,10 @@ type NewsroomArticlesPageProps = {
|
|
|
11
14
|
export default async function NewsroomArticlesPage({ searchParams }: NewsroomArticlesPageProps) {
|
|
12
15
|
const resolved = await searchParams;
|
|
13
16
|
const demoValue = Array.isArray(resolved?.demo) ? resolved?.demo[0] : resolved?.demo;
|
|
14
|
-
if (resolveActiveSiteBrand().newsroomLayout === "organizer" && demoValue !== "1")
|
|
17
|
+
if (resolveActiveSiteBrand().newsroomLayout === "organizer" && demoValue !== "1") {
|
|
18
|
+
const boardPathSegment = contentItemBoardPathSegment();
|
|
19
|
+
if (boardPathSegment !== "articles") redirect(toPublicNewsroomPath(`/newsroom/${boardPathSegment}`));
|
|
20
|
+
return <NewsroomBoardView subject="contentItem" />;
|
|
21
|
+
}
|
|
15
22
|
return <NewsroomArticlesView demo={demoValue === "1"} />;
|
|
16
23
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { notFound } from "next/navigation";
|
|
2
|
+
import { resolveActiveSiteBrand } from "../../../../lib/site-brand-tenant";
|
|
3
|
+
import { NewsroomBoardView } from "../../../../components/newsroom-board-view";
|
|
2
4
|
import { NewsDeskPage } from "../../../../components/news-desk-page";
|
|
3
5
|
|
|
4
6
|
export const dynamic = "force-dynamic";
|
|
@@ -13,5 +15,6 @@ export default async function NewsroomDeepSectionPage({ params, searchParams }:
|
|
|
13
15
|
const normalizedSectionId = decodeURIComponent(sectionId ?? "").trim();
|
|
14
16
|
if (!normalizedSectionId) notFound();
|
|
15
17
|
|
|
18
|
+
if (resolveActiveSiteBrand().newsroomLayout === "organizer") return <NewsroomBoardView subject="desk" deskId={normalizedSectionId} />;
|
|
16
19
|
return <NewsDeskPage sectionPageId={normalizedSectionId} searchParams={searchParams} />;
|
|
17
20
|
}
|
|
@@ -41,7 +41,7 @@ import type {
|
|
|
41
41
|
UserDirectoryEntry,
|
|
42
42
|
} from "../lib/category-repository";
|
|
43
43
|
import { relationTypeKey } from "../lib/semantic-graph";
|
|
44
|
-
import {
|
|
44
|
+
import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
|
|
45
45
|
import { getContentItemStageDefinitions } from "../lib/site-brand";
|
|
46
46
|
import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
|
|
47
47
|
import { configureAmplifyClient } from "./amplify-client-provider";
|
|
@@ -2172,31 +2172,45 @@ const CONTENT_ITEMS_BY_STAGE_QUERY = `
|
|
|
2172
2172
|
}
|
|
2173
2173
|
`;
|
|
2174
2174
|
|
|
2175
|
-
export
|
|
2176
|
-
|
|
2175
|
+
export const CONTENT_ITEM_COLUMN_PAGE_LIMIT = 25;
|
|
2176
|
+
|
|
2177
|
+
export type ContentItemStagePage = {
|
|
2178
|
+
versions: ContentItemVersionRecord[];
|
|
2179
|
+
nextToken: string | null;
|
|
2180
|
+
};
|
|
2181
|
+
|
|
2182
|
+
/** One newest-updated-first page of the content items in one stage, so a board column can load as it is scrolled. */
|
|
2183
|
+
export async function loadNewsroomContentItemStagePage(
|
|
2184
|
+
stage: string,
|
|
2185
|
+
nextToken: string | null = null,
|
|
2186
|
+
limit: number = CONTENT_ITEM_COLUMN_PAGE_LIMIT,
|
|
2187
|
+
): Promise<ContentItemStagePage> {
|
|
2188
|
+
if (isLocalPreviewEnabled()) {
|
|
2189
|
+
const versionsInStage = createLocalPreviewContentItemVersions().filter(version => version.stage === stage);
|
|
2190
|
+
const offset = nextToken ? Number(nextToken) : 0;
|
|
2191
|
+
const followingOffset = offset + limit;
|
|
2192
|
+
return {
|
|
2193
|
+
versions: versionsInStage.slice(offset, followingOffset),
|
|
2194
|
+
nextToken: followingOffset < versionsInStage.length ? String(followingOffset) : null,
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2177
2197
|
configureAmplifyClient();
|
|
2178
2198
|
const client = generateClient<Schema>() as unknown as {
|
|
2179
2199
|
graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<ContentItemVersionRecord>>;
|
|
2180
2200
|
};
|
|
2181
2201
|
const field = "listItemsByStageAndUpdatedAt";
|
|
2182
|
-
const
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
versions.push(...((connection.items ?? []).filter(Boolean) as ContentItemVersionRecord[]));
|
|
2195
|
-
nextToken = connection.nextToken ?? null;
|
|
2196
|
-
} while (nextToken);
|
|
2197
|
-
return versions;
|
|
2198
|
-
}));
|
|
2199
|
-
return versionsPerStage.flat();
|
|
2202
|
+
const response: GraphQLConnectionResponse<ContentItemVersionRecord> = await client.graphql({
|
|
2203
|
+
query: CONTENT_ITEMS_BY_STAGE_QUERY,
|
|
2204
|
+
variables: { stage, sortDirection: "DESC", limit, nextToken },
|
|
2205
|
+
authMode: USER_POOL_AUTH_MODE,
|
|
2206
|
+
});
|
|
2207
|
+
assertNoGraphQLErrors(response.errors);
|
|
2208
|
+
const connection = response.data?.[field];
|
|
2209
|
+
if (!connection) throw new Error(`Missing GraphQL connection payload for ${field}.`);
|
|
2210
|
+
return {
|
|
2211
|
+
versions: (connection.items ?? []).filter(Boolean) as ContentItemVersionRecord[],
|
|
2212
|
+
nextToken: connection.nextToken ?? null,
|
|
2213
|
+
};
|
|
2200
2214
|
}
|
|
2201
2215
|
|
|
2202
2216
|
const PUBLISHED_ITEMS_QUERY = `
|
|
@@ -2222,6 +2236,7 @@ type PublishedItemBoardRow = {
|
|
|
2222
2236
|
};
|
|
2223
2237
|
|
|
2224
2238
|
export async function loadNewsroomPublishedContentItemVersions(): Promise<ContentItemVersionRecord[]> {
|
|
2239
|
+
if (isLocalPreviewEnabled()) return [];
|
|
2225
2240
|
configureAmplifyClient();
|
|
2226
2241
|
const client = generateClient<Schema>() as unknown as {
|
|
2227
2242
|
graphql: (options: Record<string, unknown>) => Promise<GraphQLConnectionResponse<PublishedItemBoardRow>>;
|
|
@@ -2,12 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import Link from "next/link";
|
|
5
|
-
import { IssueCollection, IssueDetailPanel } from "@kanbus/ui";
|
|
5
|
+
import { IssueCollection, IssueDetailPanel, IssueTypeFilter, issueTypeFromFilter, useBoardColumnState } from "@kanbus/ui";
|
|
6
6
|
import "@kanbus/ui/styles/kanban.css";
|
|
7
7
|
import "./newsroom-newsprint-board.css";
|
|
8
8
|
import { PlusIcon } from "lucide-react";
|
|
9
|
-
import { buttonVariants } from "./ui/button";
|
|
10
|
-
import { cn } from "../lib/utils";
|
|
11
9
|
import { newsroomHref } from "../lib/newsroom-base-path";
|
|
12
10
|
import {
|
|
13
11
|
ASSIGNMENT_BOARD_TYPE,
|
|
@@ -15,14 +13,20 @@ import {
|
|
|
15
13
|
canReadNewsroomBoard,
|
|
16
14
|
contentItemBoardConfig,
|
|
17
15
|
contentItemBoardType,
|
|
16
|
+
deskBoardConfig,
|
|
17
|
+
deskContainsAssignment,
|
|
18
|
+
deskContainsContentItem,
|
|
18
19
|
projectAssignmentBoard,
|
|
19
20
|
projectContentItemBoard,
|
|
20
21
|
type NativeBoardIssue,
|
|
21
22
|
} from "../lib/newsroom-board";
|
|
22
|
-
import type { AssignmentRecord } from "../lib/category-repository";
|
|
23
|
-
import {
|
|
23
|
+
import type { AssignmentRecord, NewsroomSectionRecord } from "../lib/category-repository";
|
|
24
|
+
import { displayNewsroomSectionShortTitle } from "../lib/newsroom-sections";
|
|
25
|
+
import { getContentItemName, getContentItemStageDefinitions, SITE_BRAND } from "../lib/site-brand";
|
|
24
26
|
import { getNewsroomNavHref } from "../lib/newsroom-nav";
|
|
25
|
-
import { loadNewsroomAssignmentPage,
|
|
27
|
+
import { loadNewsroomAssignmentPage, loadNewsroomPublishedContentItemVersions } from "./news-desk-taxonomy-client";
|
|
28
|
+
import { usePagedContentItemVersions } from "./use-paged-content-item-versions";
|
|
29
|
+
import type { ContentItemVersionRecord } from "../lib/content-item-lifecycle";
|
|
26
30
|
import { useOptionalNewsDeskClient } from "./news-desk-client-provider";
|
|
27
31
|
import { NewsDeskAccessGate } from "./newsroom-access-gate";
|
|
28
32
|
import { NewsroomOpsShell, NewsroomOpsStatusBanner } from "./newsroom-ops-shell";
|
|
@@ -31,6 +35,7 @@ import { readLocalReaderSettings, subscribeReaderSettingsChanges } from "./reade
|
|
|
31
35
|
|
|
32
36
|
const STATIC_MOTION = { mode: "static" } as const;
|
|
33
37
|
const BOARD_REFRESH_INTERVAL_MILLISECONDS = 30_000;
|
|
38
|
+
const COLUMN_LOAD_MORE_DISTANCE_PIXELS = 240;
|
|
34
39
|
|
|
35
40
|
async function loadEveryNewsroomAssignment() {
|
|
36
41
|
const assignments: AssignmentRecord[] = [];
|
|
@@ -43,43 +48,59 @@ async function loadEveryNewsroomAssignment() {
|
|
|
43
48
|
return assignments;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
export type NewsroomBoardSubject = "contentItem" | "assignment";
|
|
51
|
+
export type NewsroomBoardSubject = "contentItem" | "assignment" | "desk";
|
|
47
52
|
|
|
48
53
|
/** Read-only board of one record kind over authenticated CMS records; existing workspaces own every mutation. */
|
|
49
|
-
export function NewsroomBoardView({ subject }: { subject: NewsroomBoardSubject }) {
|
|
54
|
+
export function NewsroomBoardView({ subject, deskId }: { subject: NewsroomBoardSubject; deskId?: string }) {
|
|
50
55
|
const session = useOptionalNewsDeskClient();
|
|
51
56
|
const allowed = canReadNewsroomBoard(session?.shell.phase);
|
|
52
57
|
const theme = useResolvedPapyrusTheme();
|
|
53
58
|
const [lowMotion, setLowMotion] = useState(false);
|
|
54
|
-
const [
|
|
59
|
+
const [assignmentBoardIssues, setAssignmentBoardIssues] = useState<NativeBoardIssue[]>([]);
|
|
60
|
+
const [publishedVersions, setPublishedVersions] = useState<ContentItemVersionRecord[]>([]);
|
|
61
|
+
const [assignments, setAssignments] = useState<AssignmentRecord[]>([]);
|
|
55
62
|
const [view, setView] = useState<"board" | "list">("board");
|
|
56
63
|
const [selected, setSelected] = useState<string | null>(null);
|
|
57
|
-
const [
|
|
58
|
-
const [
|
|
64
|
+
const [supportingRecordsLoading, setSupportingRecordsLoading] = useState(false);
|
|
65
|
+
const [supportingRecordsError, setSupportingRecordsError] = useState<string | null>(null);
|
|
59
66
|
const generation = useRef(0);
|
|
67
|
+
const boardBodyRef = useRef<HTMLDivElement | null>(null);
|
|
68
|
+
const [typeFilter, setTypeFilter] = useState<string | null>("all");
|
|
69
|
+
const showsContentItems = subject !== "assignment";
|
|
70
|
+
const contentItemColumnKeyPrefix = `${contentItemBoardType()}/`;
|
|
71
|
+
const boardType = subject === "assignment" ? ASSIGNMENT_BOARD_TYPE : contentItemBoardType();
|
|
72
|
+
const deskAssignmentIssues = useMemo(() => subject === "desk" && deskId
|
|
73
|
+
? projectAssignmentBoard(assignments.filter(assignment => deskContainsAssignment(assignment, deskId))) : [], [assignments, deskId, subject]);
|
|
74
|
+
const columnStateConfig = useMemo(() => subject === "contentItem" ? contentItemBoardConfig([])
|
|
75
|
+
: subject === "desk" ? deskBoardConfig(deskAssignmentIssues)
|
|
76
|
+
: assignmentBoardConfig(assignmentBoardIssues), [assignmentBoardIssues, deskAssignmentIssues, subject]);
|
|
77
|
+
const columnState = useBoardColumnState({ config: columnStateConfig, storageKey: `papyrus.cms.columns.v1.${SITE_BRAND.id}.${subject}` });
|
|
78
|
+
const openedCollapsedUntilOpenedStages = useMemo(() => !showsContentItems ? [] : getContentItemStageDefinitions()
|
|
79
|
+
.filter(definition => definition.collapsedUntilOpened && !columnState.collapsedColumns.has(`${contentItemColumnKeyPrefix}${definition.key}`))
|
|
80
|
+
.map(definition => definition.key), [contentItemColumnKeyPrefix, columnState.collapsedColumns, showsContentItems]);
|
|
81
|
+
const pagedContentItems = usePagedContentItemVersions({ enabled: allowed && showsContentItems, openedCollapsedUntilOpenedStages });
|
|
60
82
|
useEffect(() => {
|
|
61
83
|
setLowMotion(readLocalReaderSettings().motion === "low");
|
|
62
84
|
return subscribeReaderSettingsChanges(settings => setLowMotion(settings.motion === "low"));
|
|
63
85
|
}, []);
|
|
64
86
|
useEffect(() => {
|
|
65
87
|
const request = ++generation.current;
|
|
66
|
-
|
|
67
|
-
if (!allowed) {
|
|
68
|
-
const
|
|
69
|
-
? Promise.all([
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
88
|
+
setAssignmentBoardIssues([]); setPublishedVersions([]); setAssignments([]); setSelected(null); setSupportingRecordsError(null);
|
|
89
|
+
if (!allowed) { setSupportingRecordsLoading(false); return; }
|
|
90
|
+
const loadSupportingRecords = () => subject !== "assignment"
|
|
91
|
+
? Promise.all([loadNewsroomPublishedContentItemVersions(), loadEveryNewsroomAssignment()]).then(([published, everyAssignment]) => {
|
|
92
|
+
if (generation.current === request) { setPublishedVersions(published); setAssignments(everyAssignment); }
|
|
93
|
+
})
|
|
94
|
+
: loadEveryNewsroomAssignment().then(everyAssignment => {
|
|
95
|
+
if (generation.current === request) setAssignmentBoardIssues(projectAssignmentBoard(everyAssignment));
|
|
96
|
+
});
|
|
74
97
|
const refresh = (initial: boolean) => {
|
|
75
|
-
if (initial)
|
|
76
|
-
|
|
77
|
-
if (generation.current
|
|
78
|
-
setIssues(loadedIssues);
|
|
79
|
-
setError(null);
|
|
98
|
+
if (initial) setSupportingRecordsLoading(true);
|
|
99
|
+
loadSupportingRecords().then(() => {
|
|
100
|
+
if (generation.current === request) setSupportingRecordsError(null);
|
|
80
101
|
}).catch((reason: unknown) => {
|
|
81
|
-
if (generation.current === request && initial)
|
|
82
|
-
}).finally(() => { if (generation.current === request && initial)
|
|
102
|
+
if (generation.current === request && initial) setSupportingRecordsError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
|
|
103
|
+
}).finally(() => { if (generation.current === request && initial) setSupportingRecordsLoading(false); });
|
|
83
104
|
};
|
|
84
105
|
refresh(true);
|
|
85
106
|
const refreshWhenVisible = () => { if (document.visibilityState === "visible") refresh(false); };
|
|
@@ -87,31 +108,54 @@ export function NewsroomBoardView({ subject }: { subject: NewsroomBoardSubject }
|
|
|
87
108
|
document.addEventListener("visibilitychange", refreshWhenVisible);
|
|
88
109
|
return () => { generation.current = request + 1; window.clearInterval(interval); document.removeEventListener("visibilitychange", refreshWhenVisible); };
|
|
89
110
|
}, [allowed, subject]);
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
111
|
+
const issues = useMemo(() => {
|
|
112
|
+
if (subject === "assignment") return assignmentBoardIssues;
|
|
113
|
+
if (subject === "contentItem") return projectContentItemBoard({ contentItemVersions: [...pagedContentItems.versions, ...publishedVersions], assignments });
|
|
114
|
+
const versionsOnDesk = [...pagedContentItems.versions, ...publishedVersions].filter(version => deskId && deskContainsContentItem(version, deskId));
|
|
115
|
+
return [...projectContentItemBoard({ contentItemVersions: versionsOnDesk, assignments: assignments.filter(assignment => deskId && deskContainsAssignment(assignment, deskId)) }), ...deskAssignmentIssues];
|
|
116
|
+
}, [assignmentBoardIssues, assignments, deskAssignmentIssues, deskId, pagedContentItems.versions, publishedVersions, subject]);
|
|
117
|
+
const loading = supportingRecordsLoading || (showsContentItems && pagedContentItems.loading);
|
|
118
|
+
const error = supportingRecordsError ?? (showsContentItems ? pagedContentItems.error : null);
|
|
119
|
+
const config = useMemo(() => subject === "contentItem" ? contentItemBoardConfig(issues) : subject === "desk" ? deskBoardConfig(issues) : assignmentBoardConfig(issues), [issues, subject]);
|
|
120
|
+
const columnStageFromElement = (element: Element | null) => {
|
|
121
|
+
const columnKey = element?.closest("[data-column-key]")?.getAttribute("data-column-key");
|
|
122
|
+
return columnKey?.startsWith(contentItemColumnKeyPrefix) ? columnKey.slice(contentItemColumnKeyPrefix.length) : null;
|
|
123
|
+
};
|
|
124
|
+
const loadMoreWhenColumnIsNearItsEnd = (columnScroll: HTMLElement) => {
|
|
125
|
+
const distanceToEnd = columnScroll.scrollHeight - columnScroll.scrollTop - columnScroll.clientHeight;
|
|
126
|
+
const stage = columnStageFromElement(columnScroll);
|
|
127
|
+
if (stage && distanceToEnd < COLUMN_LOAD_MORE_DISTANCE_PIXELS) pagedContentItems.loadMoreForStage(stage);
|
|
128
|
+
};
|
|
129
|
+
const handleBoardScroll = (event: React.UIEvent<HTMLDivElement>) => {
|
|
130
|
+
const target = event.target as HTMLElement;
|
|
131
|
+
if (showsContentItems && target.classList?.contains("kb-column-scroll")) loadMoreWhenColumnIsNearItsEnd(target);
|
|
132
|
+
};
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (!showsContentItems) return;
|
|
135
|
+
boardBodyRef.current?.querySelectorAll<HTMLElement>(".kb-column-scroll").forEach(loadMoreWhenColumnIsNearItsEnd);
|
|
136
|
+
});
|
|
137
|
+
const deskTitle = session?.shell.dashboard?.newsroomSections?.find(section => section.id === deskId)?.title ?? (deskId ? displayNewsroomSectionShortTitle({ id: deskId } as NewsroomSectionRecord) : "Desk");
|
|
138
|
+
const pageTitle = subject === "contentItem" ? getContentItemName().plural : subject === "desk" ? deskTitle : "Assignments";
|
|
93
139
|
const issue = issues.find(item => item.id === selected);
|
|
94
140
|
if (!allowed) return <NewsDeskAccessGate shell={session?.shell ?? null} />;
|
|
95
|
-
return <NewsroomOpsShell activeTab={subject === "contentItem" ? "articles" : "assignments"} appTitle={SITE_BRAND.appTitle} backLabel={SITE_BRAND.backToHomeLabel} pageTitle={pageTitle}
|
|
96
|
-
headerActions={
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
141
|
+
return <NewsroomOpsShell activeTab={subject === "contentItem" ? "articles" : subject === "desk" ? "desks" : "assignments"} appTitle={SITE_BRAND.appTitle} backLabel={SITE_BRAND.backToHomeLabel} pageTitle={pageTitle}
|
|
142
|
+
headerActions={<>
|
|
143
|
+
<span role="status" className="organizer-board-note">{loading ? "Loading records…" : `${issues.length} records loaded.`}</span>
|
|
144
|
+
<div role="group" aria-label="Record presentation" className="organizer-board-view-toggle"><button type="button" className="organizer-header-button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" className="organizer-header-button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button></div>
|
|
145
|
+
{showsContentItems && (
|
|
146
|
+
<Link className="organizer-header-button" data-newsroom-article-new href={getNewsroomNavHref(newsroomHref("articles", "new"))}>
|
|
147
|
+
<PlusIcon size={14} aria-hidden="true" />
|
|
148
|
+
{`New ${getContentItemName().singular.toLowerCase()}`}
|
|
149
|
+
</Link>
|
|
150
|
+
)}
|
|
151
|
+
</>}>
|
|
102
152
|
<div className="organizer-native-board papyrus-newsprint-board" data-color-mode={theme}>
|
|
103
|
-
<div className="organizer-board-toolbar">
|
|
104
|
-
<div role="group" aria-label="Record presentation"><button type="button" aria-pressed={view === "board"} onClick={() => setView("board")}>Board</button><button type="button" aria-pressed={view === "list"} onClick={() => setView("list")}>List</button></div>
|
|
105
|
-
</div>
|
|
106
|
-
<p className="organizer-board-note">{subject === "contentItem"
|
|
107
|
-
? `Each ${getContentItemName().singular.toLowerCase()} appears once, in the column for its stage. Open one to use its existing editor.`
|
|
108
|
-
: "Each assignment appears once, in the column for its status. Open one to use its existing workflow controls."}</p>
|
|
109
153
|
{error && <NewsroomOpsStatusBanner tone="error">{error}</NewsroomOpsStatusBanner>}
|
|
110
|
-
<
|
|
111
|
-
<div className="organizer-board-body" data-detail-open={Boolean(issue)}>
|
|
112
|
-
<IssueCollection issues={issues} config={config} query={{ type: boardType }} view={view}
|
|
154
|
+
{subject === "desk" && <IssueTypeFilter config={config} value={typeFilter} onChange={setTypeFilter} name="Record type" className="organizer-board-type-filter" />}
|
|
155
|
+
<div className="organizer-board-body" data-detail-open={Boolean(issue)} ref={boardBodyRef} onScrollCapture={handleBoardScroll}>
|
|
156
|
+
<IssueCollection issues={issues} config={config} query={{ type: subject === "desk" ? issueTypeFromFilter(typeFilter) : boardType }} view={view}
|
|
113
157
|
motion={lowMotion ? STATIC_MOTION : undefined}
|
|
114
|
-
|
|
158
|
+
collapsedColumns={columnState.collapsedColumns} onToggleCollapse={columnState.toggleColumn} initialScroll="start"
|
|
115
159
|
selectedIssueId={selected} detailOpen={Boolean(issue)} onSelectIssue={item => setSelected(item.id)} ariaLabel={pageTitle}
|
|
116
160
|
emptyMessage={loading ? "Loading records…" : error ? "Records could not be loaded." : "No records in this selection."} />
|
|
117
161
|
{issue && <IssueDetailPanel issue={issue} onClose={() => setSelected(null)} fields={[{ label: "CMS record ID", value: issue.nativeId }, { label: "Status", value: issue.nativeStatus }]}>
|
|
@@ -21,7 +21,7 @@ const ICONS = { overview: LayoutDashboard, articles: ClipboardList, references:
|
|
|
21
21
|
function Navigation({ activePath, demo, admin, surface, onNavigate }: { activePath: string; demo: boolean; admin: boolean; surface: string; onNavigate?: () => void }) {
|
|
22
22
|
const id = useId();
|
|
23
23
|
const all = ORGANIZER_NAV_GROUPS.flatMap(group => group.items);
|
|
24
|
-
const active = all.filter(item => activePath ===
|
|
24
|
+
const active = all.filter(item => [item.path, ...(item.alsoActiveOnPaths ?? [])].some(candidate => activePath === candidate || (candidate !== "/newsroom" && activePath.startsWith(candidate + "/")))).sort((a, b) => b.path.length - a.path.length)[0]?.key;
|
|
25
25
|
return <nav className="organizer-destinations" aria-label={surface === "sidebar" ? "Newsroom" : "All workspaces"}>
|
|
26
26
|
{ORGANIZER_NAV_GROUPS.map((group, index) => <section key={group.title} aria-labelledby={`${id}-${index}`}>
|
|
27
27
|
<h3 id={`${id}-${index}`}>{group.title}</h3>
|
|
@@ -20,7 +20,7 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
20
20
|
.organizer-destinations a[aria-current=page]{border-bottom:2px solid var(--org-ink);color:var(--org-ink)}
|
|
21
21
|
.newsroom-organizer :is(a,button):focus-visible,.organizer-menu :is(a,button):focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
22
22
|
.organizer-workspace{display:flex;flex-direction:column;min-height:0;min-width:0;overflow:hidden;padding:0 16px}
|
|
23
|
-
.organizer-topbar{height:
|
|
23
|
+
.organizer-topbar{height:48px;min-height:48px;display:flex;align-items:center;gap:16px;border-bottom:1px solid var(--org-ink)}
|
|
24
24
|
.organizer-masthead{font:900 28px Newsroom Display,Georgia,serif;white-space:nowrap;max-width:30vw;overflow:hidden;text-overflow:ellipsis}
|
|
25
25
|
.organizer-topbar h1{font:400 12px Newsroom UI,system-ui;flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
|
|
26
26
|
.organizer-toggle{display:flex;align-items:center;justify-content:center;width:36px;min-height:36px;padding:0;border:1px solid var(--org-line);flex-shrink:0}
|
|
@@ -36,12 +36,12 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
36
36
|
.organizer-account-popup [role=menuitem]{min-height:40px;font-size:12px}.organizer-account-popup .reader-auth{display:contents}.organizer-account-popup .reader-auth__error{display:block;white-space:normal;font-size:11px;padding:8px}
|
|
37
37
|
.organizer-bottom-nav{display:none}
|
|
38
38
|
.organizer-main:has(.organizer-native-board){display:flex;overflow:hidden}
|
|
39
|
-
.organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:
|
|
40
|
-
.organizer-board-
|
|
41
|
-
.organizer-
|
|
42
|
-
.organizer-
|
|
43
|
-
.organizer-board-
|
|
44
|
-
.organizer-
|
|
39
|
+
.organizer-native-board{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;gap:8px}
|
|
40
|
+
.organizer-board-view-toggle{display:flex;gap:4px;flex-shrink:0}
|
|
41
|
+
.organizer-header-button{display:inline-flex;align-items:center;gap:6px;min-height:36px;padding:4px 12px;border:1px solid var(--org-line);font:inherit;color:inherit;background:transparent;text-decoration:none;white-space:nowrap;cursor:pointer}
|
|
42
|
+
.organizer-header-button[aria-pressed=true]{background:var(--org-ink);color:var(--org-paper)}
|
|
43
|
+
.organizer-board-note{font-size:11px;line-height:1.5;margin:0;color:var(--org-muted);white-space:nowrap}
|
|
44
|
+
@media(min-width:901px){.newsroom-organizer[data-sidebar-collapsed=false] .organizer-masthead{display:none}}
|
|
45
45
|
.organizer-board-body{position:relative;display:grid;grid-template-columns:minmax(0,1fr);flex:1;min-width:0;min-height:0;gap:16px}
|
|
46
46
|
.organizer-board-body[data-detail-open=true]{grid-template-columns:minmax(0,1fr) minmax(280px,360px)}
|
|
47
47
|
.organizer-board-body>.kb-issue-collection{height:100%;min-height:0;overflow:auto}
|
|
@@ -51,6 +51,7 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
51
51
|
.organizer-menu .organizer-destinations{overflow-y:auto}.organizer-menu .organizer-destinations section{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.organizer-menu .organizer-destinations h3{grid-column:1/-1;font-size:12px;text-transform:none;margin:0 0 4px}
|
|
52
52
|
.organizer-menu .organizer-destinations a{border:1px solid var(--org-line);padding:12px;color:var(--org-ink)}
|
|
53
53
|
@media(max-width:900px){
|
|
54
|
+
.organizer-board-note{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
|
|
54
55
|
.newsroom-organizer,.newsroom-organizer[data-sidebar-collapsed=true]{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr) auto}
|
|
55
56
|
.organizer-sidebar,.organizer-toggle{display:none}.organizer-workspace{padding:0 16px}.organizer-topbar{gap:8px;height:55px;min-height:55px}
|
|
56
57
|
.organizer-masthead{font-size:24px;max-width:36vw}.organizer-topbar h1{font-size:10px}.organizer-account-header{display:flex}.organizer-header-actions{gap:4px;max-width:35%}
|
|
@@ -13297,7 +13297,7 @@ function buildUiAnalysisReindexAssignmentPlan({
|
|
|
13297
13297
|
createdBy: actorLabel,
|
|
13298
13298
|
createdAt: now,
|
|
13299
13299
|
updatedAt: now,
|
|
13300
|
-
newsroomFeedKey: "
|
|
13300
|
+
newsroomFeedKey: "operations",
|
|
13301
13301
|
metadata,
|
|
13302
13302
|
};
|
|
13303
13303
|
const event: AssignmentEventRecord = {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
canonicalContentItemStage,
|
|
6
|
+
legacyStageKeysToLoad,
|
|
7
|
+
type ContentItemVersionRecord,
|
|
8
|
+
} from "../lib/content-item-lifecycle";
|
|
9
|
+
import { getContentItemStageDefinitions } from "../lib/site-brand";
|
|
10
|
+
import { loadNewsroomContentItemStagePage } from "./news-desk-taxonomy-client";
|
|
11
|
+
|
|
12
|
+
const REFRESH_INTERVAL_MILLISECONDS = 30_000;
|
|
13
|
+
|
|
14
|
+
type StageBucket = {
|
|
15
|
+
versions: ContentItemVersionRecord[];
|
|
16
|
+
nextToken: string | null;
|
|
17
|
+
pagesLoaded: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type PagedContentItemVersions = {
|
|
21
|
+
versions: ContentItemVersionRecord[];
|
|
22
|
+
loading: boolean;
|
|
23
|
+
error: string | null;
|
|
24
|
+
stageHasMore: (canonicalStage: string) => boolean;
|
|
25
|
+
loadMoreForStage: (canonicalStage: string) => void;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function mergeFreshFirstPage(existing: StageBucket | undefined, fresh: StageBucket): StageBucket {
|
|
29
|
+
if (!existing || existing.pagesLoaded <= 1) return fresh;
|
|
30
|
+
const freshIds = new Set(fresh.versions.map(version => version.id));
|
|
31
|
+
return {
|
|
32
|
+
versions: [...fresh.versions, ...existing.versions.filter(version => !freshIds.has(version.id))],
|
|
33
|
+
nextToken: existing.nextToken,
|
|
34
|
+
pagesLoaded: existing.pagesLoaded,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Newest-updated content items per stage, one page per stage to start, more pages on demand.
|
|
40
|
+
* The periodic refresh re-fetches only first pages and keeps whatever later pages were already loaded.
|
|
41
|
+
*/
|
|
42
|
+
export function usePagedContentItemVersions(options: {
|
|
43
|
+
enabled: boolean;
|
|
44
|
+
openedCollapsedUntilOpenedStages: readonly string[];
|
|
45
|
+
}): PagedContentItemVersions {
|
|
46
|
+
const { enabled, openedCollapsedUntilOpenedStages } = options;
|
|
47
|
+
const [buckets, setBuckets] = useState<Record<string, StageBucket>>({});
|
|
48
|
+
const [loading, setLoading] = useState(false);
|
|
49
|
+
const [error, setError] = useState<string | null>(null);
|
|
50
|
+
const bucketsRef = useRef(buckets);
|
|
51
|
+
const stageKeysBeingExtendedRef = useRef(new Set<string>());
|
|
52
|
+
const generationRef = useRef(0);
|
|
53
|
+
|
|
54
|
+
const openedStagesSignature = openedCollapsedUntilOpenedStages.join(",");
|
|
55
|
+
const stageKeysToLoad = useMemo(() => {
|
|
56
|
+
const stageDefinitions = getContentItemStageDefinitions();
|
|
57
|
+
return [
|
|
58
|
+
...stageDefinitions
|
|
59
|
+
.filter(definition => !definition.collapsedUntilOpened || openedStagesSignature.split(",").includes(definition.key))
|
|
60
|
+
.map(definition => definition.key),
|
|
61
|
+
...legacyStageKeysToLoad(stageDefinitions),
|
|
62
|
+
];
|
|
63
|
+
}, [openedStagesSignature]);
|
|
64
|
+
const stageKeysToLoadRef = useRef(stageKeysToLoad);
|
|
65
|
+
stageKeysToLoadRef.current = stageKeysToLoad;
|
|
66
|
+
|
|
67
|
+
const storeBuckets = useCallback((update: (previous: Record<string, StageBucket>) => Record<string, StageBucket>) => {
|
|
68
|
+
const next = update(bucketsRef.current);
|
|
69
|
+
bucketsRef.current = next;
|
|
70
|
+
setBuckets(next);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
const refreshFirstPages = useCallback(async (request: number, isInitial: boolean, stageKeys: readonly string[]) => {
|
|
74
|
+
if (isInitial) setLoading(true);
|
|
75
|
+
try {
|
|
76
|
+
const freshBuckets = await Promise.all(stageKeys.map(async stageKey => {
|
|
77
|
+
const page = await loadNewsroomContentItemStagePage(stageKey);
|
|
78
|
+
return [stageKey, { versions: page.versions, nextToken: page.nextToken, pagesLoaded: 1 }] as const;
|
|
79
|
+
}));
|
|
80
|
+
if (generationRef.current !== request) return;
|
|
81
|
+
storeBuckets(previous => {
|
|
82
|
+
const next = { ...previous };
|
|
83
|
+
for (const [stageKey, fresh] of freshBuckets) next[stageKey] = mergeFreshFirstPage(previous[stageKey], fresh);
|
|
84
|
+
return next;
|
|
85
|
+
});
|
|
86
|
+
setError(null);
|
|
87
|
+
} catch (reason: unknown) {
|
|
88
|
+
if (generationRef.current === request && isInitial) setError(reason instanceof Error ? reason.message : "Could not load newsroom records.");
|
|
89
|
+
} finally {
|
|
90
|
+
if (generationRef.current === request && isInitial) setLoading(false);
|
|
91
|
+
}
|
|
92
|
+
}, [storeBuckets]);
|
|
93
|
+
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
const request = ++generationRef.current;
|
|
96
|
+
bucketsRef.current = {};
|
|
97
|
+
setBuckets({});
|
|
98
|
+
setError(null);
|
|
99
|
+
if (!enabled) { setLoading(false); return; }
|
|
100
|
+
void refreshFirstPages(request, true, stageKeysToLoadRef.current);
|
|
101
|
+
const refreshWhenVisible = () => {
|
|
102
|
+
if (document.visibilityState === "visible") void refreshFirstPages(request, false, stageKeysToLoadRef.current);
|
|
103
|
+
};
|
|
104
|
+
const interval = window.setInterval(refreshWhenVisible, REFRESH_INTERVAL_MILLISECONDS);
|
|
105
|
+
document.addEventListener("visibilitychange", refreshWhenVisible);
|
|
106
|
+
return () => {
|
|
107
|
+
generationRef.current = request + 1;
|
|
108
|
+
window.clearInterval(interval);
|
|
109
|
+
document.removeEventListener("visibilitychange", refreshWhenVisible);
|
|
110
|
+
};
|
|
111
|
+
}, [enabled, refreshFirstPages]);
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (!enabled) return;
|
|
115
|
+
const stageKeysNotLoadedYet = stageKeysToLoad.filter(stageKey => !(stageKey in bucketsRef.current));
|
|
116
|
+
if (stageKeysNotLoadedYet.length) void refreshFirstPages(generationRef.current, false, stageKeysNotLoadedYet);
|
|
117
|
+
}, [enabled, refreshFirstPages, stageKeysToLoad]);
|
|
118
|
+
|
|
119
|
+
const stageKeysInCanonicalStage = useCallback((canonicalStage: string) => Object.keys(bucketsRef.current)
|
|
120
|
+
.filter(stageKey => canonicalContentItemStage(stageKey, getContentItemStageDefinitions()) === canonicalStage), []);
|
|
121
|
+
|
|
122
|
+
const loadMoreForStage = useCallback((canonicalStage: string) => {
|
|
123
|
+
const request = generationRef.current;
|
|
124
|
+
for (const stageKey of stageKeysInCanonicalStage(canonicalStage)) {
|
|
125
|
+
const bucket = bucketsRef.current[stageKey];
|
|
126
|
+
if (!bucket?.nextToken || stageKeysBeingExtendedRef.current.has(stageKey)) continue;
|
|
127
|
+
stageKeysBeingExtendedRef.current.add(stageKey);
|
|
128
|
+
loadNewsroomContentItemStagePage(stageKey, bucket.nextToken).then(page => {
|
|
129
|
+
if (generationRef.current !== request) return;
|
|
130
|
+
storeBuckets(previous => {
|
|
131
|
+
const current = previous[stageKey] ?? bucket;
|
|
132
|
+
const knownIds = new Set(current.versions.map(version => version.id));
|
|
133
|
+
return {
|
|
134
|
+
...previous,
|
|
135
|
+
[stageKey]: {
|
|
136
|
+
versions: [...current.versions, ...page.versions.filter(version => !knownIds.has(version.id))],
|
|
137
|
+
nextToken: page.nextToken,
|
|
138
|
+
pagesLoaded: current.pagesLoaded + 1,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}).catch(() => undefined).finally(() => { stageKeysBeingExtendedRef.current.delete(stageKey); });
|
|
143
|
+
}
|
|
144
|
+
}, [stageKeysInCanonicalStage, storeBuckets]);
|
|
145
|
+
|
|
146
|
+
const stageHasMore = useCallback((canonicalStage: string) => stageKeysInCanonicalStage(canonicalStage)
|
|
147
|
+
.some(stageKey => Boolean(bucketsRef.current[stageKey]?.nextToken)), [stageKeysInCanonicalStage]);
|
|
148
|
+
|
|
149
|
+
const versions = useMemo(() => Object.values(buckets).flatMap(bucket => bucket.versions), [buckets]);
|
|
150
|
+
return { versions, loading, error, stageHasMore, loadMoreForStage };
|
|
151
|
+
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
} from "./blog-rhythm";
|
|
13
13
|
import { layoutAllTextLines, layoutTextLines, prepareWithSegments, type TextLine, type TextObstacle } from "./pretext-layout";
|
|
14
14
|
|
|
15
|
-
export type FeaturedLayoutMode = "
|
|
15
|
+
export type FeaturedLayoutMode = "float" | "stacked";
|
|
16
16
|
|
|
17
17
|
export type SolvedFeaturedItem = {
|
|
18
18
|
mode: FeaturedLayoutMode;
|
|
@@ -92,19 +92,6 @@ export function solveFeaturedItem(input: SolveFeaturedItemInput): SolvedFeatured
|
|
|
92
92
|
});
|
|
93
93
|
const aspectRatio = getImageAspectRatio(input.imageAsset);
|
|
94
94
|
|
|
95
|
-
if (mode === "obstacle") {
|
|
96
|
-
return solveObstacleFeaturedItem({
|
|
97
|
-
prepared,
|
|
98
|
-
containerWidth: input.containerWidth,
|
|
99
|
-
viewportWidth: input.viewportWidth,
|
|
100
|
-
rhythm,
|
|
101
|
-
textStyle: input.textStyle,
|
|
102
|
-
aspectRatio,
|
|
103
|
-
gap,
|
|
104
|
-
imageAsset: input.imageAsset,
|
|
105
|
-
});
|
|
106
|
-
}
|
|
107
|
-
|
|
108
95
|
if (mode === "float") {
|
|
109
96
|
return solveFloatFeaturedItem({
|
|
110
97
|
prepared,
|
|
@@ -151,96 +138,6 @@ type PreparedSolveInput = {
|
|
|
151
138
|
imageAsset: ArticleImageAsset;
|
|
152
139
|
};
|
|
153
140
|
|
|
154
|
-
function solveObstacleFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
|
|
155
|
-
const maxImageWidth = getFloatImageWidth(input);
|
|
156
|
-
let best: {
|
|
157
|
-
score: number;
|
|
158
|
-
textLines: TextLine[];
|
|
159
|
-
imageHeight: number;
|
|
160
|
-
imageWidth: number;
|
|
161
|
-
captionHeight: number;
|
|
162
|
-
mediaHeight: number;
|
|
163
|
-
copyWidth: number;
|
|
164
|
-
textFrameHeight: number;
|
|
165
|
-
hasMore: boolean;
|
|
166
|
-
} | null = null;
|
|
167
|
-
|
|
168
|
-
for (const imageWidth of getObstacleImageWidths(maxImageWidth, input.rhythm)) {
|
|
169
|
-
const copyWidth = Math.max(
|
|
170
|
-
getFloatCopyMinimum(input.viewportWidth, input.rhythm),
|
|
171
|
-
input.containerWidth - imageWidth - input.gap,
|
|
172
|
-
);
|
|
173
|
-
const imageHeight = snapImageHeight(imageWidth / input.aspectRatio, input.rhythm, input.imageAsset.layout);
|
|
174
|
-
const captionHeight = getFeatureCaptionHeight(input.imageAsset, imageWidth, input.rhythm);
|
|
175
|
-
const mediaHeight = imageHeight + captionHeight;
|
|
176
|
-
const obstacle: TextObstacle = {
|
|
177
|
-
x: Math.max(0, copyWidth - imageWidth),
|
|
178
|
-
y: 0,
|
|
179
|
-
width: imageWidth,
|
|
180
|
-
height: mediaHeight,
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
let candidateHeight = mediaHeight;
|
|
184
|
-
let result = layoutTextLines({
|
|
185
|
-
prepared: input.prepared,
|
|
186
|
-
cursor: { segmentIndex: 0, graphemeIndex: 0 },
|
|
187
|
-
maxHeight: candidateHeight,
|
|
188
|
-
maxWidth: copyWidth,
|
|
189
|
-
lineHeight: input.textStyle.lineHeight,
|
|
190
|
-
linePaintHeight: input.textStyle.linePaintHeight,
|
|
191
|
-
fontSize: input.textStyle.fontSize,
|
|
192
|
-
fontFamily: input.textStyle.fontFamily,
|
|
193
|
-
obstacles: [obstacle],
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
const textBottom = getMeasuredTextHeight(result.lines);
|
|
197
|
-
if (textBottom > 0 && textBottom < candidateHeight) {
|
|
198
|
-
candidateHeight = reserveRhythmRows(textBottom, input.rhythm);
|
|
199
|
-
obstacle.height = candidateHeight;
|
|
200
|
-
result = layoutTextLines({
|
|
201
|
-
prepared: input.prepared,
|
|
202
|
-
cursor: { segmentIndex: 0, graphemeIndex: 0 },
|
|
203
|
-
maxHeight: candidateHeight,
|
|
204
|
-
maxWidth: copyWidth,
|
|
205
|
-
lineHeight: input.textStyle.lineHeight,
|
|
206
|
-
linePaintHeight: input.textStyle.linePaintHeight,
|
|
207
|
-
fontSize: input.textStyle.fontSize,
|
|
208
|
-
fontFamily: input.textStyle.fontFamily,
|
|
209
|
-
obstacles: [obstacle],
|
|
210
|
-
});
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const whitespace = Math.max(0, candidateHeight - getMeasuredTextHeight(result.lines));
|
|
214
|
-
const score = 50_000 - whitespace * 1.4 - Math.abs(candidateHeight - mediaHeight) * 0.35 - Math.abs(maxImageWidth - imageWidth) * 0.2;
|
|
215
|
-
const candidate = {
|
|
216
|
-
score,
|
|
217
|
-
textLines: result.lines,
|
|
218
|
-
imageHeight: Math.min(imageHeight, candidateHeight),
|
|
219
|
-
imageWidth,
|
|
220
|
-
captionHeight,
|
|
221
|
-
mediaHeight: candidateHeight,
|
|
222
|
-
copyWidth,
|
|
223
|
-
textFrameHeight: reserveRhythmRows(getMeasuredTextHeight(result.lines), input.rhythm),
|
|
224
|
-
hasMore: result.hasMore,
|
|
225
|
-
};
|
|
226
|
-
if (!best || candidate.score > best.score) best = candidate;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const fallbackImageHeight = input.rhythm.rowHeight * 6;
|
|
230
|
-
return {
|
|
231
|
-
mode: "obstacle",
|
|
232
|
-
textLines: best?.textLines ?? [],
|
|
233
|
-
imageHeight: best?.imageHeight ?? fallbackImageHeight,
|
|
234
|
-
imageWidth: best?.imageWidth ?? maxImageWidth,
|
|
235
|
-
captionHeight: best?.captionHeight ?? 0,
|
|
236
|
-
mediaHeight: best?.mediaHeight ?? fallbackImageHeight,
|
|
237
|
-
copyWidth: best?.copyWidth ?? input.containerWidth,
|
|
238
|
-
textFrameHeight: best?.textFrameHeight ?? 0,
|
|
239
|
-
hasMore: best?.hasMore ?? false,
|
|
240
|
-
gap: input.gap,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
|
|
244
141
|
function solveFloatFeaturedItem(input: PreparedSolveInput): SolvedFeaturedItem {
|
|
245
142
|
const geometry = computeFloatGeometry({
|
|
246
143
|
containerWidth: input.containerWidth,
|
|
@@ -417,11 +314,6 @@ function getFloatCopyMinimum(viewportWidth: number, rhythm: VerticalRhythm): num
|
|
|
417
314
|
return rhythmLengthRows(viewportWidth <= 600 ? FLOAT_COPY_MIN_PHONE_ROWS : FLOAT_COPY_MIN_DESKTOP_ROWS, rhythm);
|
|
418
315
|
}
|
|
419
316
|
|
|
420
|
-
function getObstacleImageWidths(maximum: number, rhythm: VerticalRhythm): number[] {
|
|
421
|
-
const candidateRows = [0, 1, 2].map((offset) => Math.max(rhythm.rowHeight * 5, maximum - (offset * rhythm.rowHeight)));
|
|
422
|
-
return Array.from(new Set(candidateRows));
|
|
423
|
-
}
|
|
424
|
-
|
|
425
317
|
function snapImageHeight(
|
|
426
318
|
naturalHeight: number,
|
|
427
319
|
rhythm: VerticalRhythm,
|
|
@@ -7,24 +7,51 @@
|
|
|
7
7
|
export type ContentItemStageDefinition = {
|
|
8
8
|
key: string;
|
|
9
9
|
label: string;
|
|
10
|
+
/** Closed or rarely visited stage: its board column starts collapsed and its cards load only when it is opened. */
|
|
11
|
+
collapsedUntilOpened?: boolean;
|
|
10
12
|
};
|
|
11
13
|
|
|
14
|
+
export const PUBLISHED_STAGE_KEY = "published";
|
|
15
|
+
|
|
16
|
+
/** Stage keys written before the story stages were simplified, read as the stage that replaced them. */
|
|
17
|
+
export const LEGACY_CONTENT_ITEM_STAGE_ALIASES: Readonly<Record<string, string>> = {
|
|
18
|
+
research: "reporting",
|
|
19
|
+
report: "reporting",
|
|
20
|
+
editor_select: "reporting",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Stages for work that has not become a story yet; they appear on the assignments board only. */
|
|
24
|
+
export const ASSIGNMENT_PHASE_STAGE_KEYS: readonly string[] = ["idea", "assignment"];
|
|
25
|
+
|
|
12
26
|
export const DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS: readonly ContentItemStageDefinition[] = [
|
|
13
|
-
{ key: "
|
|
14
|
-
{ key: "assignment", label: "Assignment" },
|
|
15
|
-
{ key: "research", label: "Research" },
|
|
16
|
-
{ key: "report", label: "Report" },
|
|
17
|
-
{ key: "editor_select", label: "Editor select" },
|
|
27
|
+
{ key: "reporting", label: "Reporting" },
|
|
18
28
|
{ key: "copywriting", label: "Copywriting" },
|
|
19
29
|
{ key: "illustration", label: "Illustration" },
|
|
20
30
|
{ key: "typesetting", label: "Typesetting" },
|
|
21
31
|
{ key: "published", label: "Published" },
|
|
32
|
+
{ key: "held", label: "Held" },
|
|
33
|
+
{ key: "spiked", label: "Spiked", collapsedUntilOpened: true },
|
|
22
34
|
];
|
|
23
35
|
|
|
24
36
|
export function contentItemStageKeys(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
|
|
25
37
|
return stageDefinitions.map(definition => definition.key);
|
|
26
38
|
}
|
|
27
39
|
|
|
40
|
+
export function canonicalContentItemStage(
|
|
41
|
+
stage: string,
|
|
42
|
+
stageDefinitions: readonly ContentItemStageDefinition[] = DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS,
|
|
43
|
+
): string {
|
|
44
|
+
const isDefined = stageDefinitions.some(definition => definition.key === stage);
|
|
45
|
+
return isDefined ? stage : LEGACY_CONTENT_ITEM_STAGE_ALIASES[stage] ?? stage;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function legacyStageKeysToLoad(stageDefinitions: readonly ContentItemStageDefinition[]): string[] {
|
|
49
|
+
const definedKeys = contentItemStageKeys(stageDefinitions);
|
|
50
|
+
return Object.entries(LEGACY_CONTENT_ITEM_STAGE_ALIASES)
|
|
51
|
+
.filter(([legacyKey, replacementKey]) => !definedKeys.includes(legacyKey) && definedKeys.includes(replacementKey))
|
|
52
|
+
.map(([legacyKey]) => legacyKey);
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
export function contentItemStageLabel(
|
|
29
56
|
stage: string,
|
|
30
57
|
stageDefinitions: readonly ContentItemStageDefinition[] = DEFAULT_CONTENT_ITEM_STAGE_DEFINITIONS,
|
|
@@ -6,15 +6,13 @@ const SEED_TIMESTAMP_BASE = Date.parse("2026-10-01T12:00:00.000Z");
|
|
|
6
6
|
const ONE_HOUR_IN_MILLISECONDS = 60 * 60 * 1000;
|
|
7
7
|
|
|
8
8
|
const SEED_CONTENT_ITEM_HEADLINES_BY_POSITION: readonly (readonly string[])[] = [
|
|
9
|
-
["Readers ask what the newsroom should chase next", "A tip from the field: harbor dredging schedule"],
|
|
10
|
-
["City council budget vote draws a crowd", "Interview request: regional transit director"],
|
|
11
|
-
["Mapping every permit filed since January", "What the county audit left out"],
|
|
12
9
|
["Three sources confirm the zoning reversal", "Reporting packet: school board redistricting"],
|
|
13
|
-
["Editor shortlist: the week's lead candidates", "Editor shortlist: the weekend long read"],
|
|
14
10
|
["Draft in progress: the last ferry crossing", "Draft in progress: neighborhood bakery profile"],
|
|
15
11
|
["Illustration brief: flooded streets, aerial view", "Illustration brief: the market at dawn"],
|
|
16
12
|
["Layout pass: front page lead and rail", "Layout pass: the Saturday supplement"],
|
|
17
13
|
["Published: harbor safety review", "Published: the lighthouse keepers' archive"],
|
|
14
|
+
["Held for the winter issue: the lighthouse keepers' archive", "Evergreen: how the harbor dredging works"],
|
|
15
|
+
["Spiked: the unconfirmed council rumor", "Spiked: the recycled press release"],
|
|
18
16
|
];
|
|
19
17
|
|
|
20
18
|
function isoTimestampHoursBeforeBase(hours: number): string {
|
package/lib/newsroom-board.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { KanbanConfig, KanbanIssue } from "@kanbus/ui";
|
|
2
2
|
import type { AssignmentRecord } from "./category-repository";
|
|
3
3
|
import {
|
|
4
|
+
ASSIGNMENT_PHASE_STAGE_KEYS,
|
|
5
|
+
PUBLISHED_STAGE_KEY,
|
|
6
|
+
canonicalContentItemStage,
|
|
4
7
|
contentItemStageKeys,
|
|
5
8
|
contentItemStageLabel,
|
|
6
9
|
latestVersionPerContentItem,
|
|
@@ -17,6 +20,10 @@ export interface NativeBoardIssue extends KanbanIssue {
|
|
|
17
20
|
href: string;
|
|
18
21
|
}
|
|
19
22
|
|
|
23
|
+
export function contentItemBoardPathSegment(): string {
|
|
24
|
+
return getContentItemName().plural.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "articles";
|
|
25
|
+
}
|
|
26
|
+
|
|
20
27
|
export function contentItemBoardType(): string {
|
|
21
28
|
return getContentItemName().singular.toLowerCase();
|
|
22
29
|
}
|
|
@@ -38,8 +45,12 @@ export function projectContentItemBoard(records: {
|
|
|
38
45
|
}
|
|
39
46
|
}
|
|
40
47
|
const contentItemType = contentItemBoardType();
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
const stageDefinitions = getContentItemStageDefinitions();
|
|
49
|
+
const stageKeys = contentItemStageKeys(stageDefinitions);
|
|
50
|
+
const contentItemVersionsOnBoard = latestVersionPerContentItem(records.contentItemVersions)
|
|
51
|
+
.map(version => ({ version, stage: canonicalContentItemStage(version.stage ?? "idea", stageDefinitions) }))
|
|
52
|
+
.filter(({ stage }) => stageKeys.includes(stage) || !ASSIGNMENT_PHASE_STAGE_KEYS.includes(stage));
|
|
53
|
+
return contentItemVersionsOnBoard.map(({ version, stage }) => {
|
|
43
54
|
const workCount = assignmentCountByContentItem.get(version.lineageId) ?? 0;
|
|
44
55
|
return {
|
|
45
56
|
id: `contentItem:${version.lineageId}`, nativeId: version.lineageId, kind: "contentItem" as const, type: contentItemType,
|
|
@@ -66,9 +77,10 @@ export function contentItemColumnOrder(
|
|
|
66
77
|
stagesPresentOnBoard: readonly string[],
|
|
67
78
|
stageKeys: readonly string[] = contentItemStageKeys(getContentItemStageDefinitions()),
|
|
68
79
|
): string[] {
|
|
69
|
-
const lastStage = stageKeys[stageKeys.length - 1];
|
|
70
80
|
const unrecognizedStages = [...new Set(stagesPresentOnBoard)].filter(stage => !stageKeys.includes(stage));
|
|
71
|
-
|
|
81
|
+
const stageBeforeWhichUnrecognizedStagesSit = stageKeys.includes(PUBLISHED_STAGE_KEY) ? PUBLISHED_STAGE_KEY : stageKeys[stageKeys.length - 1];
|
|
82
|
+
const insertionIndex = stageKeys.indexOf(stageBeforeWhichUnrecognizedStagesSit);
|
|
83
|
+
return [...stageKeys.slice(0, insertionIndex), ...unrecognizedStages, ...stageKeys.slice(insertionIndex)];
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[]): string[] {
|
|
@@ -77,7 +89,7 @@ export function assignmentColumnOrder(statusesPresentOnBoard: readonly string[])
|
|
|
77
89
|
return [...ASSIGNMENT_STATUSES_BEFORE_OTHERS, ...otherStatuses, ...ASSIGNMENT_STATUSES_AFTER_OTHERS];
|
|
78
90
|
}
|
|
79
91
|
|
|
80
|
-
function boardConfigForType(type: string, columns: readonly { key: string; name: string }[]): KanbanConfig {
|
|
92
|
+
function boardConfigForType(type: string, columns: readonly { key: string; name: string; collapsed?: boolean }[]): KanbanConfig {
|
|
81
93
|
const statuses = columns.map(column => ({ ...column, category: "native" }));
|
|
82
94
|
return {
|
|
83
95
|
hierarchy: [], types: [type], statuses,
|
|
@@ -86,11 +98,15 @@ function boardConfigForType(type: string, columns: readonly { key: string; name:
|
|
|
86
98
|
};
|
|
87
99
|
}
|
|
88
100
|
|
|
89
|
-
/** Content items read left to right through the lifecycle
|
|
101
|
+
/** Content items read left to right through the lifecycle; stages marked collapsed-until-opened start collapsed. */
|
|
90
102
|
export function contentItemBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
|
|
91
103
|
const contentItemType = contentItemBoardType();
|
|
92
104
|
return boardConfigForType(contentItemType, contentItemColumnOrder(issues.map(issue => issue.nativeStatus))
|
|
93
|
-
.map(stage => ({
|
|
105
|
+
.map(stage => ({
|
|
106
|
+
key: `${contentItemType}/${stage}`,
|
|
107
|
+
name: contentItemStageLabel(stage, getContentItemStageDefinitions()),
|
|
108
|
+
collapsed: getContentItemStageDefinitions().some(definition => definition.key === stage && definition.collapsedUntilOpened),
|
|
109
|
+
})));
|
|
94
110
|
}
|
|
95
111
|
|
|
96
112
|
export function assignmentBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
|
|
@@ -98,6 +114,31 @@ export function assignmentBoardConfig(issues: readonly NativeBoardIssue[]): Kanb
|
|
|
98
114
|
.map(status => ({ key: `${ASSIGNMENT_BOARD_TYPE}/${status}`, name: assignmentStatusLabel(status) })));
|
|
99
115
|
}
|
|
100
116
|
|
|
117
|
+
function matchesDesk(candidate: string | null | undefined, deskId: string): boolean {
|
|
118
|
+
return Boolean(candidate) && candidate!.trim().toLowerCase() === deskId.trim().toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function deskContainsContentItem(version: ContentItemVersionRecord, deskId: string): boolean {
|
|
122
|
+
return matchesDesk(version.section, deskId);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function deskContainsAssignment(assignment: AssignmentRecord, deskId: string): boolean {
|
|
126
|
+
return matchesDesk(assignment.sectionId, deskId) || matchesDesk(assignment.sectionKey, deskId);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A desk board shows stories and assignments side by side; each keeps its own columns and the type filter narrows to one. */
|
|
130
|
+
export function deskBoardConfig(issues: readonly NativeBoardIssue[]): KanbanConfig {
|
|
131
|
+
const storiesConfig = contentItemBoardConfig(issues.filter(issue => issue.kind === "contentItem"));
|
|
132
|
+
const assignmentsConfig = assignmentBoardConfig(issues.filter(issue => issue.kind === "assignment"));
|
|
133
|
+
return {
|
|
134
|
+
...storiesConfig,
|
|
135
|
+
types: [...(storiesConfig.types ?? []), ...(assignmentsConfig.types ?? [])],
|
|
136
|
+
statuses: [...storiesConfig.statuses, ...assignmentsConfig.statuses],
|
|
137
|
+
categories: storiesConfig.categories,
|
|
138
|
+
workflows: { ...storiesConfig.workflows, ...assignmentsConfig.workflows },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
101
142
|
export function canReadNewsroomBoard(phase: string | undefined): boolean {
|
|
102
143
|
return phase === "ready";
|
|
103
144
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import { contentItemBoardPathSegment } from "./newsroom-board";
|
|
1
2
|
import { getContentItemName } from "./site-brand";
|
|
2
3
|
|
|
3
4
|
const contentItemPluralLowercase = getContentItemName().plural.toLowerCase();
|
|
4
5
|
export type OrganizerDestination = {
|
|
5
6
|
key: string; label: string; description: string; path: string;
|
|
6
|
-
access?: "admin"; primary?: boolean; unavailable?: string;
|
|
7
|
+
access?: "admin"; primary?: boolean; unavailable?: string; alsoActiveOnPaths?: readonly string[];
|
|
7
8
|
};
|
|
8
9
|
export const ORGANIZER_NAV_GROUPS: readonly { title: string; items: readonly OrganizerDestination[] }[] = [
|
|
9
10
|
{ title: "Editorial work", items: [
|
|
10
11
|
{ key: "overview", label: "Overview", description: "See the desk and choose where to begin.", path: "/newsroom", primary: true },
|
|
11
|
-
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: "/newsroom/articles", primary: true },
|
|
12
|
+
{ key: "articles", label: getContentItemName().plural, description: `Follow ${contentItemPluralLowercase} across their stages and open one to write or revise it.`, path: `/newsroom/${contentItemBoardPathSegment()}`, alsoActiveOnPaths: ["/newsroom/articles"], primary: true },
|
|
12
13
|
{ key: "assignments", label: "Assignments", description: "Follow research and reporting work across its statuses and claim it.", path: "/newsroom/assignments" },
|
|
13
14
|
{ key: "messages", label: "Messages", description: "Read and reply to newsroom conversations.", path: "/newsroom/messages", primary: true },
|
|
14
15
|
{ key: "insights", label: "Insights", description: "Discuss findings and research threads.", path: "/newsroom/insights" },
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface ProspectusPublishingAccountChoice {
|
|
2
|
+
accountId: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
role: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export type ProspectusAccountState =
|
|
8
|
+
| { status: "unavailable" }
|
|
9
|
+
| { status: "signedOut"; signInUrl: string }
|
|
10
|
+
| {
|
|
11
|
+
status: "signedIn";
|
|
12
|
+
publishingAccounts: ProspectusPublishingAccountChoice[];
|
|
13
|
+
selectedAccountId: string | null;
|
|
14
|
+
cyclotronLabAccess: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type FetchImplementation = (input: string, init?: RequestInit) => Promise<Response>;
|
|
18
|
+
|
|
19
|
+
const SESSION_PATH = "/api/auth/prospectus/session";
|
|
20
|
+
const SELECTION_PATH = "/api/auth/prospectus/selection";
|
|
21
|
+
const SIGN_IN_PATH = "/api/auth/prospectus/sign-in";
|
|
22
|
+
|
|
23
|
+
export function prospectusSignInUrl(returnPath: string): string {
|
|
24
|
+
return `${SIGN_IN_PATH}?returnTo=${encodeURIComponent(returnPath)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function loadProspectusAccountState(
|
|
28
|
+
returnPath: string,
|
|
29
|
+
fetchImplementation: FetchImplementation = fetch,
|
|
30
|
+
): Promise<ProspectusAccountState> {
|
|
31
|
+
let response: Response;
|
|
32
|
+
try {
|
|
33
|
+
response = await fetchImplementation(SESSION_PATH, { credentials: "same-origin", cache: "no-store" });
|
|
34
|
+
} catch {
|
|
35
|
+
return { status: "unavailable" };
|
|
36
|
+
}
|
|
37
|
+
if (!response.ok) return { status: "unavailable" };
|
|
38
|
+
const body = await response.json().catch(() => null) as
|
|
39
|
+
| { signedIn?: boolean; publishingAccounts?: ProspectusPublishingAccountChoice[]; selectedAccountId?: string | null; cyclotronLabAccess?: boolean }
|
|
40
|
+
| null;
|
|
41
|
+
if (!body) return { status: "unavailable" };
|
|
42
|
+
if (!body.signedIn) return { status: "signedOut", signInUrl: prospectusSignInUrl(returnPath) };
|
|
43
|
+
return {
|
|
44
|
+
status: "signedIn",
|
|
45
|
+
publishingAccounts: Array.isArray(body.publishingAccounts) ? body.publishingAccounts : [],
|
|
46
|
+
selectedAccountId: body.selectedAccountId ?? null,
|
|
47
|
+
cyclotronLabAccess: body.cyclotronLabAccess === true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function selectProspectusAccount(
|
|
52
|
+
accountId: string,
|
|
53
|
+
fetchImplementation: FetchImplementation = fetch,
|
|
54
|
+
): Promise<{ selectedAccountId: string; role: string } | { error: string }> {
|
|
55
|
+
const response = await fetchImplementation(SELECTION_PATH, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
credentials: "same-origin",
|
|
58
|
+
headers: { "content-type": "application/json" },
|
|
59
|
+
body: JSON.stringify({ accountId }),
|
|
60
|
+
});
|
|
61
|
+
const body = await response.json().catch(() => null) as { selectedAccountId?: string; role?: string; message?: string } | null;
|
|
62
|
+
if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "That account could not be selected." };
|
|
63
|
+
return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
|
|
64
|
+
}
|
package/lib/site-stack.ts
CHANGED
|
@@ -149,7 +149,7 @@ export const PILOBOL_US_THEME_PACK_TOKENS: ThemePackTokens = {
|
|
|
149
149
|
};
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
* Anthus palette, read off the live Gatsby site at `AnthusAI/Anth.us`
|
|
152
|
+
* Anthus palette, read off the live Gatsby site at `AnthusAI/Anth.us-legacy-Gatsby`
|
|
153
153
|
* (`src/styles/variables.scss` `:root`). Colors only — Shadcn owns typography.
|
|
154
154
|
* Mapping from that file's names to the pack's slots:
|
|
155
155
|
*
|
package/package.json
CHANGED
|
@@ -135,6 +135,7 @@
|
|
|
135
135
|
"./components/use-blog-header-obstacles": "./components/use-blog-header-obstacles.ts",
|
|
136
136
|
"./components/use-brand-fonts-ready": "./components/use-brand-fonts-ready.ts",
|
|
137
137
|
"./components/use-near-viewport": "./components/use-near-viewport.ts",
|
|
138
|
+
"./components/use-paged-content-item-versions": "./components/use-paged-content-item-versions.ts",
|
|
138
139
|
"./components/use-resolved-papyrus-theme": "./components/use-resolved-papyrus-theme.ts",
|
|
139
140
|
"./components/use-rhythm-overlay": "./components/use-rhythm-overlay.ts",
|
|
140
141
|
"./lib/amplify-outputs-guard": "./lib/amplify-outputs-guard.ts",
|
|
@@ -219,6 +220,7 @@
|
|
|
219
220
|
"./lib/presentation-footer": "./lib/presentation-footer.ts",
|
|
220
221
|
"./lib/pretext-layout": "./lib/pretext-layout.ts",
|
|
221
222
|
"./lib/priming-proof-report": "./lib/priming-proof-report.ts",
|
|
223
|
+
"./lib/prospectus-account-client": "./lib/prospectus-account-client.ts",
|
|
222
224
|
"./lib/prospectus-publication-access": "./lib/prospectus-publication-access.ts",
|
|
223
225
|
"./lib/prospectus-session": "./lib/prospectus-session.ts",
|
|
224
226
|
"./lib/prospectus-sign-in-routes": "./lib/prospectus-sign-in-routes.ts",
|
|
@@ -350,7 +352,7 @@
|
|
|
350
352
|
"optional": true
|
|
351
353
|
}
|
|
352
354
|
},
|
|
353
|
-
"version": "1.4.0-next.
|
|
355
|
+
"version": "1.4.0-next.3",
|
|
354
356
|
"dependencies": {
|
|
355
357
|
"@aws-amplify/adapter-nextjs": "^1.7.3",
|
|
356
358
|
"@aws-crypto/sha256-js": "5.2.0",
|
|
@@ -376,7 +378,9 @@
|
|
|
376
378
|
"clsx": "^2.1.1",
|
|
377
379
|
"cmdk": "^1.1.1",
|
|
378
380
|
"cyclotron": "https://github.com/AnthusAI/Cyclotron/releases/download/ui-v0.2.2/cyclotron-0.2.2.tgz",
|
|
381
|
+
"d3": "^7.9.0",
|
|
379
382
|
"esbuild": "^0.28.2",
|
|
383
|
+
"framer-motion": "^14.3.0",
|
|
380
384
|
"gray-matter": "^4.0.3",
|
|
381
385
|
"gsap": "^3.15.0",
|
|
382
386
|
"lucide-react": "^1.16.0",
|
|
@@ -385,6 +389,7 @@
|
|
|
385
389
|
"shadcn": "^4.8.0",
|
|
386
390
|
"tailwind-merge": "^3.6.0",
|
|
387
391
|
"tailwindcss": "^4.3.0",
|
|
392
|
+
"three": "^0.186.1",
|
|
388
393
|
"tsx": "^4.23.15",
|
|
389
394
|
"tw-animate-css": "^1.4.0",
|
|
390
395
|
"use-stick-to-bottom": "^1.1.4",
|
|
@@ -13,6 +13,7 @@ import { PresentationFooter } from "../../components/presentation-footer";
|
|
|
13
13
|
import { PresentationHeader } from "../../components/presentation-header";
|
|
14
14
|
import { useBlogHeaderObstacles } from "../../components/use-blog-header-obstacles";
|
|
15
15
|
import { useRhythmOverlay } from "../../components/use-rhythm-overlay";
|
|
16
|
+
import { useMeasuredWidth } from "./presentation-shared";
|
|
16
17
|
import { BLOG_RHYTHM, getFeaturedLayoutStyle, getRhythmShellStyle } from "./rhythm-shell-style";
|
|
17
18
|
|
|
18
19
|
const BlogPageBackground = SITE_BRAND.components?.BlogPageBackground ?? GenericBlogPageBackground;
|
|
@@ -135,24 +136,6 @@ export function RhythmArticlePage({
|
|
|
135
136
|
);
|
|
136
137
|
}
|
|
137
138
|
|
|
138
|
-
function useMeasuredWidth(ref: RefObject<HTMLElement | null>): number {
|
|
139
|
-
const [width, setWidth] = useState(0);
|
|
140
|
-
useEffect(() => {
|
|
141
|
-
const node = ref.current;
|
|
142
|
-
if (!node) return;
|
|
143
|
-
const update = () => setWidth(Math.max(1, Math.floor(node.getBoundingClientRect().width)));
|
|
144
|
-
update();
|
|
145
|
-
const observer = new ResizeObserver(update);
|
|
146
|
-
observer.observe(node);
|
|
147
|
-
window.addEventListener("resize", update);
|
|
148
|
-
return () => {
|
|
149
|
-
observer.disconnect();
|
|
150
|
-
window.removeEventListener("resize", update);
|
|
151
|
-
};
|
|
152
|
-
}, [ref]);
|
|
153
|
-
return width;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
139
|
function useViewportWidth(): number {
|
|
157
140
|
const [width, setWidth] = useState(0);
|
|
158
141
|
useEffect(() => {
|
package/routes.manifest.json
CHANGED