@anthusai/papyrus 5.47.0 → 5.48.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.
- package/components/needs-me-bodies.tsx +290 -0
- package/components/needs-me-detail.tsx +23 -0
- package/components/needs-me-feed.tsx +561 -0
- package/components/needs-me-reference-detail.tsx +77 -0
- package/components/needs-me-shared.tsx +39 -0
- package/components/newsroom-ops-overview.tsx +10 -9
- package/components/newsroom-organizer.css +7 -0
- package/components/newsroom-references-view.tsx +2 -110
- package/components/notification-cards.tsx +14 -9
- package/components/notification-center.tsx +145 -304
- package/components/reference-capture-panel.tsx +2 -2
- package/components/reference-feed.tsx +1 -1
- package/components/topic-steering-workspace.tsx +2 -1
- package/components/use-media-query.ts +16 -0
- package/components/use-reference-preview-attachments.ts +101 -0
- package/components/use-reference-ratings.ts +6 -4
- package/lib/needs-me-items.ts +289 -0
- package/lib/needs-me-ranking.ts +315 -0
- package/lib/notification-feed.ts +0 -38
- package/lib/reference-preview.ts +2 -2
- package/lib/review-client.ts +17 -4
- package/lib/review-drafts.ts +16 -0
- package/lib/review-fixture.ts +20 -0
- package/package.json +10 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CheckIcon, CircleCheckIcon, CircleXIcon, ExternalLinkIcon, FileSearchIcon, Loader2Icon, LockIcon, MailIcon } from "lucide-react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import type { ReferenceAttachmentRecord } from "../lib/category-repository";
|
|
8
|
+
import { QUARANTINE_LABELS, type MonitoringEmailDetails } from "../lib/monitoring-overview";
|
|
9
|
+
import { sourceHost, requestCaption } from "../lib/notification-feed";
|
|
10
|
+
import { NEEDS_ME_TYPE_LABELS } from "../lib/needs-me-ranking";
|
|
11
|
+
import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
12
|
+
import { answerWords, formatWhen, nextText, steeringText, subjectHref, subjectLabel } from "../lib/review-requests";
|
|
13
|
+
import { DecidedLine } from "./notification-cards";
|
|
14
|
+
import { SourceMark } from "./reference-feed";
|
|
15
|
+
import { RatingGrid } from "./review-rating-grid";
|
|
16
|
+
import type { RatingController } from "./use-reference-ratings";
|
|
17
|
+
import { FlagFooter, decidedChoiceOf } from "./needs-me-shared";
|
|
18
|
+
|
|
19
|
+
import type { CorrectCaptureAction } from "./reference-capture-client";
|
|
20
|
+
/** What the editor typed before deciding an item that is not a Reference: the optional why, and the one steering chip. */
|
|
21
|
+
export type DecisionExtras = { why: string; notThisKind: boolean };
|
|
22
|
+
|
|
23
|
+
const CHIP = "inline-flex min-h-8 max-w-full items-center gap-1.5 rounded-full bg-[var(--feed-control)] px-2.5 text-xs font-medium text-foreground outline-none transition-colors hover:bg-[var(--feed-control)]/70 focus-visible:ring-2 focus-visible:ring-ring";
|
|
24
|
+
const PILL_TOGGLE = "inline-flex min-h-8 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
|
|
25
|
+
|
|
26
|
+
export type DetailProps = {
|
|
27
|
+
item: NeedsMeEntry;
|
|
28
|
+
ratings: RatingController;
|
|
29
|
+
extras: DecisionExtras;
|
|
30
|
+
onExtras: (next: DecisionExtras) => void;
|
|
31
|
+
disabledReason: string | null;
|
|
32
|
+
attachments: ReferenceAttachmentRecord[];
|
|
33
|
+
captureTeaserLine?: number;
|
|
34
|
+
demo: boolean;
|
|
35
|
+
predictionLoading: boolean;
|
|
36
|
+
onCorrectCapture?: CorrectCaptureAction;
|
|
37
|
+
loadEmail?: (receiptId: string) => Promise<MonitoringEmailDetails>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function Heading({ item }: { item: NeedsMeEntry }) {
|
|
41
|
+
return (
|
|
42
|
+
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold" data-needs-me-detail-badges>
|
|
43
|
+
<span className="inline-flex items-center rounded-full bg-[var(--feed-control)] px-2.5 py-1">{NEEDS_ME_TYPE_LABELS[item.type]}</span>
|
|
44
|
+
{item.hardGate ? <span className="inline-flex items-center gap-1 rounded-full bg-[var(--feed-control)] px-2.5 py-1"><LockIcon aria-hidden="true" className="size-3.5" />Hard gate: waiting for you</span> : null}
|
|
45
|
+
{item.decisionState !== "open" ? <span className="inline-flex items-center gap-1 rounded-full bg-[var(--feed-control)] px-2.5 py-1" data-needs-me-detail-state>{item.decisionState === "rejected" ? <CircleXIcon aria-hidden="true" className="size-3.5" /> : <CircleCheckIcon aria-hidden="true" className="size-3.5" />}{item.decisionState === "accepted" ? "Accepted" : item.decisionState === "rejected" ? "Rejected" : "Acknowledged"}</span> : null}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What was recorded when the item was decided, for the line under the buttons. */
|
|
51
|
+
function recordedAnswer(item: NeedsMeEntry): { why: string | null; notThisKind: boolean } {
|
|
52
|
+
const { payload } = item;
|
|
53
|
+
if (payload.kind === "draft") return { why: payload.item.decision?.why ?? null, notThisKind: Boolean(payload.item.decision?.notThisKind) };
|
|
54
|
+
if (payload.kind === "request") return { why: payload.request.answer?.feedback ?? null, notThisKind: payload.request.answer?.steer === "not_this_kind" };
|
|
55
|
+
if (payload.kind === "email") return { why: payload.summary.quarantine?.decision?.reason || null, notThisKind: false };
|
|
56
|
+
return { why: null, notThisKind: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function chipLabel(item: NeedsMeEntry): string | null {
|
|
60
|
+
return item.type === "draft" ? "Not this kind of story" : item.type === "stage_review" ? "Not this kind of thing" : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Accept / Reject for everything that is not a Reference: an optional why (read when you tap), one steering chip on Reject, then the shared grid. */
|
|
64
|
+
export function DecisionPanel({ item, ratings, extras, onExtras, disabledReason, lockedNote }: Pick<DetailProps, "item" | "ratings" | "extras" | "onExtras" | "disabledReason"> & { lockedNote?: ReactNode }) {
|
|
65
|
+
const rating = ratings.get(item.id);
|
|
66
|
+
const decided = decidedChoiceOf(item);
|
|
67
|
+
const locked = item.decisionState !== "open";
|
|
68
|
+
const busy = rating.feedback.phase === "saving";
|
|
69
|
+
const flag = item.type === "flag";
|
|
70
|
+
const chip = chipLabel(item);
|
|
71
|
+
const recorded = recordedAnswer(item);
|
|
72
|
+
|
|
73
|
+
// Desktop shortcuts: A accepts, R rejects. Never while typing, and never once decided.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (locked || flag) return undefined;
|
|
76
|
+
function onKey(event: KeyboardEvent) {
|
|
77
|
+
const target = event.target as HTMLElement | null;
|
|
78
|
+
if (target && (target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) return;
|
|
79
|
+
if (event.metaKey || event.ctrlKey || event.altKey || disabledReason) return;
|
|
80
|
+
if (event.key === "a" || event.key === "A") ratings.choose(item.id, "accept");
|
|
81
|
+
if (event.key === "r" || event.key === "R") ratings.choose(item.id, "reject");
|
|
82
|
+
}
|
|
83
|
+
window.addEventListener("keydown", onKey);
|
|
84
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<section aria-label="Your decision" className="space-y-3 rounded-[20px] bg-[var(--feed-footer)] p-3" data-needs-me-decision {...(locked ? {} : { "data-review-controls": "" })}>
|
|
89
|
+
{locked ? (
|
|
90
|
+
<>
|
|
91
|
+
{item.decisionState === "acknowledged"
|
|
92
|
+
? <p className="flex items-center gap-1.5 text-sm font-medium" data-flag-acknowledged><CircleCheckIcon aria-hidden="true" className="size-4" />Acknowledged{recorded.why ? <span className="font-normal text-muted-foreground">: {recorded.why}</span> : null}</p>
|
|
93
|
+
: <DecidedLine choice={decided ?? "accept"} notThisKind={recorded.notThisKind} why={recorded.why} />}
|
|
94
|
+
{flag ? null : <RatingGrid answer={decided} disabled feedback={{ phase: "saved" }} onChoose={() => undefined} prediction={{ state: "none" }} showPrediction={false} size="large" surface="footer" />}
|
|
95
|
+
{lockedNote ? <p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p> : null}
|
|
96
|
+
</>
|
|
97
|
+
) : (
|
|
98
|
+
<>
|
|
99
|
+
<textarea
|
|
100
|
+
aria-label="Why? (optional)"
|
|
101
|
+
className="block min-h-20 w-full resize-y rounded-[16px] bg-[var(--feed-control)] p-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
102
|
+
data-needs-me-why
|
|
103
|
+
onChange={(event) => onExtras({ ...extras, why: event.target.value })}
|
|
104
|
+
placeholder={flag ? "Why? Optional. Tell the step what to do differently." : "Why? Optional. Your note teaches the AI."}
|
|
105
|
+
readOnly={busy}
|
|
106
|
+
value={extras.why}
|
|
107
|
+
/>
|
|
108
|
+
{chip && !flag ? (
|
|
109
|
+
<div className="flex flex-wrap items-center gap-2" data-review-reasons>
|
|
110
|
+
<button
|
|
111
|
+
aria-pressed={extras.notThisKind}
|
|
112
|
+
className={cn(PILL_TOGGLE, extras.notThisKind ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]/60 text-muted-foreground hover:text-foreground")}
|
|
113
|
+
data-review-reason="not_this_kind"
|
|
114
|
+
onClick={() => onExtras({ ...extras, notThisKind: !extras.notThisKind })}
|
|
115
|
+
type="button"
|
|
116
|
+
>
|
|
117
|
+
{extras.notThisKind ? <CheckIcon aria-hidden="true" className="size-3.5" /> : null}
|
|
118
|
+
{chip}
|
|
119
|
+
</button>
|
|
120
|
+
<span className="text-xs text-muted-foreground">Optional. Counts if you reject.</span>
|
|
121
|
+
</div>
|
|
122
|
+
) : null}
|
|
123
|
+
{flag ? (
|
|
124
|
+
<FlagFooter busy={busy} disabledReason={disabledReason} done={false} error={rating.feedback.phase === "error" ? rating.feedback.message ?? null : null} onAcknowledge={() => ratings.choose(item.id, "accept")} />
|
|
125
|
+
) : (
|
|
126
|
+
<RatingGrid
|
|
127
|
+
answer={rating.pending}
|
|
128
|
+
disabled={Boolean(disabledReason)}
|
|
129
|
+
disabledReason={disabledReason}
|
|
130
|
+
feedback={rating.feedback}
|
|
131
|
+
onChoose={(choice) => { if (!disabledReason) ratings.choose(item.id, choice); }}
|
|
132
|
+
prediction={{ state: "none" }}
|
|
133
|
+
showPrediction={false}
|
|
134
|
+
size="large"
|
|
135
|
+
surface="footer"
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
</>
|
|
139
|
+
)}
|
|
140
|
+
</section>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function DefaultOutcome({ item }: { item: NeedsMeEntry }) {
|
|
145
|
+
return item.decisionState === "open" ? <p className="text-sm text-muted-foreground" data-needs-me-default-detail><strong className="font-semibold text-foreground">If you leave it:</strong> {item.defaultOutcome.replace(/^If you leave it,? ?/i, "").replace(/^./, (letter) => letter.toLowerCase())}</p> : null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// --- one body per type -----------------------------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
export function DraftBody(props: DetailProps & { payload: Extract<NeedsMeEntry["payload"], { kind: "draft" }> }) {
|
|
151
|
+
const { item, payload } = props;
|
|
152
|
+
const { item: draft } = payload;
|
|
153
|
+
const view = draft.view;
|
|
154
|
+
if (!view) {
|
|
155
|
+
return <p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Loading the draft…</p>;
|
|
156
|
+
}
|
|
157
|
+
return (
|
|
158
|
+
<div className="space-y-4" data-draft-detail={draft.draftId}>
|
|
159
|
+
<header className="space-y-1.5">
|
|
160
|
+
<p className="text-xs font-medium text-muted-foreground">AI {view.kindLabel.toLowerCase()}{view.section ? ` · Section: ${view.section}` : ""}</p>
|
|
161
|
+
<h2 className="break-words text-xl font-bold leading-tight tracking-tight text-foreground" data-draft-headline>{view.headline}</h2>
|
|
162
|
+
{view.dek && !view.bodyText.startsWith(view.dek.replace(/\s*\[\d+\]$/, "").slice(0, 60)) ? <p className="break-words text-base text-muted-foreground" data-draft-dek>{view.dek}</p> : null}
|
|
163
|
+
</header>
|
|
164
|
+
{view.bodyText ? <div className="max-h-96 space-y-3 overflow-y-auto whitespace-pre-wrap break-words text-sm leading-relaxed" data-draft-body tabIndex={0}>{view.bodyText}</div> : null}
|
|
165
|
+
{view.evidence.length ? (
|
|
166
|
+
<p aria-label="Evidence" className="flex items-start gap-1.5 break-words text-xs" data-draft-evidence>
|
|
167
|
+
<FileSearchIcon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
|
168
|
+
<span><span className="font-semibold">Evidence:</span> {view.evidence.join(" ")}</span>
|
|
169
|
+
</p>
|
|
170
|
+
) : null}
|
|
171
|
+
{view.sources.length ? (
|
|
172
|
+
<section aria-label="Citations" className="space-y-2">
|
|
173
|
+
<h3 className="text-sm font-medium text-foreground">Citations</h3>
|
|
174
|
+
<ul className="flex flex-wrap gap-1.5" data-draft-sources>
|
|
175
|
+
{view.sources.map((source, index) => {
|
|
176
|
+
const host = source.url ? sourceHost(source.url) : null;
|
|
177
|
+
return (
|
|
178
|
+
<li className="min-w-0 max-w-full" key={`${source.title}-${index}`}>
|
|
179
|
+
{source.url ? (
|
|
180
|
+
<a aria-label={`Source: ${source.title}${source.date ? ` (${source.date})` : ""}`} className={CHIP} data-draft-source href={source.url} rel="noreferrer" target="_blank" title={source.title}>
|
|
181
|
+
<SourceMark host={host} /><span className="truncate">{host ?? source.title}</span><ExternalLinkIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
|
|
182
|
+
</a>
|
|
183
|
+
) : <span className={CHIP} data-draft-source><span className="truncate">{source.title}</span></span>}
|
|
184
|
+
</li>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
{view.moreSources ? <li className="self-center text-xs text-muted-foreground">and {view.moreSources} more in the full draft</li> : null}
|
|
188
|
+
</ul>
|
|
189
|
+
</section>
|
|
190
|
+
) : null}
|
|
191
|
+
<p className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
|
192
|
+
<span data-draft-caption>{requestCaption(draft.request)}</span>
|
|
193
|
+
<Link className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline" data-draft-open href={view.href}>Open full draft<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" /></Link>
|
|
194
|
+
</p>
|
|
195
|
+
<DefaultOutcome item={item} />
|
|
196
|
+
<DecisionPanel {...props} lockedNote={<>To change it, <Link className="text-primary underline underline-offset-4" href={view.href}>open the draft in Proposals</Link>.</>} />
|
|
197
|
+
</div>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function RequestBody(props: DetailProps & { payload: Extract<NeedsMeEntry["payload"], { kind: "request" }> }) {
|
|
202
|
+
const { item, payload, demo } = props;
|
|
203
|
+
const { request } = payload;
|
|
204
|
+
const href = request.subject.type === "batch" ? null : subjectHref(request.subject, demo);
|
|
205
|
+
const label = request.subject.type === "batch" ? null : subjectLabel(request.subject, payload.storyTitle ? { [request.subject.id]: payload.storyTitle } : {}, payload.storyDeleted ? new Set([request.subject.id]) : new Set());
|
|
206
|
+
const steering = steeringText(request);
|
|
207
|
+
return (
|
|
208
|
+
<div className="space-y-4" data-review-mode={request.mode} data-review-request={request.id}>
|
|
209
|
+
<header className="space-y-1.5">
|
|
210
|
+
<h2 className="break-words text-xl font-bold leading-tight tracking-tight text-foreground">{request.reason}</h2>
|
|
211
|
+
{request.ask ? <p className="break-words text-base text-muted-foreground" data-review-ask>The question: {request.ask}</p> : null}
|
|
212
|
+
</header>
|
|
213
|
+
{label ? <p className="break-words text-sm" data-review-about>About: {href ? <Link className="text-primary underline underline-offset-4" href={href}>{label}</Link> : label}</p> : null}
|
|
214
|
+
{payload.excerpt ? (
|
|
215
|
+
<section aria-label="The story" className="space-y-1.5" data-story-excerpt>
|
|
216
|
+
<h3 className="text-sm font-medium text-foreground">The story{payload.storyTitle ? `: ${payload.storyTitle}` : ""}</h3>
|
|
217
|
+
<div className="max-h-72 overflow-y-auto whitespace-pre-wrap break-words rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" tabIndex={0}>{payload.excerpt}</div>
|
|
218
|
+
</section>
|
|
219
|
+
) : null}
|
|
220
|
+
<p className="text-sm" data-review-next>{nextText(request)}</p>
|
|
221
|
+
{steering ? <p className="text-sm" data-review-steering>{steering}</p> : null}
|
|
222
|
+
<p className="text-xs text-muted-foreground" data-draft-caption>{requestCaption(request)}{request.state === "answered" && request.answer ? ` · answered ${formatWhen(request.answer.at)}` : ""}</p>
|
|
223
|
+
<DecisionPanel {...props} lockedNote={request.answer ? `${answerWords(request.answer, request)} by ${request.answer.by} on ${formatWhen(request.answer.at)}.` : undefined} />
|
|
224
|
+
{item.type === "flag" ? null : <DefaultOutcome item={item} />}
|
|
225
|
+
</div>
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A held email is untrusted: its text is shown as text, its links as words, and nothing in it is opened or followed. */
|
|
230
|
+
export function EmailBody(props: DetailProps & { payload: Extract<NeedsMeEntry["payload"], { kind: "email" }> }) {
|
|
231
|
+
const { item, payload, loadEmail } = props;
|
|
232
|
+
const { summary } = payload;
|
|
233
|
+
const [detail, setDetail] = useState<MonitoringEmailDetails | null>(null);
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (!loadEmail) return undefined;
|
|
236
|
+
let live = true;
|
|
237
|
+
void loadEmail(summary.receiptId).then((next) => { if (live) setDetail(next); }, () => { if (live) setDetail({ state: "unavailable" }); });
|
|
238
|
+
return () => { live = false; };
|
|
239
|
+
}, [loadEmail, summary.receiptId]);
|
|
240
|
+
const verdicts = Object.entries(summary.verdicts ?? {});
|
|
241
|
+
return (
|
|
242
|
+
<div className="space-y-4" data-email-submission-details={summary.receiptId}>
|
|
243
|
+
<header className="space-y-1.5">
|
|
244
|
+
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"><MailIcon aria-hidden="true" className="size-3.5" />{summary.quarantine ? QUARANTINE_LABELS[summary.quarantine.state] : "Email submission"}</p>
|
|
245
|
+
<h2 className="break-words text-xl font-bold leading-tight tracking-tight text-foreground">{summary.subject?.trim() || "(no subject)"}</h2>
|
|
246
|
+
</header>
|
|
247
|
+
<p className="text-xs text-muted-foreground">Original content is shown as inert text. The sender and the message are untrusted; nothing in it is opened or run.</p>
|
|
248
|
+
<dl className="grid gap-2 text-sm">
|
|
249
|
+
<div><dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Envelope sender</dt><dd className="break-words">{summary.envelopeSender || "unknown"}</dd></div>
|
|
250
|
+
<div><dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">From (unverified)</dt><dd className="break-words">{summary.headerFrom || "unknown"}</dd></div>
|
|
251
|
+
{verdicts.length ? <div data-email-verdicts><dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Checks</dt><dd className="flex flex-wrap gap-1.5">{verdicts.map(([name, value]) => <span className={CHIP} key={name}>{name.toUpperCase()} {value}</span>)}</dd></div> : null}
|
|
252
|
+
{summary.warnings.length ? <div><dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Warnings</dt><dd>{summary.warnings.join(", ")}</dd></div> : null}
|
|
253
|
+
</dl>
|
|
254
|
+
{detail && "submission" in detail ? (
|
|
255
|
+
<>
|
|
256
|
+
<section aria-label="Message text" className="space-y-1.5">
|
|
257
|
+
<h3 className="text-sm font-medium text-foreground">Message text</h3>
|
|
258
|
+
<pre className="max-h-72 overflow-y-auto whitespace-pre-wrap break-words rounded-2xl bg-[var(--feed-control)] p-3 text-sm" data-email-body tabIndex={0}>{detail.safeBodyText}</pre>
|
|
259
|
+
</section>
|
|
260
|
+
<section aria-label="Links in the message" className="space-y-1.5">
|
|
261
|
+
<h3 className="text-sm font-medium text-foreground">Links in the message ({detail.candidateLinks.length})</h3>
|
|
262
|
+
<p className="text-xs text-muted-foreground">Shown as text. They are not opened unless you accept the message.</p>
|
|
263
|
+
<ul className="space-y-1 text-sm" data-email-links>
|
|
264
|
+
{detail.candidateLinks.map((link, index) => <li className="break-all" key={`${link.url}-${index}`}>{link.title || "Source link"} <code className="text-xs">{link.url}</code></li>)}
|
|
265
|
+
</ul>
|
|
266
|
+
</section>
|
|
267
|
+
</>
|
|
268
|
+
) : detail ? (
|
|
269
|
+
<p className="text-sm text-muted-foreground" role="status">The message text could not be read right now.</p>
|
|
270
|
+
) : loadEmail ? (
|
|
271
|
+
<p className="inline-flex items-center gap-1.5 text-sm text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Reading the message…</p>
|
|
272
|
+
) : (
|
|
273
|
+
<p className="text-sm text-muted-foreground">{summary.linkCount} candidate {summary.linkCount === 1 ? "link" : "links"} in the message.</p>
|
|
274
|
+
)}
|
|
275
|
+
<DefaultOutcome item={item} />
|
|
276
|
+
<DecisionPanel {...props} lockedNote={summary.quarantine?.decision ? `${summary.quarantine.decision.action === "accept" ? "Accepted" : "Rejected"}${summary.quarantine.decision.at ? ` on ${formatWhen(summary.quarantine.decision.at)}` : ""}.` : "Decided here. Monitoring shows what became of it."} />
|
|
277
|
+
</div>
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function ConceptBody({ item }: { item: NeedsMeEntry }) {
|
|
282
|
+
return (
|
|
283
|
+
<div className="space-y-3" data-concept-detail>
|
|
284
|
+
<h2 className="text-xl font-bold leading-tight tracking-tight text-foreground">{item.title}</h2>
|
|
285
|
+
{item.dek ? <p className="text-base text-muted-foreground">{item.dek}</p> : null}
|
|
286
|
+
<DefaultOutcome item={item} />
|
|
287
|
+
</div>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ConceptBody, DraftBody, EmailBody, Heading, RequestBody, type DetailProps } from "./needs-me-bodies";
|
|
4
|
+
import { ReferenceBody } from "./needs-me-reference-detail";
|
|
5
|
+
import { isReferenceLike } from "./needs-me-shared";
|
|
6
|
+
|
|
7
|
+
export type { DecisionExtras } from "./needs-me-bodies";
|
|
8
|
+
|
|
9
|
+
/** The detail pane for any item: the same shell, with a body that fits the type. */
|
|
10
|
+
export function NeedsMeDetail(props: DetailProps) {
|
|
11
|
+
const { item } = props;
|
|
12
|
+
const { payload } = item;
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex flex-col gap-4 p-4 font-sans" data-needs-me-detail={item.id} data-needs-me-detail-type={item.type}>
|
|
15
|
+
<Heading item={item} />
|
|
16
|
+
{payload.kind === "reference" && isReferenceLike(item) ? <ReferenceBody {...props} payload={payload} /> : null}
|
|
17
|
+
{payload.kind === "draft" ? <DraftBody {...props} payload={payload} /> : null}
|
|
18
|
+
{payload.kind === "request" ? <RequestBody {...props} payload={payload} /> : null}
|
|
19
|
+
{payload.kind === "email" ? <EmailBody {...props} payload={payload} /> : null}
|
|
20
|
+
{payload.kind === "concept" ? <ConceptBody item={item} /> : null}
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|