@anthusai/papyrus 5.44.0 → 5.46.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/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/content-actions/handler.py +8 -0
- package/amplify/functions/model-attachment-upload/handler.ts +1 -0
- package/amplify/functions/monitoring-view/schema.js +8 -0
- package/amplify/functions/monitoring-view/schema.ts +8 -0
- package/bin/papyrus-app.mjs +12 -1
- package/components/monitoring-overview-view.tsx +52 -6
- package/components/monitoring-panel.tsx +61 -3
- package/components/news-desk-client-provider.tsx +16 -1
- package/components/news-desk-page.tsx +2 -1
- package/components/newsroom-access-gate.tsx +7 -5
- package/components/newsroom-client-shell.tsx +2 -1
- package/components/newsroom-ops-overview.tsx +63 -60
- package/components/newsroom-ops-shell.tsx +4 -2
- package/components/newsroom-organizer.css +49 -2
- package/components/newsroom-references-view.tsx +296 -23
- package/components/notification-cards.tsx +364 -0
- package/components/notification-center.tsx +319 -331
- package/components/reference-capture-client.ts +110 -0
- package/components/reference-capture-panel.tsx +180 -0
- package/components/reference-feed.tsx +502 -0
- package/components/reference-review-card.tsx +32 -80
- package/components/references-page-loading.tsx +61 -0
- package/components/references-prefetch.ts +52 -0
- package/components/review-rating-grid.tsx +177 -0
- package/components/topic-steering-workspace.tsx +43 -9
- package/components/use-reference-ratings.ts +132 -0
- package/lib/category-repository.ts +6 -1
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +63 -0
- package/lib/local-preview-synthetic-rejects.ts +41 -0
- package/lib/monitoring-client.ts +16 -2
- package/lib/monitoring-overview.ts +20 -1
- package/lib/monitoring-paging.ts +58 -0
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +1 -1
- package/lib/notification-feed.ts +250 -0
- package/lib/reference-capture.ts +293 -0
- package/lib/reference-feed-cache.ts +170 -0
- package/lib/reference-feed-ranking.ts +235 -0
- package/lib/reference-preview.ts +112 -0
- package/lib/review-drafts.ts +8 -3
- package/lib/site-brand.ts +2 -0
- package/package.json +17 -1
- package/routes.manifest.json +1 -1
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CheckIcon, CircleCheckIcon, CircleXIcon, ClockIcon, ExternalLinkIcon, FileSearchIcon, FileTextIcon, FlagIcon, LockIcon, MessageSquarePlusIcon } from "lucide-react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { useState, type ReactNode } from "react";
|
|
6
|
+
import { cn } from "../lib/utils";
|
|
7
|
+
import { draftPicture, requestCaption, sourceHost, type DraftItem, type RecentDecision, type RequestItem } from "../lib/notification-feed";
|
|
8
|
+
import { cannotMoveReason, type DraftChoice } from "../lib/review-drafts";
|
|
9
|
+
import { answerWords, formatWhen, modeLabel, nextText, steeringText, subjectHref, subjectLabel, type ReviewRequest } from "../lib/review-requests";
|
|
10
|
+
import type { ReferencePreview } from "../lib/reference-preview";
|
|
11
|
+
import { FEED_SURFACE_PROPS, FeedImage, PILL, SourceMark } from "./reference-feed";
|
|
12
|
+
import { RatingGrid } from "./review-rating-grid";
|
|
13
|
+
|
|
14
|
+
/** What the editor typed with an answer. */
|
|
15
|
+
export type DecisionExtras = { why: string; notThisKind: boolean };
|
|
16
|
+
|
|
17
|
+
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";
|
|
18
|
+
const CARD = "flex flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] text-card-foreground";
|
|
19
|
+
const PILL_BUTTON = cn(PILL, "min-h-9 bg-[var(--feed-control)] px-3.5 text-foreground hover:bg-[var(--feed-control)]/70");
|
|
20
|
+
|
|
21
|
+
/** Pulsing placeholder cards in the shape of the real ones, on screen the moment the page opens. */
|
|
22
|
+
export function NotificationSkeleton({ count = 3 }: { count?: number }) {
|
|
23
|
+
return (
|
|
24
|
+
<div aria-busy="true" aria-label="Loading what needs you" className="space-y-6" data-notification-skeleton role="status" {...FEED_SURFACE_PROPS}>
|
|
25
|
+
<div className="space-y-3">
|
|
26
|
+
<div className="h-8 w-2/3 max-w-md animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
27
|
+
<div className="h-4 w-1/3 max-w-xs animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
28
|
+
</div>
|
|
29
|
+
<div className="flex gap-2">
|
|
30
|
+
{[0, 1, 2, 3].map((index) => <div className="h-9 w-24 animate-pulse rounded-full bg-[var(--feed-control)]" key={index} />)}
|
|
31
|
+
</div>
|
|
32
|
+
<div className="grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
|
33
|
+
{Array.from({ length: count }, (_, index) => (
|
|
34
|
+
<div className={CARD} key={index}>
|
|
35
|
+
<div className="aspect-[16/10] animate-pulse bg-[var(--feed-control)]" />
|
|
36
|
+
<div className="space-y-3 p-5">
|
|
37
|
+
<div className="h-3 w-1/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
38
|
+
<div className="h-5 w-11/12 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
39
|
+
<div className="h-5 w-2/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
40
|
+
<div className="h-3 w-1/2 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
41
|
+
</div>
|
|
42
|
+
<div className="flex gap-2 bg-[var(--feed-footer)] p-3">
|
|
43
|
+
<div className="h-12 flex-1 animate-pulse rounded-[16px] bg-[var(--feed-control)]" />
|
|
44
|
+
<div className="h-12 flex-1 animate-pulse rounded-[16px] bg-[var(--feed-control)]" />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function WhyField({ onChange, placeholder, readOnly, value }: { onChange: (value: string) => void; placeholder: string; readOnly: boolean; value: string }) {
|
|
54
|
+
return (
|
|
55
|
+
<textarea
|
|
56
|
+
aria-label="Why? (optional)"
|
|
57
|
+
className="block min-h-16 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"
|
|
58
|
+
onChange={(event) => onChange(event.target.value)}
|
|
59
|
+
placeholder={placeholder}
|
|
60
|
+
readOnly={readOnly}
|
|
61
|
+
value={value}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function DecidedLine({ choice, notThisKind, why }: { choice: DraftChoice; notThisKind?: boolean; why?: string | null }) {
|
|
67
|
+
const Icon = choice === "accept" ? CircleCheckIcon : CircleXIcon;
|
|
68
|
+
return (
|
|
69
|
+
<p className="flex items-start gap-1.5 text-sm font-medium" data-draft-decided={choice}>
|
|
70
|
+
<Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
71
|
+
<span className="break-words">
|
|
72
|
+
{choice === "accept" ? "Accepted" : "Rejected"}{choice === "reject" && notThisKind ? " as not this kind" : ""}
|
|
73
|
+
{why ? <span className="font-normal text-muted-foreground">: {why}</span> : null}
|
|
74
|
+
</span>
|
|
75
|
+
</p>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The footer every decidable card shares: an optional full-width Why?, the shared Accept / Reject grid, and (on Reject) the one
|
|
81
|
+
* question the AI learns from. Once decided it keeps the answer on screen, says in words that it is final here, and says where to change it.
|
|
82
|
+
*/
|
|
83
|
+
function DecisionFooter({ busy, decision, disabledReason, error, kind, lockedNote, onDecide, placeholder }: {
|
|
84
|
+
busy: boolean;
|
|
85
|
+
decision: { choice: DraftChoice; why?: string | null; notThisKind?: boolean } | null;
|
|
86
|
+
disabledReason: string | null;
|
|
87
|
+
error: string | null;
|
|
88
|
+
kind: "story" | "thing";
|
|
89
|
+
lockedNote: ReactNode;
|
|
90
|
+
onDecide: (choice: DraftChoice, extras: DecisionExtras) => void;
|
|
91
|
+
placeholder: string;
|
|
92
|
+
}) {
|
|
93
|
+
const [why, setWhy] = useState("");
|
|
94
|
+
const [notThisKind, setNotThisKind] = useState(false);
|
|
95
|
+
const [pending, setPending] = useState<DraftChoice | null>(null);
|
|
96
|
+
const locked = decision !== null;
|
|
97
|
+
const choose = (choice: DraftChoice) => {
|
|
98
|
+
if (locked || busy || disabledReason) return;
|
|
99
|
+
setPending(choice);
|
|
100
|
+
onDecide(choice, { why: why.trim(), notThisKind: choice === "reject" && notThisKind });
|
|
101
|
+
};
|
|
102
|
+
return (
|
|
103
|
+
<div className="space-y-2.5 bg-[var(--feed-footer)] p-3" data-feed-actions {...(locked ? {} : { "data-review-controls": "" })}>
|
|
104
|
+
{locked ? (
|
|
105
|
+
<>
|
|
106
|
+
<DecidedLine choice={decision.choice} notThisKind={decision.notThisKind} why={decision.why} />
|
|
107
|
+
<RatingGrid
|
|
108
|
+
answer={decision.choice} disabled disabledReason={null} feedback={{ phase: "saved" }} onChoose={() => undefined}
|
|
109
|
+
prediction={{ state: "none" }} showPrediction={false} surface="footer"
|
|
110
|
+
/>
|
|
111
|
+
<p className="text-xs text-muted-foreground" data-review-disabled-reason>{lockedNote}</p>
|
|
112
|
+
</>
|
|
113
|
+
) : (
|
|
114
|
+
<>
|
|
115
|
+
<WhyField onChange={setWhy} placeholder={placeholder} readOnly={busy} value={why} />
|
|
116
|
+
<div className="flex flex-wrap items-center gap-2" data-review-reasons>
|
|
117
|
+
<button
|
|
118
|
+
aria-disabled={busy || undefined}
|
|
119
|
+
aria-pressed={notThisKind}
|
|
120
|
+
className={cn(PILL, "min-h-8 px-3 text-xs", notThisKind ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]/60 text-muted-foreground hover:text-foreground")}
|
|
121
|
+
data-review-reason="not_this_kind"
|
|
122
|
+
onClick={() => { if (!busy) setNotThisKind((current) => !current); }}
|
|
123
|
+
type="button"
|
|
124
|
+
>
|
|
125
|
+
{notThisKind ? <CheckIcon aria-hidden="true" className="size-3.5" /> : null}
|
|
126
|
+
{kind === "story" ? "Not this kind of story" : "Not this kind of thing"}
|
|
127
|
+
</button>
|
|
128
|
+
<span className="text-xs text-muted-foreground">Optional. Counts if you reject.</span>
|
|
129
|
+
</div>
|
|
130
|
+
<RatingGrid
|
|
131
|
+
answer={busy ? pending : null}
|
|
132
|
+
disabled={Boolean(disabledReason)}
|
|
133
|
+
disabledReason={disabledReason}
|
|
134
|
+
feedback={busy ? { phase: "saving" } : error ? { phase: "error", message: error } : { phase: "idle" }}
|
|
135
|
+
onChoose={choose}
|
|
136
|
+
prediction={{ state: "none" }}
|
|
137
|
+
showPrediction={false}
|
|
138
|
+
surface="footer"
|
|
139
|
+
/>
|
|
140
|
+
</>
|
|
141
|
+
)}
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** One AI story draft as a feed card: the cited source's picture, headline, dek, section, evidence, cited sources, then Accept or Reject. */
|
|
147
|
+
export function DraftCard({ busy, demo: _demo, error, item, onDecide, previews, session }: {
|
|
148
|
+
busy: boolean;
|
|
149
|
+
demo?: boolean;
|
|
150
|
+
error: string | null;
|
|
151
|
+
item: DraftItem;
|
|
152
|
+
onDecide: (draftId: string, choice: DraftChoice, extras: DecisionExtras) => void;
|
|
153
|
+
previews: ReadonlyMap<string, ReferencePreview>;
|
|
154
|
+
/** Words for why answering is not possible yet (for example while the session is confirmed), or null. */
|
|
155
|
+
session: string | null;
|
|
156
|
+
}) {
|
|
157
|
+
const { view, decision, draftId, request } = item;
|
|
158
|
+
const link = request.links.find((entry) => entry.id === draftId);
|
|
159
|
+
if (!view) {
|
|
160
|
+
return (
|
|
161
|
+
<article aria-busy="true" className={CARD} data-draft-state="loading" data-review-draft={draftId} role="status" {...FEED_SURFACE_PROPS}>
|
|
162
|
+
<div className="aspect-[16/10] animate-pulse bg-[var(--feed-control)]" />
|
|
163
|
+
<div className="space-y-3 p-5">
|
|
164
|
+
<p className="text-xs text-muted-foreground">{link?.label ?? "Draft"}: loading the draft…</p>
|
|
165
|
+
<div className="h-5 w-11/12 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
166
|
+
<div className="h-5 w-2/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
167
|
+
</div>
|
|
168
|
+
</article>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const picture = draftPicture(view, previews);
|
|
172
|
+
const hasSource = view.sources.some((source) => source.url);
|
|
173
|
+
const hold = cannotMoveReason(view);
|
|
174
|
+
return (
|
|
175
|
+
<article
|
|
176
|
+
className={cn(CARD, decision && "opacity-95")}
|
|
177
|
+
data-draft-decided-card={decision?.choice}
|
|
178
|
+
data-draft-state={decision ? "decided" : "open"}
|
|
179
|
+
data-review-draft={draftId}
|
|
180
|
+
data-review-request={request.id}
|
|
181
|
+
{...FEED_SURFACE_PROPS}
|
|
182
|
+
>
|
|
183
|
+
{hasSource ? <FeedImage host={picture.host} lead={false} name={picture.siteName ?? picture.host ?? "Source"} src={picture.imageUrl} /> : null}
|
|
184
|
+
<div className="flex flex-1 flex-col gap-2.5 p-5">
|
|
185
|
+
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground" data-draft-kind>
|
|
186
|
+
<FileTextIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
187
|
+
<span>AI {view.kindLabel.toLowerCase()}{view.section ? ` · Section: ${view.section}` : ""}</span>
|
|
188
|
+
</p>
|
|
189
|
+
<h3 className="break-words text-xl font-bold leading-[1.15] tracking-tight text-foreground" data-draft-headline>{view.headline}</h3>
|
|
190
|
+
{view.dek ? <p className="line-clamp-4 break-words text-sm text-muted-foreground" data-draft-dek>{view.dek}</p> : null}
|
|
191
|
+
{view.evidence.length ? (
|
|
192
|
+
<p aria-label="Evidence" className="flex items-start gap-1.5 break-words text-xs" data-draft-evidence>
|
|
193
|
+
<FileSearchIcon aria-hidden="true" className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
|
194
|
+
<span><span className="font-semibold">Evidence:</span> {view.evidence.join(" ")}</span>
|
|
195
|
+
</p>
|
|
196
|
+
) : null}
|
|
197
|
+
{view.sources.length ? (
|
|
198
|
+
<ul aria-label="Sources" className="flex flex-wrap gap-1.5" data-draft-sources>
|
|
199
|
+
{view.sources.map((source, index) => {
|
|
200
|
+
const host = source.url ? sourceHost(source.url) : null;
|
|
201
|
+
return (
|
|
202
|
+
<li className="min-w-0 max-w-full" key={`${source.title}-${index}`}>
|
|
203
|
+
{source.url ? (
|
|
204
|
+
<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}>
|
|
205
|
+
<SourceMark host={host} />
|
|
206
|
+
<span className="truncate">{host ?? source.title}</span>
|
|
207
|
+
<ExternalLinkIcon aria-hidden="true" className="size-3 shrink-0 opacity-60" />
|
|
208
|
+
</a>
|
|
209
|
+
) : <span className={CHIP} data-draft-source><span className="truncate">{source.title}</span></span>}
|
|
210
|
+
</li>
|
|
211
|
+
);
|
|
212
|
+
})}
|
|
213
|
+
{view.moreSources ? <li className="self-center text-xs text-muted-foreground">and {view.moreSources} more in the full draft</li> : null}
|
|
214
|
+
</ul>
|
|
215
|
+
) : null}
|
|
216
|
+
<p className="mt-auto flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pt-1 text-xs text-muted-foreground">
|
|
217
|
+
<span data-draft-caption>{requestCaption(request)}</span>
|
|
218
|
+
<Link className="inline-flex items-center gap-1 font-medium text-primary underline-offset-4 hover:underline" data-draft-open href={view.href}>
|
|
219
|
+
Open full draft<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
220
|
+
</Link>
|
|
221
|
+
</p>
|
|
222
|
+
</div>
|
|
223
|
+
<DecisionFooter
|
|
224
|
+
busy={busy}
|
|
225
|
+
decision={decision}
|
|
226
|
+
disabledReason={hold ?? session}
|
|
227
|
+
error={error}
|
|
228
|
+
kind="story"
|
|
229
|
+
lockedNote={<>To change it, <Link className="text-primary underline underline-offset-4" href={view.href}>open the draft in Proposals</Link>.</>}
|
|
230
|
+
onDecide={(choice, extras) => onDecide(draftId, choice, extras)}
|
|
231
|
+
placeholder="Why? Optional. Your note teaches the AI."
|
|
232
|
+
/>
|
|
233
|
+
</article>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const MODE_ICON = { hard_gate: LockIcon, soft_review: ClockIcon, flag: FlagIcon } as const;
|
|
238
|
+
|
|
239
|
+
function AboutLine({ request, demo, titles, deleted, onOpen }: { request: ReviewRequest; demo: boolean; titles: Readonly<Record<string, string>>; deleted: ReadonlySet<string>; onOpen?: () => void }) {
|
|
240
|
+
const href = subjectHref(request.subject, demo);
|
|
241
|
+
if (request.subject.type === "batch") return null;
|
|
242
|
+
const label = subjectLabel(request.subject, titles, deleted);
|
|
243
|
+
return (
|
|
244
|
+
<p className="break-words text-sm" data-review-about>
|
|
245
|
+
About: {href ? <Link className="text-primary underline underline-offset-4" href={href} onClick={onOpen}>{label}</Link> : label}
|
|
246
|
+
</p>
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A request decided as a whole: a hard gate, a soft review of something that is not a draft. */
|
|
251
|
+
export function RequestCard({ busy, demo, deleted, error, item, onAnswer, onOpen, session, titles }: {
|
|
252
|
+
busy: boolean; demo: boolean; deleted: ReadonlySet<string>; error: string | null; item: RequestItem;
|
|
253
|
+
onAnswer: (request: ReviewRequest, choice: DraftChoice, extras: DecisionExtras) => void;
|
|
254
|
+
onOpen?: (requestId: string) => void; session: string | null; titles: Readonly<Record<string, string>>;
|
|
255
|
+
}) {
|
|
256
|
+
const { request } = item;
|
|
257
|
+
const Icon = MODE_ICON[request.mode];
|
|
258
|
+
const answered = request.state === "answered" && request.answer;
|
|
259
|
+
const choice: DraftChoice | null = answered ? (request.answer!.decision === "reject" || (request.answer!.decision === "feedback" && request.answer!.steer === "not_this_kind") ? "reject" : "accept") : null;
|
|
260
|
+
const steering = steeringText(request);
|
|
261
|
+
return (
|
|
262
|
+
<article className={CARD} data-review-mode={request.mode} data-review-request={request.id} data-review-state={request.state} {...FEED_SURFACE_PROPS}>
|
|
263
|
+
<div className="flex flex-1 flex-col gap-2.5 p-5">
|
|
264
|
+
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
265
|
+
<Icon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
266
|
+
<span>{modeLabel(request.mode)}{request.mode === "hard_gate" ? " · Waiting for you" : ""}</span>
|
|
267
|
+
</p>
|
|
268
|
+
<h3 className="break-words text-xl font-bold leading-[1.15] tracking-tight text-foreground">{request.reason}</h3>
|
|
269
|
+
{request.ask ? <p className="break-words text-sm text-muted-foreground">{request.ask}</p> : null}
|
|
270
|
+
<AboutLine deleted={deleted} demo={demo} onOpen={() => onOpen?.(request.id)} request={request} titles={titles} />
|
|
271
|
+
<p className="text-sm" data-review-next>{nextText(request)}</p>
|
|
272
|
+
{steering ? <p className="text-sm" data-review-steering>{steering}</p> : null}
|
|
273
|
+
<p className="mt-auto pt-1 text-xs text-muted-foreground" data-draft-caption>{requestCaption(request)}</p>
|
|
274
|
+
</div>
|
|
275
|
+
<DecisionFooter
|
|
276
|
+
busy={busy}
|
|
277
|
+
decision={choice ? { choice, why: request.answer?.feedback ?? null, notThisKind: request.answer?.steer === "not_this_kind" } : null}
|
|
278
|
+
disabledReason={session}
|
|
279
|
+
error={error}
|
|
280
|
+
kind="thing"
|
|
281
|
+
lockedNote={answered ? `${answerWords(request.answer!, request)} by ${request.answer!.by}.` : ""}
|
|
282
|
+
onDecide={(decided, extras) => onAnswer(request, decided, extras)}
|
|
283
|
+
placeholder="Why? Optional. It is saved with your answer."
|
|
284
|
+
/>
|
|
285
|
+
</article>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A flag is information, not a question: a slim card with Acknowledge and an optional note. */
|
|
290
|
+
export function FlagCard({ busy, demo, deleted, error, item, onAcknowledge, onOpen, session, titles }: {
|
|
291
|
+
busy: boolean; demo: boolean; deleted: ReadonlySet<string>; error: string | null; item: RequestItem;
|
|
292
|
+
onAcknowledge: (request: ReviewRequest, note: string) => void;
|
|
293
|
+
onOpen?: (requestId: string) => void; session: string | null; titles: Readonly<Record<string, string>>;
|
|
294
|
+
}) {
|
|
295
|
+
const { request } = item;
|
|
296
|
+
const [noting, setNoting] = useState(false);
|
|
297
|
+
const [note, setNote] = useState("");
|
|
298
|
+
const done = request.state === "answered";
|
|
299
|
+
return (
|
|
300
|
+
<article className="flex flex-col gap-3 rounded-[20px] bg-[var(--feed-card)] p-4" data-review-mode="flag" data-review-request={request.id} data-review-state={request.state} {...FEED_SURFACE_PROPS}>
|
|
301
|
+
<div className="flex flex-col gap-3">
|
|
302
|
+
<div className="min-w-0 space-y-1">
|
|
303
|
+
<p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
304
|
+
<FlagIcon aria-hidden="true" className="size-3.5 shrink-0" />For your information
|
|
305
|
+
</p>
|
|
306
|
+
<h3 className="break-words text-base font-semibold leading-snug text-foreground">{request.reason}</h3>
|
|
307
|
+
<AboutLine deleted={deleted} demo={demo} onOpen={() => onOpen?.(request.id)} request={request} titles={titles} />
|
|
308
|
+
<p className="text-xs text-muted-foreground" data-draft-caption>{requestCaption(request)} · {done && request.answer ? `${answerWords(request.answer, request)} by ${request.answer.by} on ${formatWhen(request.answer.at)}.` : "Nothing is waiting on this."}</p>
|
|
309
|
+
</div>
|
|
310
|
+
{done ? (
|
|
311
|
+
<p className="inline-flex items-center gap-1.5 text-sm font-medium" data-flag-acknowledged><CircleCheckIcon aria-hidden="true" className="size-4" />Acknowledged</p>
|
|
312
|
+
) : (
|
|
313
|
+
<div className="flex flex-wrap gap-2">
|
|
314
|
+
<button aria-expanded={noting} className={PILL_BUTTON} data-flag-note-toggle onClick={() => setNoting((current) => !current)} type="button">
|
|
315
|
+
<MessageSquarePlusIcon aria-hidden="true" className="size-4" />Add a note
|
|
316
|
+
</button>
|
|
317
|
+
<button
|
|
318
|
+
aria-disabled={busy || Boolean(session) || undefined}
|
|
319
|
+
className={cn(PILL_BUTTON, "font-semibold", (busy || session) && "opacity-60")}
|
|
320
|
+
data-flag-acknowledge
|
|
321
|
+
onClick={() => { if (!busy && !session) onAcknowledge(request, note.trim()); }}
|
|
322
|
+
type="button"
|
|
323
|
+
>
|
|
324
|
+
<CheckIcon aria-hidden="true" className="size-4" />Acknowledge
|
|
325
|
+
</button>
|
|
326
|
+
</div>
|
|
327
|
+
)}
|
|
328
|
+
</div>
|
|
329
|
+
{noting && !done ? <WhyField onChange={setNote} placeholder="Why? Optional. Tell the step what to do differently." readOnly={busy} value={note} /> : null}
|
|
330
|
+
<div aria-live="polite" className="empty:hidden text-xs text-muted-foreground" role="status">{busy ? "Saving…" : session && !done ? session : ""}</div>
|
|
331
|
+
{error ? <p className="text-xs text-destructive" role="alert">{error}</p> : null}
|
|
332
|
+
</article>
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The last few things the editor decided, as slim rows: what, when, and where to look again. */
|
|
337
|
+
export function RecentDecisions({ entries }: { entries: RecentDecision[] }) {
|
|
338
|
+
if (!entries.length) return null;
|
|
339
|
+
return (
|
|
340
|
+
<ul aria-label="Recently decided" className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3" data-recent-decisions {...FEED_SURFACE_PROPS}>
|
|
341
|
+
{entries.map((entry) => {
|
|
342
|
+
const Icon = entry.choice === "reject" ? CircleXIcon : CircleCheckIcon;
|
|
343
|
+
const body = (
|
|
344
|
+
<>
|
|
345
|
+
<Icon aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
|
|
346
|
+
<span className="min-w-0">
|
|
347
|
+
<span className="block break-words text-sm font-medium leading-snug">{entry.title}</span>
|
|
348
|
+
<span className="block text-xs text-muted-foreground">
|
|
349
|
+
{entry.word}{entry.at ? ` · ${new Date(entry.at).toISOString().slice(0, 10)}` : ""}{entry.note ? ` · ${entry.note}` : ""}
|
|
350
|
+
</span>
|
|
351
|
+
</span>
|
|
352
|
+
</>
|
|
353
|
+
);
|
|
354
|
+
return (
|
|
355
|
+
<li data-recent-decision={entry.key} key={entry.key}>
|
|
356
|
+
{entry.href ? (
|
|
357
|
+
<Link className="flex items-start gap-2 rounded-[16px] bg-[var(--feed-card)] p-3 outline-none hover:bg-[var(--feed-control)] focus-visible:ring-2 focus-visible:ring-ring" href={entry.href}>{body}</Link>
|
|
358
|
+
) : <div className="flex items-start gap-2 rounded-[16px] bg-[var(--feed-card)] p-3">{body}</div>}
|
|
359
|
+
</li>
|
|
360
|
+
);
|
|
361
|
+
})}
|
|
362
|
+
</ul>
|
|
363
|
+
);
|
|
364
|
+
}
|