@anthusai/papyrus 1.13.0 → 1.15.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.
@@ -0,0 +1,72 @@
1
+ import type { ReferenceRecord, SemanticRelationRecord } from "./category-repository";
2
+ import { SHADOW_AGREEMENT_SCHEMA } from "./cyclotron-suggestions";
3
+ import { SECTION_FIT_CLASSIFIER_PREFIX, SECTION_SELECTOR_CLASSIFIER_PREFIX, parseSiteSectionAgreement, type SiteSectionAgreement } from "./site-section-suggestions";
4
+
5
+ const PREVIEW_SITE_CYCLOTRON_ID = "preview-site-cyclotron";
6
+
7
+ /**
8
+ * Local preview only: a suggested Section on the first pending references and
9
+ * a measured agreement for each Section, so the References screen can be
10
+ * reviewed without a sweep.
11
+ */
12
+ export function createLocalPreviewSectionFixtures(
13
+ references: ReferenceRecord[],
14
+ sectionIds: string[],
15
+ ): { relations: SemanticRelationRecord[]; agreement: SiteSectionAgreement | null } {
16
+ if (sectionIds.length < 2) return { relations: [], agreement: null };
17
+ const relations: SemanticRelationRecord[] = references.slice(0, 6).map((reference, index) => {
18
+ const lineageId = reference.lineageId ?? reference.id;
19
+ const top = sectionIds[index % sectionIds.length];
20
+ const runnerUp = sectionIds[(index + 1) % sectionIds.length];
21
+ const confidence = 0.88 - index * 0.07;
22
+ const probabilities: Record<string, number> = { [top]: confidence, [runnerUp]: (1 - confidence) * 0.7 };
23
+ const rest = sectionIds.filter((candidate) => candidate !== top && candidate !== runnerUp);
24
+ rest.forEach((candidate) => { probabilities[candidate] = (1 - confidence) * 0.3 / rest.length; });
25
+ return {
26
+ id: `preview-section-relation-${index}`,
27
+ relationState: "current",
28
+ predicate: "site_decision_is",
29
+ subjectKind: "reference",
30
+ subjectId: reference.id,
31
+ subjectLineageId: lineageId,
32
+ objectKind: "semanticNode",
33
+ objectId: `semantic-node-site-${top}-v1`,
34
+ objectLineageId: `semantic-node-site-${top}`,
35
+ subjectStateKey: `reference#${lineageId}#current`,
36
+ objectStateKey: `semanticNode#semantic-node-site-${top}#current`,
37
+ objectSubjectStateKey: `semanticNode#semantic-node-site-${top}#current#reference`,
38
+ predicateObjectStateKey: `site_decision_is#semanticNode#semantic-node-site-${top}#current`,
39
+ subjectVersionKey: `reference#${reference.id}`,
40
+ objectVersionKey: `semanticNode#semantic-node-site-${top}-v1`,
41
+ confidence,
42
+ classifierId: `${PREVIEW_SITE_CYCLOTRON_ID}/${SECTION_SELECTOR_CLASSIFIER_PREFIX}preview`,
43
+ modelVersion: "2",
44
+ reviewRecommended: false,
45
+ metadata: JSON.stringify({
46
+ decisionId: `preview-site-decision-${index}`,
47
+ cyclotronId: PREVIEW_SITE_CYCLOTRON_ID,
48
+ classifier: `${SECTION_SELECTOR_CLASSIFIER_PREFIX}preview`,
49
+ label: top,
50
+ probabilities,
51
+ version: 2,
52
+ decidedAt: new Date(Date.UTC(2026, 9, 18, 6 - index)).toISOString(),
53
+ }),
54
+ } as SemanticRelationRecord;
55
+ });
56
+ const classifiers: Record<string, unknown> = { [`${SECTION_SELECTOR_CLASSIFIER_PREFIX}preview`]: { comparisons: 14, correct: 10, agreement: 10 / 14 } };
57
+ sectionIds.forEach((sectionId, index) => {
58
+ classifiers[`${SECTION_FIT_CLASSIFIER_PREFIX}${sectionId}`] = index === sectionIds.length - 1
59
+ ? { comparisons: 4, agreement: 0.75, precision: 0.5, recall: 1 }
60
+ : { comparisons: 12 + index, agreement: 0.9 - index * 0.08, precision: 0.85 - index * 0.06, recall: 0.8 };
61
+ });
62
+ return {
63
+ relations,
64
+ agreement: parseSiteSectionAgreement({
65
+ schema: SHADOW_AGREEMENT_SCHEMA,
66
+ computedAt: "2026-10-18T09:00:00.000Z",
67
+ comparisons: 14,
68
+ selectorAccuracy: 10 / 14,
69
+ classifiers,
70
+ }),
71
+ };
72
+ }
@@ -18,6 +18,7 @@ type FetchImplementation = (input: string, init?: RequestInit) => Promise<Respon
18
18
 
