@anthusai/papyrus 5.43.0-next.4 → 5.43.0-next.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/category-action/handler.ts +2 -1
- package/amplify/functions/content-actions/handler.py +8 -0
- package/amplify/functions/content-actions/requirements.txt +2 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +16 -0
- package/amplify/functions/monitoring-view/schema.ts +16 -0
- package/amplify/functions/procedure-action/handler.ts +2 -10
- package/amplify/functions/proposal-actions/proposals.py +20 -3
- package/amplify/plugins/reader-steering/function/handler.py +1 -3
- package/amplify/plugins/reader-steering/function/moderation.py +19 -0
- package/app/api/newsroom/source-frameable/route.ts +12 -0
- package/app/api/newsroom/source-icon/route.ts +20 -0
- package/app/newsroom/[section]/[...selection]/page.tsx +2 -0
- package/app/newsroom/[section]/page.tsx +1 -0
- package/bin/papyrus-app.mjs +12 -1
- package/bin/papyrus-infra.mjs +21 -1
- package/components/auto-accepted-badge.tsx +21 -0
- package/components/conversation-context.tsx +52 -0
- package/components/conversation-provider.tsx +20 -0
- package/components/conversation-thread.tsx +174 -0
- package/components/decision-control.tsx +306 -0
- package/components/dev-sandbox-editor-sign-in.tsx +2 -2
- package/components/markdown-context.tsx +412 -0
- package/components/monitoring-overview-view.tsx +143 -21
- package/components/monitoring-panel.tsx +64 -4
- package/components/needs-me-bodies.tsx +302 -0
- package/components/needs-me-detail.tsx +24 -0
- package/components/needs-me-feed.tsx +594 -0
- package/components/needs-me-reference-detail.tsx +79 -0
- package/components/needs-me-shared.tsx +48 -0
- package/components/news-desk-client-provider.tsx +24 -3
- package/components/news-desk-page.tsx +2 -1
- package/components/news-desk-taxonomy-client.ts +253 -31
- package/components/newsroom-access-gate.tsx +13 -20
- package/components/newsroom-board-skeleton.tsx +20 -0
- package/components/newsroom-board-view.tsx +2 -6
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +88 -73
- package/components/newsroom-ops-shell.tsx +8 -28
- package/components/newsroom-organizer.css +127 -3
- package/components/newsroom-references-view.tsx +334 -158
- package/components/notification-cards.tsx +370 -0
- package/components/notification-center.tsx +376 -213
- package/components/papyrus-console-shell.tsx +2 -1
- package/components/proposals-panel.tsx +21 -24
- package/components/proposals-view.tsx +4 -2
- package/components/reader-auth-control.tsx +3 -1
- package/components/reference-capture-client.ts +138 -0
- package/components/reference-capture-panel.tsx +268 -0
- package/components/reference-conversation.tsx +43 -0
- package/components/reference-feed.tsx +514 -0
- package/components/reference-review-card.tsx +62 -197
- package/components/reference-source-preview.tsx +27 -5
- package/components/reference-stories.tsx +34 -0
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/story-reporting-trail-client.ts +45 -8
- package/components/story-reporting-trail.tsx +24 -13
- package/components/topic-steering-workspace.tsx +50 -439
- package/components/ui/auto-grow-textarea.tsx +18 -0
- package/components/use-conversation.ts +84 -0
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +188 -0
- package/infra/amplify-app-shell.js +18 -2
- package/infra/amplify-app-shell.ts +18 -2
- package/infra/chat-stack.js +222 -0
- package/infra/chat-stack.ts +250 -0
- package/infra/chat.test.js +142 -0
- package/infra/chat.test.ts +151 -0
- package/infra/index.js +1 -0
- package/infra/site-config.js +58 -1
- package/infra/site-config.ts +78 -1
- package/lib/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +20 -0
- package/lib/conversation.ts +154 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-seed.ts +24 -2
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +29 -2
- package/lib/monitoring-overview.ts +52 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/newsroom-all-pages.ts +21 -0
- package/lib/newsroom-chat-identity.ts +2 -1
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +2 -1
- package/lib/newsroom-references.ts +25 -1
- package/lib/notification-feed.ts +212 -0
- package/lib/proposals-client.ts +29 -0
- package/lib/reader-subject.ts +3 -2
- package/lib/reference-capture.ts +294 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +236 -0
- package/lib/reference-policy.ts +12 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-client.ts +71 -5
- package/lib/review-drafts.ts +277 -0
- package/lib/review-fixture.ts +56 -2
- package/lib/review-requests.ts +89 -24
- package/lib/site-brand.ts +2 -0
- package/lib/source-frameable.ts +77 -0
- package/lib/source-icon.ts +104 -0
- package/lib/staff-groups.ts +23 -0
- package/lib/staging-gate.ts +2 -2
- package/lib/story-reporting-trail.ts +33 -2
- package/package.json +45 -2
- package/routes.manifest.json +23 -1
- package/components/newsroom-overview-skeleton.tsx +0 -25
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { referenceDisplayTitle, referenceLineageId, formatReferenceListDate } from "../lib/newsroom-references";
|
|
4
|
+
import { referenceMetadataField } from "../lib/reference-display";
|
|
5
|
+
import { referenceDek, referencePreviewsByLineage } from "../lib/reference-preview";
|
|
6
|
+
import { DEFAULT_TEASER_WORDS } from "../lib/reference-capture";
|
|
7
|
+
import { isAutoAccepted, referenceReasonCode, normalizeReferenceCurationStatus } from "../lib/reference-policy";
|
|
8
|
+
import { AutoAcceptedBadge } from "./auto-accepted-badge";
|
|
9
|
+
import type { ReferenceRecord } from "../lib/category-repository";
|
|
10
|
+
import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
11
|
+
import { DefaultOutcome, Heading, type DetailProps } from "./needs-me-bodies";
|
|
12
|
+
import { decidedChoiceOf } from "./needs-me-shared";
|
|
13
|
+
import { predictionFromDecision, ReviewCard, type CardPrediction } from "./reference-review-card";
|
|
14
|
+
import { ReferenceConversation } from "./reference-conversation";
|
|
15
|
+
import { ReferenceCapturePanel } from "./reference-capture-panel";
|
|
16
|
+
import { useReferenceCapture } from "./reference-capture-client";
|
|
17
|
+
import { ReferenceSourcePreview } from "./reference-source-preview";
|
|
18
|
+
import { ReferenceSuggestionProvenance } from "./reference-cyclotron-suggestion";
|
|
19
|
+
import { useReferencePreviewAttachments } from "./use-reference-preview-attachments";
|
|
20
|
+
|
|
21
|
+
function cardPrediction(item: NeedsMeEntry, predictionLoading: boolean): CardPrediction {
|
|
22
|
+
if (item.payload.kind === "reference" && item.payload.decision) return predictionFromDecision(item.payload.decision);
|
|
23
|
+
return predictionLoading ? { state: "loading" } : { state: "none" };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A Reference, or a Reference whose capture may be cut off: the References detail's own pieces, in the same order. What the item is
|
|
28
|
+
* comes first (title, blurb, link, badges, summary), then the rating, then what was captured and how well.
|
|
29
|
+
*/
|
|
30
|
+
export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEntry["payload"], { kind: "reference" }> }) {
|
|
31
|
+
const { item, payload, ratings, disabledReason, attachments, captureTeaserLine = DEFAULT_TEASER_WORDS, onCorrectCapture, predictionLoading } = props;
|
|
32
|
+
const reference: ReferenceRecord = payload.reference;
|
|
33
|
+
const lineageId = referenceLineageId(reference);
|
|
34
|
+
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
35
|
+
const previewAttachments = useReferencePreviewAttachments(reference, attachments);
|
|
36
|
+
const capture = useReferenceCapture(reference, previewAttachments, captureTeaserLine, onCorrectCapture);
|
|
37
|
+
const url = reference.sourceUri?.trim() && /^https?:\/\//i.test(reference.sourceUri.trim()) ? reference.sourceUri.trim() : null;
|
|
38
|
+
const summary = capture.summary ?? referenceMetadataField(null, reference.metadata, "summary");
|
|
39
|
+
const dek = referenceDek(reference, referencePreviewsByLineage(previewAttachments).get(lineageId));
|
|
40
|
+
const incomplete = item.type === "incomplete_capture";
|
|
41
|
+
const auto = isAutoAccepted(reference);
|
|
42
|
+
return (
|
|
43
|
+
<div className="space-y-4" data-reference-curation-status={status} data-news-desk-reference-detail={lineageId}>
|
|
44
|
+
<ReviewCard
|
|
45
|
+
decided={decidedChoiceOf(item)}
|
|
46
|
+
disabledReason={disabledReason}
|
|
47
|
+
initialNote={status === "pending" || auto ? "" : reference.curationStatusReason ?? ""}
|
|
48
|
+
initialReason={referenceReasonCode(reference) ?? null}
|
|
49
|
+
dek={dek}
|
|
50
|
+
itemId={item.id}
|
|
51
|
+
meta={<><Heading item={item} />{auto ? <AutoAcceptedBadge actor={reference.curationStatusUpdatedBy} reason={reference.curationStatusReason} /> : null}</>}
|
|
52
|
+
onDecide={async () => undefined}
|
|
53
|
+
prediction={cardPrediction(item, predictionLoading)}
|
|
54
|
+
ratings={ratings}
|
|
55
|
+
sourcePreview={<ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />}
|
|
56
|
+
summary={summary}
|
|
57
|
+
title={referenceDisplayTitle(reference)}
|
|
58
|
+
titleHref={url}
|
|
59
|
+
>
|
|
60
|
+
{payload.decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={payload.decision} /></div> : null}
|
|
61
|
+
<ReferenceConversation disabledReason={disabledReason ? "Editor access is required to write messages." : null} lineageId={lineageId} referenceId={reference.id} title={referenceDisplayTitle(reference)} />
|
|
62
|
+
</ReviewCard>
|
|
63
|
+
<DefaultOutcome item={item} />
|
|
64
|
+
{incomplete ? <p className="text-sm text-muted-foreground" data-needs-me-capture-note>The captured text looks cut off or missing. Read it below. If it is incomplete, choose Paste or correct the text and say whether the capture was complete, or rate the Reference as it is.</p> : null}
|
|
65
|
+
<ReferenceCapturePanel capture={capture} showSummary={false} />
|
|
66
|
+
<dl className="grid grid-cols-2 gap-3 text-sm">
|
|
67
|
+
<div>
|
|
68
|
+
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Imported</dt>
|
|
69
|
+
<dd className="mt-1 text-foreground">{formatReferenceListDate(reference)}</dd>
|
|
70
|
+
</div>
|
|
71
|
+
<div>
|
|
72
|
+
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Media</dt>
|
|
73
|
+
<dd className="mt-1 text-foreground">{reference.mediaType ?? "unknown"}</dd>
|
|
74
|
+
</div>
|
|
75
|
+
</dl>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CheckIcon, CircleCheckIcon, Loader2Icon } from "lucide-react";
|
|
4
|
+
import { cn } from "../lib/utils";
|
|
5
|
+
import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
6
|
+
import type { ReviewChoice } from "./reference-review-card";
|
|
7
|
+
|
|
8
|
+
/** References and captures are rated like the References feed: a reason code is asked for on Reject. */
|
|
9
|
+
export function isReferenceLike(item: Pick<NeedsMeEntry, "type">): boolean {
|
|
10
|
+
return item.type === "reference" || item.type === "incomplete_capture";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function decidedChoiceOf(item: Pick<NeedsMeEntry, "decisionState">): ReviewChoice | null {
|
|
14
|
+
return item.decisionState === "accepted" ? "accept" : item.decisionState === "rejected" ? "reject" : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** What was recorded when a non-Reference item was decided: its Why, and whether it was rejected as "not this kind". Shown again whenever the item is reopened. */
|
|
18
|
+
export function recordedAnswer(item: NeedsMeEntry): { why: string | null; notThisKind: boolean } {
|
|
19
|
+
const { payload } = item;
|
|
20
|
+
if (payload.kind === "draft") return { why: payload.item.decision?.why ?? null, notThisKind: Boolean(payload.item.decision?.notThisKind) };
|
|
21
|
+
if (payload.kind === "request") return { why: payload.request.answer?.feedback ?? null, notThisKind: payload.request.answer?.steer === "not_this_kind" };
|
|
22
|
+
if (payload.kind === "email") return { why: payload.summary.quarantine?.decision?.reason || null, notThisKind: false };
|
|
23
|
+
return { why: null, notThisKind: false };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A flag is information, not a question: one Acknowledge button in the footer's own tone. */
|
|
27
|
+
export function FlagFooter({ busy, disabledReason, done, error, onAcknowledge }: { busy: boolean; disabledReason: string | null; done: boolean; error: string | null; onAcknowledge: () => void }) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="space-y-2" data-flag-footer>
|
|
30
|
+
{done ? (
|
|
31
|
+
<p className="inline-flex min-h-12 items-center gap-1.5 text-sm font-medium" data-flag-acknowledged><CircleCheckIcon aria-hidden="true" className="size-4" />Acknowledged</p>
|
|
32
|
+
) : (
|
|
33
|
+
<button
|
|
34
|
+
aria-disabled={busy || Boolean(disabledReason) || undefined}
|
|
35
|
+
className={cn("inline-flex min-h-12 w-full items-center justify-center gap-1.5 rounded-[16px] bg-[var(--feed-control)] px-3 text-sm font-semibold text-foreground outline-none transition-colors hover:bg-[var(--feed-control)]/70 focus-visible:ring-2 focus-visible:ring-ring", (busy || disabledReason) && "opacity-60")}
|
|
36
|
+
data-flag-acknowledge
|
|
37
|
+
onClick={() => { if (!busy && !disabledReason) onAcknowledge(); }}
|
|
38
|
+
type="button"
|
|
39
|
+
>
|
|
40
|
+
{busy ? <Loader2Icon aria-hidden="true" className="size-4 animate-spin" /> : <CheckIcon aria-hidden="true" className="size-4" />}
|
|
41
|
+
{busy ? "Saving…" : "Acknowledge"}
|
|
42
|
+
</button>
|
|
43
|
+
)}
|
|
44
|
+
{disabledReason && !done ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{disabledReason}</p> : null}
|
|
45
|
+
{error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -6,6 +6,9 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
|
|
|
6
6
|
import type { DevSandboxEditorAuth } from "../lib/dev-sandbox-editor-auth";
|
|
7
7
|
import type { CategorySteeringDashboard } from "../lib/category-repository";
|
|
8
8
|
import { ensureDevSandboxEditorSession } from "./dev-sandbox-editor-sign-in";
|
|
9
|
+
import { discardReferencesHeadStart, prefetchReferencesFeed, wantsReferencesHeadStart } from "./references-prefetch";
|
|
10
|
+
import { SITE_BRAND } from "../lib/site-brand";
|
|
11
|
+
import { clearSavedNewsroomDisplay, loadSessionDisplay, saveSessionDisplay } from "../lib/reference-feed-cache";
|
|
9
12
|
import {
|
|
10
13
|
loadEditorAssignmentsData,
|
|
11
14
|
loadEditorDoctrineRecordsData,
|
|
@@ -75,7 +78,12 @@ export function NewsDeskClientProvider({
|
|
|
75
78
|
));
|
|
76
79
|
return;
|
|
77
80
|
}
|
|
78
|
-
|
|
81
|
+
// A recent confirmed session is shown (name, "loading" instead of "checking") while the real check runs again.
|
|
82
|
+
// Display only: the check below still decides access, and the backend authorizes every call.
|
|
83
|
+
const remembered = loadSessionDisplay(SITE_BRAND.id);
|
|
84
|
+
setShell((current) => (remembered && !current.dashboard
|
|
85
|
+
? { ...beginAccessCheck(current), phase: "loadingDesk" as const, auth: { status: "signedIn" as const, label: remembered.label } }
|
|
86
|
+
: beginAccessCheck(current)));
|
|
79
87
|
|
|
80
88
|
try {
|
|
81
89
|
await ensureDevSandboxEditorSession(devSandboxEditorAuth);
|
|
@@ -88,6 +96,8 @@ export function NewsDeskClientProvider({
|
|
|
88
96
|
if (bootstrapSequenceRef.current !== sequence) return;
|
|
89
97
|
|
|
90
98
|
if (access.status === "signedOut") {
|
|
99
|
+
clearSavedNewsroomDisplay(SITE_BRAND.id);
|
|
100
|
+
discardReferencesHeadStart();
|
|
91
101
|
setShell((current) => resolveSignedOut(current, access.auth));
|
|
92
102
|
return;
|
|
93
103
|
}
|
|
@@ -100,7 +110,10 @@ export function NewsDeskClientProvider({
|
|
|
100
110
|
return;
|
|
101
111
|
}
|
|
102
112
|
|
|
113
|
+
saveSessionDisplay(SITE_BRAND.id, { label: access.auth.label, isAdmin: access.isAdmin });
|
|
103
114
|
setShell((current) => beginDeskLoad(current, access.auth));
|
|
115
|
+
// The References reads do not depend on the summary dashboard: start them now, side by side with it.
|
|
116
|
+
if (typeof window !== "undefined" && wantsReferencesHeadStart(window.location.pathname)) prefetchReferencesFeed();
|
|
104
117
|
|
|
105
118
|
try {
|
|
106
119
|
const dashboard = await loadEditorNewsDeskDashboard({ isAdmin: access.isAdmin });
|
|
@@ -113,7 +126,8 @@ export function NewsDeskClientProvider({
|
|
|
113
126
|
return;
|
|
114
127
|
}
|
|
115
128
|
if (isAuthorizationError(error)) {
|
|
116
|
-
|
|
129
|
+
// The session already has editor rights, so the backend refusing a read is not a missing role. Say what was refused.
|
|
130
|
+
setShell((current) => resolveDashboardFailure(current, access.auth, refusedReadMessage(error)));
|
|
117
131
|
return;
|
|
118
132
|
}
|
|
119
133
|
setShell((current) => resolveDashboardFailure(
|
|
@@ -182,6 +196,8 @@ export function NewsDeskClientProvider({
|
|
|
182
196
|
const unsubscribe = Hub.listen("auth", ({ payload }) => {
|
|
183
197
|
if (payload.event === "signedOut") {
|
|
184
198
|
bootstrapSequenceRef.current += 1;
|
|
199
|
+
clearSavedNewsroomDisplay(SITE_BRAND.id);
|
|
200
|
+
discardReferencesHeadStart();
|
|
185
201
|
setShell((current) => resolveSignedOut(current, { status: "signedOut", label: "Signed out" }));
|
|
186
202
|
return;
|
|
187
203
|
}
|
|
@@ -215,9 +231,14 @@ function isAuthorizationError(error: unknown): boolean {
|
|
|
215
231
|
return /not authorized|unauthorized|access denied|forbidden/i.test(message);
|
|
216
232
|
}
|
|
217
233
|
|
|
234
|
+
function refusedReadMessage(error: unknown): string {
|
|
235
|
+
const detail = error instanceof Error ? error.message : String(error ?? "");
|
|
236
|
+
return `The Newsroom backend refused a read for this signed-in account (${detail}). The account has editor or admin rights, so this is a backend authorization rule, not a missing role.`;
|
|
237
|
+
}
|
|
238
|
+
|
|
218
239
|
function resolvePrivateReadFailure(state: NewsDeskShellState, auth: NewsDeskShellState["auth"], error: unknown, fallback: string): NewsDeskShellState {
|
|
219
240
|
if (isUnauthenticatedError(error)) return resolveSignedOut(state, { status: "signedOut", label: "Signed out" });
|
|
220
|
-
if (isAuthorizationError(error)) return
|
|
241
|
+
if (isAuthorizationError(error)) return resolveDashboardFailure(state, auth, refusedReadMessage(error));
|
|
221
242
|
return resolveDashboardFailure(state, auth, error instanceof Error ? error.message : fallback);
|
|
222
243
|
}
|
|
223
244
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { withReferenceFeedFixture } from "../lib/local-preview-feed-fixture";
|
|
1
2
|
import { resolveNewsDeskTab } from "../lib/newsroom-section-routing";
|
|
2
3
|
import { NewsDeskWorkspace, type NewsDeskTab } from "./topic-steering-workspace";
|
|
3
4
|
import {
|
|
@@ -67,7 +68,7 @@ export async function NewsDeskPage({ section: routeSection, selectionPath, searc
|
|
|
67
68
|
const analysisProfiles = await loadAnalysisProfileSummaries(getBrandAnalysisProfilesPath(brand));
|
|
68
69
|
const configuredCorpora = await loadConfiguredCorpusSummaries(getBrandSteeringConfigPath(brand));
|
|
69
70
|
const dashboard = useDemoDashboard
|
|
70
|
-
? createDemoCategorySteeringDashboard(brand.id, getBrandNewsroomSectionsConfigPath(brand))
|
|
71
|
+
? withReferenceFeedFixture(createDemoCategorySteeringDashboard(brand.id, getBrandNewsroomSectionsConfigPath(brand)))
|
|
71
72
|
: await loadCategorySteeringDashboard();
|
|
72
73
|
return <NewsDeskWorkspace analysisProfiles={analysisProfiles} configuredCorpora={configuredCorpora} dashboard={dashboard} initialSelection={initialSelection} initialTab={initialTab} />;
|
|
73
74
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { loadReferenceReviewDetails } from "../lib/reference-review-details";
|
|
4
|
+
import { loadAllPages } from "../lib/newsroom-all-pages";
|
|
5
|
+
import { hasEditorRights } from "../lib/staff-groups";
|
|
4
6
|
|
|
5
7
|
import { isEditorialWorkAssignment } from "../lib/newsroom-assignments";
|
|
6
8
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
@@ -8,6 +10,7 @@ import { createLocalPreviewRealPublishedVersions, createLocalPreviewAssignments,
|
|
|
8
10
|
import { generateClient } from "aws-amplify/data";
|
|
9
11
|
import { testEditorOverrideAllowed } from "../lib/test-editor-override";
|
|
10
12
|
import { downloadData, getUrl } from "aws-amplify/storage";
|
|
13
|
+
import { fetchAuthSession } from "aws-amplify/auth";
|
|
11
14
|
import type { Schema } from "../amplify/data/resource";
|
|
12
15
|
import type { NewsDeskAppendix, NewsDeskCategoryTreeNode } from "../lib/content-types";
|
|
13
16
|
import { createEmptyCategorySteeringDashboard } from "../lib/category-dashboard";
|
|
@@ -43,7 +46,19 @@ import type {
|
|
|
43
46
|
SemanticRelationRecord,
|
|
44
47
|
UserDirectoryEntry,
|
|
45
48
|
} from "../lib/category-repository";
|
|
46
|
-
import { relationTypeKey } from "../lib/semantic-graph";
|
|
49
|
+
import { relationTypeKey, semanticStateKey, semanticVersionKey } from "../lib/semantic-graph";
|
|
50
|
+
import {
|
|
51
|
+
CONVERSATION_FEED_KEY,
|
|
52
|
+
REFERENCE_CONVERSATION_KIND,
|
|
53
|
+
conversationAnchorKey,
|
|
54
|
+
conversationMessageId,
|
|
55
|
+
conversationThreadId,
|
|
56
|
+
conversationThreadKind,
|
|
57
|
+
createWithSequenceRetry,
|
|
58
|
+
findOrCreateThread,
|
|
59
|
+
type ConversationAnchor,
|
|
60
|
+
type ConversationMessage,
|
|
61
|
+
} from "../lib/conversation";
|
|
47
62
|
import { legacyStageKeysToLoad, type ContentItemVersionRecord } from "../lib/content-item-lifecycle";
|
|
48
63
|
import { getContentItemStageDefinitions } from "../lib/site-brand";
|
|
49
64
|
import { DOCTRINE_DEFINITIONS, getCategoryDoctrineDefinitions, type DoctrineCategory } from "../lib/doctrine";
|
|
@@ -101,6 +116,23 @@ const LIST_MESSAGE_THREADS_BY_KIND_AND_UPDATED_AT_QUERY = `
|
|
|
101
116
|
}
|
|
102
117
|
`;
|
|
103
118
|
|
|
119
|
+
const LIST_MESSAGE_THREADS_BY_ANCHOR_AND_UPDATED_AT_QUERY = `
|
|
120
|
+
query ListMessageThreadsByAnchorAndUpdatedAt($primaryAnchorKey: String!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
|
|
121
|
+
listMessageThreadsByAnchorAndUpdatedAt(primaryAnchorKey: $primaryAnchorKey, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
|
|
122
|
+
items {
|
|
123
|
+
id threadKind status title summary primaryAnchorKind primaryAnchorId primaryAnchorLineageId primaryAnchorKey createdBySub createdByUserProfileId createdByLabel messageCount lastMessageId lastMessageAt contextDigest activeResponseMessageId responseLockOwner responseLockExpiresAt metadata createdAt updatedAt newsroomFeedKey
|
|
124
|
+
}
|
|
125
|
+
nextToken
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
const CREATE_SEMANTIC_RELATION_MUTATION = `
|
|
131
|
+
mutation CreateSemanticRelation($input: CreateSemanticRelationInput!) {
|
|
132
|
+
createSemanticRelation(input: $input) { id }
|
|
133
|
+
}
|
|
134
|
+
`;
|
|
135
|
+
|
|
104
136
|
const LIST_MESSAGES_BY_THREAD_AND_SEQUENCE_QUERY = `
|
|
105
137
|
query ListMessagesByThreadAndSequence($threadId: ID!, $sortDirection: ModelSortDirection, $limit: Int, $nextToken: String) {
|
|
106
138
|
listMessagesByThreadAndSequence(threadId: $threadId, sortDirection: $sortDirection, limit: $limit, nextToken: $nextToken) {
|
|
@@ -838,7 +870,7 @@ export async function loadEditorResolvedAccessState(): Promise<EditorAccessState
|
|
|
838
870
|
if (!refreshed.hasSession || refreshed.auth.status === "signedOut") {
|
|
839
871
|
return { status: "signedOut", isEditor: false, isAdmin: false, auth: refreshed.auth, error: null };
|
|
840
872
|
}
|
|
841
|
-
const isEditor = refreshed.groups
|
|
873
|
+
const isEditor = hasEditorRights(refreshed.groups);
|
|
842
874
|
if (!isEditor) {
|
|
843
875
|
return {
|
|
844
876
|
status: "forbidden",
|
|
@@ -1386,6 +1418,41 @@ export async function createSectionForumThreadRecord(input: {
|
|
|
1386
1418
|
return { created: true, thread: normalizeThreadRecord(response.createMessageThread ?? thread) };
|
|
1387
1419
|
}
|
|
1388
1420
|
|
|
1421
|
+
/**
|
|
1422
|
+
* Appends one Message to a thread: the next sequence number, the Message, then the thread's count and last Message. The Message id
|
|
1423
|
+
* derives from the sequence number, so a create that loses a race for the number collides and retries with the next one.
|
|
1424
|
+
*/
|
|
1425
|
+
async function appendMessageToThread(thread: MessageThreadRecord, build: (sequenceNumber: number, now: string) => MessageRecord): Promise<{ thread: MessageThreadRecord; message: MessageRecord }> {
|
|
1426
|
+
const threadId = thread.id;
|
|
1427
|
+
let now = new Date().toISOString();
|
|
1428
|
+
let built: MessageRecord | null = null;
|
|
1429
|
+
const { created } = await createWithSequenceRetry({
|
|
1430
|
+
list: () => listMessagesByThreadId(threadId, 1000),
|
|
1431
|
+
create: (sequenceNumber) => {
|
|
1432
|
+
now = new Date().toISOString();
|
|
1433
|
+
built = build(sequenceNumber, now);
|
|
1434
|
+
return runGraphql<{ createMessage?: MessageRecord | null }>(CREATE_MESSAGE_MUTATION, { input: built });
|
|
1435
|
+
},
|
|
1436
|
+
});
|
|
1437
|
+
const createdMessage = normalizeMessageRecord(created.createMessage ?? built!);
|
|
1438
|
+
const updated = await runGraphql<{ updateMessageThread?: MessageThreadRecord | null }>(
|
|
1439
|
+
UPDATE_MESSAGE_THREAD_MUTATION,
|
|
1440
|
+
{
|
|
1441
|
+
input: {
|
|
1442
|
+
id: threadId,
|
|
1443
|
+
messageCount: (thread.messageCount ?? 0) + 1,
|
|
1444
|
+
lastMessageId: createdMessage.id,
|
|
1445
|
+
lastMessageAt: createdMessage.createdAt,
|
|
1446
|
+
updatedAt: now,
|
|
1447
|
+
},
|
|
1448
|
+
},
|
|
1449
|
+
);
|
|
1450
|
+
return {
|
|
1451
|
+
thread: normalizeThreadRecord(updated.updateMessageThread ?? { ...thread, updatedAt: now }),
|
|
1452
|
+
message: createdMessage,
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1389
1456
|
export async function appendForumThreadMessageRecord(input: {
|
|
1390
1457
|
threadId: string;
|
|
1391
1458
|
summary: string;
|
|
@@ -1402,11 +1469,8 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1402
1469
|
if (!summary) throw new Error("summary is required");
|
|
1403
1470
|
if (!content) throw new Error("content is required");
|
|
1404
1471
|
const thread = await getMessageThreadRecord(threadId);
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
const now = new Date().toISOString();
|
|
1408
|
-
const message: MessageRecord = {
|
|
1409
|
-
id: `message-forum-${safeForumId(threadId)}-${String(nextSequence).padStart(4, "0")}`,
|
|
1472
|
+
return appendMessageToThread(thread, (sequenceNumber, now) => ({
|
|
1473
|
+
id: `message-forum-${safeForumId(threadId)}-${String(sequenceNumber).padStart(4, "0")}`,
|
|
1410
1474
|
messageKind: "forum_post",
|
|
1411
1475
|
messageDomain: "edition_coordination",
|
|
1412
1476
|
status: "active",
|
|
@@ -1415,7 +1479,7 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1415
1479
|
authorLabel: input.authorLabel?.trim() || "editor",
|
|
1416
1480
|
threadId,
|
|
1417
1481
|
parentMessageId: input.parentMessageId?.trim() || null,
|
|
1418
|
-
sequenceNumber
|
|
1482
|
+
sequenceNumber,
|
|
1419
1483
|
role: input.role || "human",
|
|
1420
1484
|
messageType: "forum_message",
|
|
1421
1485
|
content,
|
|
@@ -1423,30 +1487,184 @@ export async function appendForumThreadMessageRecord(input: {
|
|
|
1423
1487
|
createdAt: now,
|
|
1424
1488
|
updatedAt: now,
|
|
1425
1489
|
newsroomFeedKey: "messages",
|
|
1426
|
-
};
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
const
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1490
|
+
}));
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
type ConversationAuthor = { sub: string | null; label: string };
|
|
1494
|
+
|
|
1495
|
+
async function currentConversationAuthor(): Promise<ConversationAuthor> {
|
|
1496
|
+
const snapshot = await loadReaderSessionSnapshot();
|
|
1497
|
+
let sub: string | null = null;
|
|
1498
|
+
try {
|
|
1499
|
+
const session = await fetchAuthSession();
|
|
1500
|
+
const claim = session.tokens?.idToken?.payload?.sub ?? session.tokens?.accessToken?.payload?.sub;
|
|
1501
|
+
sub = typeof claim === "string" && claim ? claim : null;
|
|
1502
|
+
} catch {
|
|
1503
|
+
sub = null;
|
|
1504
|
+
}
|
|
1505
|
+
return { sub, label: snapshot.auth.status === "signedIn" ? snapshot.auth.label : "editor" };
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
async function findConversationThread(anchorKey: string): Promise<MessageThreadRecord | null> {
|
|
1509
|
+
type Response = { listMessageThreadsByAnchorAndUpdatedAt?: { items?: Array<MessageThreadRecord | null> | null } | null };
|
|
1510
|
+
const response = await runGraphql<Response>(LIST_MESSAGE_THREADS_BY_ANCHOR_AND_UPDATED_AT_QUERY, {
|
|
1511
|
+
primaryAnchorKey: anchorKey,
|
|
1512
|
+
sortDirection: "DESC",
|
|
1513
|
+
limit: 10,
|
|
1514
|
+
});
|
|
1515
|
+
const found = (response.listMessageThreadsByAnchorAndUpdatedAt?.items ?? []).filter(Boolean) as MessageThreadRecord[];
|
|
1516
|
+
const thread = found.find((entry) => entry.status !== "deleted") ?? null;
|
|
1517
|
+
return thread ? normalizeThreadRecord(thread) : null;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** The one conversation for an anchor: found by its anchor key, created when there is none. Concurrent first posts share the thread. */
|
|
1521
|
+
export async function ensureConversationThread(anchor: ConversationAnchor, author?: ConversationAuthor): Promise<MessageThreadRecord> {
|
|
1522
|
+
const anchorKey = conversationAnchorKey(anchor);
|
|
1523
|
+
if (!anchorKey) throw new Error("An unattached conversation starts its own thread.");
|
|
1524
|
+
return findOrCreateThread({
|
|
1525
|
+
find: () => findConversationThread(anchorKey),
|
|
1526
|
+
create: async () => {
|
|
1527
|
+
const now = new Date().toISOString();
|
|
1528
|
+
const lineageId = anchor.lineageId!.trim();
|
|
1529
|
+
const thread: MessageThreadRecord = {
|
|
1530
|
+
id: conversationThreadId(anchor),
|
|
1531
|
+
threadKind: conversationThreadKind(anchor),
|
|
1532
|
+
status: "active",
|
|
1533
|
+
title: anchor.title?.trim() || lineageId,
|
|
1534
|
+
primaryAnchorKind: anchor.kind,
|
|
1535
|
+
primaryAnchorId: lineageId,
|
|
1536
|
+
primaryAnchorLineageId: lineageId,
|
|
1537
|
+
primaryAnchorKey: anchorKey,
|
|
1538
|
+
createdBySub: author?.sub ?? null,
|
|
1539
|
+
createdByLabel: author?.label ?? "editor",
|
|
1540
|
+
messageCount: 0,
|
|
1541
|
+
lastMessageId: null,
|
|
1542
|
+
lastMessageAt: null,
|
|
1543
|
+
metadata: JSON.stringify({ anchorKind: anchor.kind, anchorLineageId: lineageId }),
|
|
1544
|
+
createdAt: now,
|
|
1440
1545
|
updatedAt: now,
|
|
1441
|
-
|
|
1546
|
+
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1547
|
+
};
|
|
1548
|
+
const response = await runGraphql<{ createMessageThread?: MessageThreadRecord | null }>(CREATE_MESSAGE_THREAD_MUTATION, { input: thread });
|
|
1549
|
+
return normalizeThreadRecord(response.createMessageThread ?? thread);
|
|
1442
1550
|
},
|
|
1443
|
-
);
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
function toConversationMessage(record: MessageRecord): ConversationMessage {
|
|
1444
1555
|
return {
|
|
1445
|
-
|
|
1446
|
-
|
|
1556
|
+
id: record.id,
|
|
1557
|
+
parentMessageId: record.parentMessageId ?? null,
|
|
1558
|
+
authorLabel: record.authorLabel ?? "",
|
|
1559
|
+
content: record.content ?? record.body ?? "",
|
|
1560
|
+
createdAt: record.createdAt,
|
|
1561
|
+
sequenceNumber: Number(record.sequenceNumber ?? 0),
|
|
1447
1562
|
};
|
|
1448
1563
|
}
|
|
1449
1564
|
|
|
1565
|
+
/** Every Message in the anchor's conversation, oldest first. An anchor with no conversation yet has none. */
|
|
1566
|
+
export async function loadConversationMessages(anchor: ConversationAnchor): Promise<ConversationMessage[]> {
|
|
1567
|
+
const anchorKey = conversationAnchorKey(anchor);
|
|
1568
|
+
if (!anchorKey) return [];
|
|
1569
|
+
const thread = await findConversationThread(anchorKey);
|
|
1570
|
+
if (!thread) return [];
|
|
1571
|
+
const records = await listMessagesByThreadId(thread.id, 500);
|
|
1572
|
+
return records.filter((record) => record.status !== "deleted").map(toConversationMessage);
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
/** How many Messages each Reference has, from the Reference conversation threads alone (no query per card). Keyed by lineage id. */
|
|
1576
|
+
export async function loadReferenceConversationCounts(): Promise<Map<string, number>> {
|
|
1577
|
+
const threads = await listMessageThreadsByKind(REFERENCE_CONVERSATION_KIND, 500);
|
|
1578
|
+
const counts = new Map<string, number>();
|
|
1579
|
+
for (const thread of threads) {
|
|
1580
|
+
const lineageId = thread.primaryAnchorLineageId;
|
|
1581
|
+
if (lineageId && thread.status !== "deleted" && (thread.messageCount ?? 0) > 0) counts.set(lineageId, thread.messageCount ?? 0);
|
|
1582
|
+
}
|
|
1583
|
+
return counts;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/**
|
|
1587
|
+
* Writes a Message into the anchor's conversation (finding or creating the thread), and a `comment` relation from the Message to
|
|
1588
|
+
* the Reference or Item so the thing's own detail can find it. The relation is secondary: if it fails the Message is still saved.
|
|
1589
|
+
*/
|
|
1590
|
+
export async function appendConversationMessage(input: {
|
|
1591
|
+
anchor: ConversationAnchor;
|
|
1592
|
+
body: string;
|
|
1593
|
+
parentMessageId?: string | null;
|
|
1594
|
+
}): Promise<{ message: ConversationMessage; thread: MessageThreadRecord; relationError: string | null }> {
|
|
1595
|
+
const content = String(input.body || "").trim();
|
|
1596
|
+
if (!content) throw new Error("Write something to post.");
|
|
1597
|
+
const author = await currentConversationAuthor();
|
|
1598
|
+
const thread = await ensureConversationThread(input.anchor, author);
|
|
1599
|
+
const result = await appendMessageToThread(thread, (sequenceNumber, now) => ({
|
|
1600
|
+
id: conversationMessageId(thread.id, sequenceNumber),
|
|
1601
|
+
messageKind: "comment",
|
|
1602
|
+
messageDomain: "commentary",
|
|
1603
|
+
status: "active",
|
|
1604
|
+
summary: content.slice(0, 120),
|
|
1605
|
+
source: "newsroom.conversation",
|
|
1606
|
+
authorSub: author.sub,
|
|
1607
|
+
authorLabel: author.label,
|
|
1608
|
+
threadId: thread.id,
|
|
1609
|
+
parentMessageId: input.parentMessageId?.trim() || null,
|
|
1610
|
+
sequenceNumber,
|
|
1611
|
+
role: "human",
|
|
1612
|
+
messageType: "comment",
|
|
1613
|
+
content,
|
|
1614
|
+
metadata: JSON.stringify({ messageKind: "comment", anchorKey: conversationAnchorKey(input.anchor) }),
|
|
1615
|
+
createdAt: now,
|
|
1616
|
+
updatedAt: now,
|
|
1617
|
+
newsroomFeedKey: CONVERSATION_FEED_KEY,
|
|
1618
|
+
}));
|
|
1619
|
+
let relationError: string | null = null;
|
|
1620
|
+
if (input.anchor.kind !== "none") {
|
|
1621
|
+
try {
|
|
1622
|
+
await createCommentRelation(result.message, input.anchor);
|
|
1623
|
+
} catch (error) {
|
|
1624
|
+
relationError = error instanceof Error ? error.message : "The link to the Reference could not be saved.";
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
return { message: toConversationMessage(result.message), thread: result.thread, relationError };
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
async function createCommentRelation(message: MessageRecord, anchor: ConversationAnchor): Promise<void> {
|
|
1631
|
+
if (anchor.kind === "none") return;
|
|
1632
|
+
const lineageId = anchor.lineageId.trim();
|
|
1633
|
+
const objectId = anchor.id?.trim() || lineageId;
|
|
1634
|
+
const now = message.createdAt;
|
|
1635
|
+
await runGraphql(CREATE_SEMANTIC_RELATION_MUTATION, {
|
|
1636
|
+
input: {
|
|
1637
|
+
id: `semantic-relation-${message.id}-comment`,
|
|
1638
|
+
relationState: "current",
|
|
1639
|
+
predicate: "comment",
|
|
1640
|
+
relationTypeId: "semantic-relation-type-comment",
|
|
1641
|
+
relationTypeKey: "comment",
|
|
1642
|
+
relationDomain: "commentary",
|
|
1643
|
+
subjectKind: "message",
|
|
1644
|
+
subjectId: message.id,
|
|
1645
|
+
subjectLineageId: message.id,
|
|
1646
|
+
subjectVersionNumber: 1,
|
|
1647
|
+
objectKind: anchor.kind,
|
|
1648
|
+
objectId,
|
|
1649
|
+
objectLineageId: lineageId,
|
|
1650
|
+
subjectStateKey: semanticStateKey("message", message.id),
|
|
1651
|
+
objectStateKey: semanticStateKey(anchor.kind, lineageId),
|
|
1652
|
+
objectSubjectStateKey: `${semanticStateKey(anchor.kind, lineageId)}#message`,
|
|
1653
|
+
predicateObjectStateKey: `comment#${semanticStateKey(anchor.kind, lineageId)}`,
|
|
1654
|
+
subjectVersionKey: semanticVersionKey("message", message.id),
|
|
1655
|
+
objectVersionKey: semanticVersionKey(anchor.kind, objectId),
|
|
1656
|
+
score: 1,
|
|
1657
|
+
rank: 1,
|
|
1658
|
+
reviewRecommended: false,
|
|
1659
|
+
importedAt: now,
|
|
1660
|
+
createdAt: now,
|
|
1661
|
+
updatedAt: now,
|
|
1662
|
+
newsroomFeedKey: "semanticRelations",
|
|
1663
|
+
metadata: JSON.stringify({ kind: "conversation.comment", messageKind: "comment" }),
|
|
1664
|
+
},
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1450
1668
|
export async function deleteForumThreadMessageRecord(input: {
|
|
1451
1669
|
threadId: string;
|
|
1452
1670
|
messageId: string;
|
|
@@ -1650,22 +1868,26 @@ export async function loadEditorReferencesData(): Promise<{
|
|
|
1650
1868
|
referenceAttachments,
|
|
1651
1869
|
};
|
|
1652
1870
|
}
|
|
1653
|
-
const referencePage = await loadNewsroomReferencePage();
|
|
1654
1871
|
return {
|
|
1655
|
-
references:
|
|
1872
|
+
references: await loadAllNewsroomReferences(),
|
|
1656
1873
|
referenceAttachments,
|
|
1657
1874
|
};
|
|
1658
1875
|
}
|
|
1659
|
-
const [
|
|
1660
|
-
|
|
1876
|
+
const [references, referenceAttachments] = await Promise.all([
|
|
1877
|
+
loadAllNewsroomReferences(),
|
|
1661
1878
|
listUserPoolModel<ReferenceAttachmentRecord>("ReferenceAttachment"),
|
|
1662
1879
|
]);
|
|
1663
1880
|
return {
|
|
1664
|
-
references
|
|
1881
|
+
references,
|
|
1665
1882
|
referenceAttachments: referenceAttachments.sort((left, right) => left.sortKey.localeCompare(right.sortKey)),
|
|
1666
1883
|
};
|
|
1667
1884
|
}
|
|
1668
1885
|
|
|
1886
|
+
/** Every reference, page by page: the References feed ranks the whole set, so stopping at the first page hid most of it. */
|
|
1887
|
+
function loadAllNewsroomReferences(): Promise<ReferenceRecord[]> {
|
|
1888
|
+
return loadAllPages((nextToken) => loadNewsroomReferencePage({ nextToken }));
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1669
1891
|
export function normalizeNewsroomReferencePageOrder(value?: string | null): NewsroomReferencePageOrder {
|
|
1670
1892
|
return value?.trim() === "imported" ? "imported" : "published";
|
|
1671
1893
|
}
|
|
@@ -1,44 +1,36 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { newsroomHref } from "../lib/newsroom-base-path";
|
|
4
3
|
import Link from "next/link";
|
|
5
4
|
import { SITE_BRAND } from "../lib/site-brand";
|
|
6
5
|
import type { NewsDeskShellState } from "../lib/news-desk-session";
|
|
7
6
|
import { cn } from "../lib/utils";
|
|
8
7
|
import { NewsroomOpsShell } from "./newsroom-ops-shell";
|
|
9
|
-
import {
|
|
8
|
+
import { NotificationSkeleton } from "./notification-cards";
|
|
10
9
|
import { ReaderAuthControl } from "./reader-auth-control";
|
|
11
10
|
import { buttonVariants } from "./ui/button";
|
|
11
|
+
import { ReferencesPageLoading } from "./references-page-loading";
|
|
12
12
|
|
|
13
|
-
export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
|
|
13
|
+
export function NewsDeskAccessGate({ initialTab, shell, showSectionTabs = false }: { initialTab?: string; shell: NewsDeskShellState | null; showSectionTabs?: boolean }) {
|
|
14
14
|
const accessPhase = shell?.phase ?? "checkingAccess";
|
|
15
15
|
const pending = accessPhase === "checkingAccess" || accessPhase === "loadingDesk";
|
|
16
|
+
const referencesPage = initialTab === "references";
|
|
16
17
|
|
|
17
18
|
return (
|
|
18
19
|
<NewsroomOpsShell
|
|
19
|
-
activeTab="overview"
|
|
20
|
+
activeTab={referencesPage ? "references" : "overview"}
|
|
20
21
|
appTitle={SITE_BRAND.appTitle}
|
|
21
22
|
backHref="/"
|
|
22
23
|
backLabel={SITE_BRAND.backToHomeLabel}
|
|
23
24
|
headerActions={(
|
|
24
|
-
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }))} href="/settings">Settings</Link>
|
|
25
|
+
<Link className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "organizer-header-settings")} href="/settings">Settings</Link>
|
|
25
26
|
)}
|
|
26
|
-
pageTitle={showSectionTabs ? "Notification Center" : "Newsroom"}
|
|
27
|
-
showNavigation={showSectionTabs}
|
|
27
|
+
pageTitle={referencesPage ? "References" : showSectionTabs ? "Notification Center" : "Newsroom"}
|
|
28
|
+
showNavigation={showSectionTabs || referencesPage}
|
|
28
29
|
>
|
|
29
|
-
{
|
|
30
|
-
|
|
31
|
-
<div className="h-6 w-48 rounded bg-muted" />
|
|
32
|
-
<div className="h-4 w-3/4 rounded bg-muted" />
|
|
33
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
34
|
-
{Array.from({ length: 4 }, (_, index) => <div className="h-32 rounded-xl border bg-card p-4" key={index}>
|
|
35
|
-
<div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
|
|
36
|
-
</div>)}
|
|
37
|
-
</div>
|
|
38
|
-
</div>
|
|
39
|
-
)) : (
|
|
30
|
+
{/* The loading state is the feed's own skeleton, so what loads replaces it in place. */}
|
|
31
|
+
{pending && referencesPage ? <ReferencesPageLoading /> : pending ? <NotificationSkeleton bare={!showSectionTabs} count={4} /> : (
|
|
40
32
|
<section aria-live="polite" data-news-desk-access={accessPhase} data-news-desk-access-phase={accessPhase}>
|
|
41
|
-
<div className="space-y-3 rounded-xl
|
|
33
|
+
<div className="space-y-3 rounded-xl bg-card p-6">
|
|
42
34
|
<p className="m-0 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Access</p>
|
|
43
35
|
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight">{formatAccessTitle(shell)}</h2>
|
|
44
36
|
<p className="m-0 text-sm text-muted-foreground">{formatAccessDetail(shell)}</p>
|
|
@@ -46,7 +38,8 @@ export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell:
|
|
|
46
38
|
<p className="m-0 text-sm text-foreground">{formatAccessActionDetail(shell)}</p>
|
|
47
39
|
{shell?.phase === "signedOut" || shell?.phase === "error" ? (
|
|
48
40
|
<div className="news-desk-access-panel__auth">
|
|
49
|
-
|
|
41
|
+
{/* No fixed return path: sign-in comes back to the page that asked, so a deep link survives it. */}
|
|
42
|
+
<ReaderAuthControl showIdentity />
|
|
50
43
|
</div>
|
|
51
44
|
) : null}
|
|
52
45
|
</div>
|