19
19
  const SESSION_PATH = "/api/auth/prospectus/session";
20
20
  const SELECTION_PATH = "/api/auth/prospectus/selection";
21
+ const ACCOUNTS_PATH = "/api/auth/prospectus/accounts";
21
22
  const SIGN_IN_PATH = "/api/auth/prospectus/sign-in";
22
23
 
23
24
  export function prospectusSignInUrl(returnPath: string): string {
@@ -62,3 +63,28 @@ export async function selectProspectusAccount(
62
63
  if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "That account could not be selected." };
63
64
  return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
64
65
  }
66
+
67
+ /**
68
+ * Create a publishing account for the signed-in person and select it. Each call carries a fresh idempotency key,
69
+ * so a retried request after a dropped response cannot create a second account.
70
+ */
71
+ export async function createProspectusAccount(
72
+ name: string,
73
+ fetchImplementation: FetchImplementation = fetch,
74
+ idempotencyKey: string = crypto.randomUUID(),
75
+ ): Promise<{ selectedAccountId: string; role: string } | { error: string }> {
76
+ let response: Response;
77
+ try {
78
+ response = await fetchImplementation(ACCOUNTS_PATH, {
79
+ method: "POST",
80
+ credentials: "same-origin",
81
+ headers: { "content-type": "application/json" },
82
+ body: JSON.stringify({ name: name.trim() || undefined, idempotencyKey }),
83
+ });
84
+ } catch {
85
+ return { error: "The publishing account could not be created. Check your connection and try again." };
86
+ }
87
+ const body = await response.json().catch(() => null) as { selectedAccountId?: string; role?: string; message?: string } | null;
88
+ if (!response.ok || !body?.selectedAccountId) return { error: body?.message ?? "The publishing account could not be created." };
89
+ return { selectedAccountId: body.selectedAccountId, role: body.role ?? "" };
90
+ }
@@ -282,3 +282,48 @@ export async function handleProspectusAccountSelection(request: NextRequest, opt
282
282
  return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
283
283
  }
284
284
  }
285
+
286
+ const MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH = 150;
287
+ const DEFAULT_PUBLISHING_ACCOUNT_NAME = "Publishing account";
288
+ const IDEMPOTENCY_KEY_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
289
+
290
+ export async function handleProspectusPublishingAccountCreation(request: NextRequest, options: ProspectusSignInRouteOptions = {}): Promise<NextResponse> {
291
+ const runtime = await signInRuntimeFor(request, options);
292
+ if (!runtime) return signInUnavailableResponse();
293
+ if (request.headers.get("origin") !== request.nextUrl.origin) {
294
+ return signInFailedResponse(403, "cross_origin_creation", "Create an account from this site.");
295
+ }
296
+ const nowInSeconds = currentTimeInSeconds(options);
297
+ const session = signedInProspectusSession(request, runtime.sealingSecret, nowInSeconds);
298
+ if (!session) return signInFailedResponse(401, "signed_out", "Sign in first.");
299
+ const body = await request.json().catch(() => null) as { name?: unknown; idempotencyKey?: unknown } | null;
300
+ const requestedName = typeof body?.name === "string" ? body.name.trim() : "";
301
+ const accountName = requestedName || DEFAULT_PUBLISHING_ACCOUNT_NAME;
302
+ if (accountName.length > MAXIMUM_PUBLISHING_ACCOUNT_NAME_LENGTH) {
303
+ return signInFailedResponse(400, "account_name_too_long", "Name the account in 150 characters or fewer.");
304
+ }
305
+ const idempotencyKey = typeof body?.idempotencyKey === "string" ? body.idempotencyKey : "";
306
+ if (!IDEMPOTENCY_KEY_PATTERN.test(idempotencyKey)) {
307
+ return signInFailedResponse(400, "idempotency_key_required", "Send a UUID idempotency key.");
308
+ }
309
+ try {
310
+ const created = await runtime.accounts.createAccount(session.accessToken, accountName, idempotencyKey);
311
+ const selection = await freshlyAuthorizedPublishingSelection(runtime, session, created.account.id, siteProductFor(options));
312
+ if (!selection) return signInFailedResponse(403, "not_a_publishing_account", "The new account does not grant publishing access on this site.");
313
+ const response = NextResponse.json(
314
+ { selectedAccountId: selection.accountId, role: selection.role, publicationCapabilities: selection.publicationCapabilities },
315
+ { status: 201, headers: NO_STORE_HEADERS },
316
+ );
317
+ const selectedSession: ProspectusSignedInSession = { ...session, selectedAccountId: selection.accountId };
318
+ response.cookies.set(
319
+ PROSPECTUS_SESSION_COOKIE,
320
+ sealProspectusCookie(runtime.sealingSecret, PROSPECTUS_SESSION_COOKIE, selectedSession, session.accessTokenExpiresAt),
321
+ sealedCookieAttributes(session.accessTokenExpiresAt, nowInSeconds),
322
+ );
323
+ return response;
324
+ } catch (error) {
325
+ if (error instanceof ProspectusAccountsError) return signInFailedResponse(error.status, error.code, error.message);
326
+ console.error(`Prospectus account creation failed (${error instanceof Error ? error.name : "error"}).`);
327
+ return signInFailedResponse(503, "prospectus_unavailable", "Prospectus could not be reached.");
328
+ }
329
+ }
@@ -49,6 +49,7 @@ export interface ProspectusRequest {
49
49
  body?: unknown;
50
50
  humanAccessToken?: string;
51
51
  identityToken?: string;
52
+ idempotencyKey?: string;
52
53
  }
53
54
 
54
55
  export type ProspectusTransport = (request: ProspectusRequest) => Promise<unknown>;
@@ -84,7 +85,7 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
84
85
  }
85
86
  const runtimeCredentials = options.runtimeCredentials ?? defaultProvider();
86
87
  const fetcher = options.fetcher ?? fetch;
87
- return async ({ path, method, body, humanAccessToken, identityToken }) => {
88
+ return async ({ path, method, body, humanAccessToken, identityToken, idempotencyKey }) => {
88
89
  const credentials = await runtimeCredentials();
89
90
  if (!credentials.sessionToken || (credentials.expiration && credentials.expiration.getTime() <= Date.now())) {
90
91
  throw new Error("Prospectus calls require the site's temporary runtime credentials; static access keys are refused.");
@@ -95,6 +96,7 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
95
96
  host: url.host,
96
97
  ...(humanAccessToken ? { "x-prospectus-human-token": humanAccessToken } : {}),
97
98
  ...(identityToken ? { "x-prospectus-identity-token": identityToken } : {}),
99
+ ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
98
100
  ...(payload ? { "content-type": "application/json" } : {}),
99
101
  };
100
102
  const signer = new SignatureV4({ service: "execute-api", region: options.region, credentials, sha256: Sha256 });
@@ -144,6 +146,16 @@ export class ProspectusSiteAccounts {
144
146
  }) as Promise<ProspectusAccountAuthorization>;
145
147
  }
146
148
 
149
+ createAccount(humanAccessToken: string, accountName: string, idempotencyKey: string): Promise<{ account: { id: string; name: string; role: ProspectusRole } }> {
150
+ return this.transport({
151
+ path: "/v1/accounts",
152
+ method: "POST",
153
+ body: { name: accountName },
154
+ humanAccessToken,
155
+ idempotencyKey,
156
+ }) as Promise<{ account: { id: string; name: string; role: ProspectusRole } }>;
157
+ }
158
+
147
159
  accountDirectory(humanAccessToken: string): Promise<{ person: ProspectusPerson | null; accounts: ProspectusDirectoryAccount[] }> {
148
160
  return this.transport({ path: "/v1/account-directory", method: "GET", humanAccessToken }) as Promise<{
149
161
  person: ProspectusPerson | null;
@@ -0,0 +1,120 @@
1
+ import type { SemanticRelationRecord } from "./category-repository";
2
+ import { MINIMUM_COMPARISONS_FOR_AGREEMENT, SHADOW_AGREEMENT_SCHEMA } from "./cyclotron-suggestions";
3
+
4
+ export const SITE_DECISION_RELATION = "site_decision_is";
5
+ export const SECTION_SELECTOR_CLASSIFIER_PREFIX = "section-selector-";
6
+ export const SECTION_FIT_CLASSIFIER_PREFIX = "section-fit-";
7
+
8
+ /** The site cyclotron's suggested Section for one reference. Advisory only. */
9
+ export type SectionSuggestion = {
10
+ decisionRelationId: string;
11
+ cyclotronId: string | null;
12
+ sectionId: string;
13
+ confidence: number | null;
14
+ /** Section id to probability, highest first. */
15
+ probabilities: Array<{ sectionId: string; probability: number }>;
16
+ decidedAt: string | null;
17
+ };
18
+
19
+ /** How often the suggestion of one Section matched what editors later decided. */
20
+ export type SectionAgreement = {
21
+ sectionId: string;
22
+ comparisons: number;
23
+ agreement: number | null;
24
+ precision: number | null;
25
+ recall: number | null;
26
+ };
27
+
28
+ export type SiteSectionAgreement = {
29
+ computedAt: string | null;
30
+ selectorAccuracy: number | null;
31
+ selectorComparisons: number;
32
+ sections: SectionAgreement[];
33
+ };
34
+
35
+ function parseMetadata(value: unknown): Record<string, unknown> {
36
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
37
+ if (typeof value !== "string") return {};
38
+ try {
39
+ const parsed = JSON.parse(value);
40
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
41
+ } catch {
42
+ return {};
43
+ }
44
+ }
45
+
46
+ function numberOrNull(value: unknown): number | null {
47
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
48
+ }
49
+
50
+ /** The part of `<cyclotronId>/<classifier>` after the last slash. */
51
+ export function siteClassifierName(classifierKey: string): string {
52
+ return classifierKey.slice(classifierKey.lastIndexOf("/") + 1);
53
+ }
54
+
55
+ /**
56
+ * Current suggested Sections keyed by reference lineage, from the relations the
57
+ * Newsroom already loads. Only the section-selector classifier names a Section.
58
+ */
59
+ export function currentSectionSuggestions(relations: SemanticRelationRecord[]): Map<string, SectionSuggestion> {
60
+ const suggestions = new Map<string, SectionSuggestion>();
61
+ for (const relation of relations) {
62
+ if (relation.predicate !== SITE_DECISION_RELATION || relation.relationState !== "current") continue;
63
+ if (!siteClassifierName(relation.classifierId ?? "").startsWith(SECTION_SELECTOR_CLASSIFIER_PREFIX)) continue;
64
+ const metadata = parseMetadata(relation.metadata);
65
+ const sectionId = typeof metadata.label === "string" ? metadata.label : "";
66
+ if (!sectionId) continue;
67
+ const rawProbabilities = parseMetadata(metadata.probabilities);
68
+ const probabilities = Object.entries(rawProbabilities)
69
+ .flatMap(([candidate, probability]) => (numberOrNull(probability) === null ? [] : [{ sectionId: candidate, probability: probability as number }]))
70
+ .sort((left, right) => right.probability - left.probability);
71
+ suggestions.set(relation.subjectLineageId, {
72
+ decisionRelationId: relation.id,
73
+ cyclotronId: typeof metadata.cyclotronId === "string" ? metadata.cyclotronId : null,
74
+ sectionId,
75
+ confidence: numberOrNull(relation.confidence) ?? probabilities.find((entry) => entry.sectionId === sectionId)?.probability ?? null,
76
+ probabilities,
77
+ decidedAt: typeof metadata.decidedAt === "string" ? metadata.decidedAt : null,
78
+ });
79
+ }
80
+ return suggestions;
81
+ }
82
+
83
+ /** The site cyclotron's id, from any current Section suggestion. */
84
+ export function siteCyclotronIdOf(suggestions: Map<string, SectionSuggestion>): string | null {
85
+ for (const suggestion of suggestions.values()) if (suggestion.cyclotronId) return suggestion.cyclotronId;
86
+ return null;
87
+ }
88
+
89
+ /** The per-Section part of a cyclotron-shadow-agreement/v1 payload the site sweep wrote, or null for any other JSON. */
90
+ export function parseSiteSectionAgreement(json: unknown): SiteSectionAgreement | null {
91
+ if (!json || typeof json !== "object") return null;
92
+ const record = json as Record<string, unknown>;
93
+ if (record.schema !== SHADOW_AGREEMENT_SCHEMA) return null;
94
+ const classifiers = record.classifiers;
95
+ if (!classifiers || typeof classifiers !== "object") return null;
96
+ const sections: SectionAgreement[] = [];
97
+ for (const [classifier, value] of Object.entries(classifiers as Record<string, unknown>)) {
98
+ if (!classifier.startsWith(SECTION_FIT_CLASSIFIER_PREFIX) || !value || typeof value !== "object") continue;
99
+ const metrics = value as Record<string, unknown>;
100
+ sections.push({
101
+ sectionId: classifier.slice(SECTION_FIT_CLASSIFIER_PREFIX.length),
102
+ comparisons: numberOrNull(metrics.comparisons) ?? 0,
103
+ agreement: numberOrNull(metrics.agreement),
104
+ precision: numberOrNull(metrics.precision),
105
+ recall: numberOrNull(metrics.recall),
106
+ });
107
+ }
108
+ if (!sections.length && numberOrNull(record.selectorAccuracy) === null) return null;
109
+ return {
110
+ computedAt: typeof record.computedAt === "string" ? record.computedAt : null,
111
+ selectorAccuracy: numberOrNull(record.selectorAccuracy),
112
+ selectorComparisons: numberOrNull(record.comparisons) ?? 0,
113
+ sections,
114
+ };
115
+ }
116
+
117
+ /** Agreement figures are worth showing only once enough editor decisions followed a suggestion. */
118
+ export function sectionAgreementIsMeasured(agreement: SectionAgreement | undefined): boolean {
119
+ return Boolean(agreement) && (agreement as SectionAgreement).comparisons >= MINIMUM_COMPARISONS_FOR_AGREEMENT && (agreement as SectionAgreement).agreement !== null;
120
+ }
package/package.json CHANGED
@@ -76,7 +76,6 @@
76
76
  "./components/archive-shell": "./components/archive-shell.tsx",
77
77
  "./components/article-video": "./components/article-video.tsx",
78
78
  "./components/blog-page-background": "./components/blog-page-background.tsx",
79
- "./components/board-card-excerpts": "./components/board-card-excerpts.tsx",
80
79
  "./components/curation-delegation-client": "./components/curation-delegation-client.ts",
81
80
  "./components/dev-sandbox-editor-sign-in": "./components/dev-sandbox-editor-sign-in.tsx",
82
81
  "./components/markus-ir-preview": "./components/markus-ir-preview.tsx",
@@ -116,6 +115,7 @@
116
115
  "./components/reference-live-agreement": "./components/reference-live-agreement.tsx",
117
116
  "./components/reference-priming": "./components/reference-priming.tsx",
118
117
  "./components/reference-relevance-review": "./components/reference-relevance-review.tsx",
118
+ "./components/reference-section-suggestion": "./components/reference-section-suggestion.tsx",
119
119
  "./components/reference-source-preview": "./components/reference-source-preview.tsx",
120
120
  "./components/settings-page": "./components/settings-page.tsx",
121
121
  "./components/signup-entries-panel": "./components/signup-entries-panel.tsx",
@@ -197,6 +197,7 @@
197
197
  "./lib/layout-scenarios": "./lib/layout-scenarios.ts",
198
198
  "./lib/layout-types": "./lib/layout-types.ts",
199
199
  "./lib/layout-warnings": "./lib/layout-warnings.ts",
200
+ "./lib/local-preview-section-fixtures": "./lib/local-preview-section-fixtures.ts",
200
201
  "./lib/local-preview-seed": "./lib/local-preview-seed.ts",
201
202
  "./lib/local-preview-tenant": "./lib/local-preview-tenant.ts",
202
203
  "./lib/local-preview": "./lib/local-preview.ts",
@@ -270,6 +271,7 @@
270
271
  "./lib/site-brand": "./lib/site-brand.ts",
271
272
  "./lib/site-env": "./lib/site-env.ts",
272
273
  "./lib/site-renderer": "./lib/site-renderer.ts",
274
+ "./lib/site-section-suggestions": "./lib/site-section-suggestions.ts",
273
275
  "./lib/site-stack": "./lib/site-stack.ts",
274
276
  "./lib/staging-gate": "./lib/staging-gate.ts",
275
277
  "./lib/staging-gated-path": "./lib/staging-gated-path.ts",
@@ -300,6 +302,7 @@
300
302
  "./app/[year]/[month]/[day]/page/[pageNumber]/page": "./app/[year]/[month]/[day]/page/[pageNumber]/page.tsx",
301
303
  "./app/[year]/[month]/[day]/section/[sectionKey]/page": "./app/[year]/[month]/[day]/section/[sectionKey]/page.tsx",
302
304
  "./app/api/archive/editions/route": "./app/api/archive/editions/route.ts",
305
+ "./app/api/auth/prospectus/accounts/route": "./app/api/auth/prospectus/accounts/route.ts",
303
306
  "./app/api/auth/prospectus/callback/route": "./app/api/auth/prospectus/callback/route.ts",
304
307
  "./app/api/auth/prospectus/selection/route": "./app/api/auth/prospectus/selection/route.ts",
305
308
  "./app/api/auth/prospectus/session/route": "./app/api/auth/prospectus/session/route.ts",
@@ -374,7 +377,7 @@
374
377
  "optional": true
375
378
  }
376
379
  },
377
- "version": "1.13.0",
380
+ "version": "1.15.0",
378
381
  "dependencies": {
379
382
  "@aws-amplify/adapter-nextjs": "^1.7.3",
380
383
  "@aws-crypto/sha256-js": "5.2.0",
@@ -387,7 +390,7 @@
387
390
  "@aws-sdk/s3-request-presigner": "^3.1045.0",
388
391
  "@base-ui/react": "^1.5.0",
389
392
  "@chenglou/pretext": "^0.0.7",
390
- "@kanbus/ui": "https://github.com/AnthusAI/Kanbus/releases/download/ui-v0.2.0/kanbus-ui-0.2.0.tgz",
393
+ "@kanbus/ui": "https://github.com/AnthusAI/Kanbus/releases/download/ui-v0.3.0/kanbus-ui-0.3.0.tgz",
391
394
  "@radix-ui/colors": "3.0.0",
392
395
  "@smithy/protocol-http": "5.4.1",
393
396
  "@smithy/signature-v4": "5.7.4",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.13.0",
2
+ "version": "1.15.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",
@@ -66,6 +66,17 @@
66
66
  "revalidate": "60"
67
67
  }
68
68
  },
69
+ {
70
+ "file": "app/api/auth/prospectus/accounts/route.ts",
71
+ "hasDefault": false,
72
+ "exports": [
73
+ "POST"
74
+ ],
75
+ "config": {
76
+ "dynamic": "\"force-dynamic\"",
77
+ "runtime": "\"nodejs\""
78
+ }
79
+ },
69
80
  {
70
81
  "file": "app/api/auth/prospectus/callback/route.ts",
71
82
  "hasDefault": false,
@@ -1,61 +0,0 @@
1
- "use client";
2
-
3
- import { useEffect } from "react";
4
-
5
- type Excerpt = { issueId: string; text: string };
6
-
7
- const EXCERPT_TOGGLE_THRESHOLD = 130;
8
-
9
- function buildExcerptElement(text: string): HTMLElement {
10
- const container = document.createElement("div");
11
- container.className = "board-card-excerpt";
12
- container.dataset.boardCardExcerpt = "";
13
- container.dataset.expanded = "false";
14
- const paragraph = document.createElement("p");
15
- paragraph.textContent = text;
16
- container.append(paragraph);
17
- if (text.length > EXCERPT_TOGGLE_THRESHOLD) {
18
- const toggle = document.createElement("button");
19
- toggle.type = "button";
20
- toggle.textContent = "More";
21
- toggle.setAttribute("aria-expanded", "false");
22
- const flip = (event: Event) => {
23
- event.stopPropagation();
24
- const expanded = container.dataset.expanded !== "true";
25
- container.dataset.expanded = String(expanded);
26
- toggle.textContent = expanded ? "Less" : "More";
27
- toggle.setAttribute("aria-expanded", String(expanded));
28
- };
29
- toggle.addEventListener("click", flip);
30
- toggle.addEventListener("keydown", event => event.stopPropagation());
31
- container.append(toggle);
32
- }
33
- return container;
34
- }
35
-
36
- /**
37
- * Adds a short expandable excerpt under each card's headline. The Kanbus card has no excerpt slot,
38
- * so each excerpt is written into the card's content block once the card is on the page.
39
- */
40
- export function BoardCardExcerpts({ excerpts, scope }: { excerpts: readonly Excerpt[]; scope: HTMLElement | null }) {
41
- useEffect(() => {
42
- if (!scope) return;
43
- const textByIssueId = new Map(excerpts.map(({ issueId, text }) => [issueId, text]));
44
- const place = () => {
45
- for (const card of Array.from(scope.querySelectorAll<HTMLElement>(".issue-card"))) {
46
- const text = textByIssueId.get(card.dataset.issueId ?? "");
47
- const content = card.querySelector<HTMLElement>(".issue-card-content");
48
- if (!text || !content) continue;
49
- const existing = content.querySelector<HTMLElement>(":scope > [data-board-card-excerpt]");
50
- if (existing && existing.querySelector("p")?.textContent === text) continue;
51
- existing?.remove();
52
- content.querySelector("h3")?.after(buildExcerptElement(text));
53
- }
54
- };
55
- place();
56
- const observer = new MutationObserver(place);
57
- observer.observe(scope, { childList: true, subtree: true });
58
- return () => observer.disconnect();
59
- }, [excerpts, scope]);
60
- return null;
61
- }