@anthusai/papyrus 5.49.0 → 5.51.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/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/components/markdown-context.tsx +412 -0
- package/components/needs-me-bodies.tsx +3 -2
- package/components/needs-me-detail.tsx +2 -1
- package/components/needs-me-feed.tsx +36 -14
- package/components/needs-me-reference-detail.tsx +13 -15
- package/components/newsroom-access-gate.tsx +7 -16
- package/components/newsroom-ops-shell.tsx +0 -22
- package/components/newsroom-organizer.css +19 -0
- package/components/newsroom-references-view.tsx +19 -23
- package/components/reader-auth-control.tsx +3 -1
- package/components/reference-capture-panel.tsx +17 -7
- package/components/reference-feed.tsx +8 -4
- package/components/reference-review-card.tsx +27 -9
- package/components/reference-source-preview.tsx +27 -5
- package/components/review-rating-grid.tsx +20 -10
- package/components/topic-steering-workspace.tsx +3 -428
- package/components/ui/auto-grow-textarea.tsx +18 -0
- package/components/use-reference-ratings.ts +9 -4
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-references.ts +25 -1
- package/lib/reference-capture.ts +3 -2
- package/lib/source-frameable.ts +77 -0
- package/lib/source-icon.ts +104 -0
- package/package.json +7 -2
- package/routes.manifest.json +23 -1
- package/components/newsroom-overview-skeleton.tsx +0 -25
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
import { referenceDisplayTitle, referenceLineageId, formatReferenceListDate } from "../lib/newsroom-references";
|
|
4
4
|
import { referenceMetadataField } from "../lib/reference-display";
|
|
5
|
+
import { referenceDek, referencePreviewsByLineage } from "../lib/reference-preview";
|
|
5
6
|
import { DEFAULT_TEASER_WORDS } from "../lib/reference-capture";
|
|
6
7
|
import { referenceReasonCode, normalizeReferenceCurationStatus } from "../lib/reference-policy";
|
|
7
8
|
import type { ReferenceRecord } from "../lib/category-repository";
|
|
8
9
|
import type { NeedsMeEntry } from "../lib/needs-me-items";
|
|
9
|
-
import { DefaultOutcome, type DetailProps } from "./needs-me-bodies";
|
|
10
|
+
import { DefaultOutcome, Heading, type DetailProps } from "./needs-me-bodies";
|
|
10
11
|
import { decidedChoiceOf } from "./needs-me-shared";
|
|
11
12
|
import { predictionFromDecision, ReviewCard, type CardPrediction } from "./reference-review-card";
|
|
12
13
|
import { ReferenceCapturePanel } from "./reference-capture-panel";
|
|
@@ -20,7 +21,10 @@ function cardPrediction(item: NeedsMeEntry, predictionLoading: boolean): CardPre
|
|
|
20
21
|
return predictionLoading ? { state: "loading" } : { state: "none" };
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* 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
|
|
26
|
+
* comes first (title, blurb, link, badges, summary), then the rating, then what was captured and how well.
|
|
27
|
+
*/
|
|
24
28
|
export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEntry["payload"], { kind: "reference" }> }) {
|
|
25
29
|
const { item, payload, ratings, disabledReason, attachments, captureTeaserLine = DEFAULT_TEASER_WORDS, onCorrectCapture, predictionLoading } = props;
|
|
26
30
|
const reference: ReferenceRecord = payload.reference;
|
|
@@ -29,26 +33,23 @@ export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEnt
|
|
|
29
33
|
const previewAttachments = useReferencePreviewAttachments(reference, attachments);
|
|
30
34
|
const capture = useReferenceCapture(reference, previewAttachments, captureTeaserLine, onCorrectCapture);
|
|
31
35
|
const url = reference.sourceUri?.trim() && /^https?:\/\//i.test(reference.sourceUri.trim()) ? reference.sourceUri.trim() : null;
|
|
32
|
-
const summary = referenceMetadataField(null, reference.metadata, "summary");
|
|
36
|
+
const summary = capture.summary ?? referenceMetadataField(null, reference.metadata, "summary");
|
|
37
|
+
const dek = referenceDek(reference, referencePreviewsByLineage(previewAttachments).get(lineageId));
|
|
33
38
|
const incomplete = item.type === "incomplete_capture";
|
|
34
|
-
const capturePanel = <ReferenceCapturePanel capture={capture} startEditing={incomplete} />;
|
|
35
39
|
return (
|
|
36
40
|
<div className="space-y-4" data-reference-curation-status={status} data-news-desk-reference-detail={lineageId}>
|
|
37
|
-
{incomplete ? (
|
|
38
|
-
<div className="space-y-2" data-needs-me-capture-first>
|
|
39
|
-
<p className="text-sm text-muted-foreground">The captured text looks cut off or missing. Paste the full text below and say whether the capture was complete, or rate the Reference as it is.</p>
|
|
40
|
-
{capturePanel}
|
|
41
|
-
</div>
|
|
42
|
-
) : null}
|
|
43
41
|
<ReviewCard
|
|
44
42
|
decided={decidedChoiceOf(item)}
|
|
45
43
|
disabledReason={disabledReason}
|
|
46
44
|
initialNote={status === "pending" ? "" : reference.curationStatusReason ?? ""}
|
|
47
45
|
initialReason={referenceReasonCode(reference) ?? null}
|
|
46
|
+
dek={dek}
|
|
48
47
|
itemId={item.id}
|
|
48
|
+
meta={<Heading item={item} />}
|
|
49
49
|
onDecide={async () => undefined}
|
|
50
50
|
prediction={cardPrediction(item, predictionLoading)}
|
|
51
51
|
ratings={ratings}
|
|
52
|
+
sourcePreview={<ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />}
|
|
52
53
|
summary={summary}
|
|
53
54
|
title={referenceDisplayTitle(reference)}
|
|
54
55
|
titleHref={url}
|
|
@@ -56,11 +57,8 @@ export function ReferenceBody(props: DetailProps & { payload: Extract<NeedsMeEnt
|
|
|
56
57
|
{payload.decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={payload.decision} /></div> : null}
|
|
57
58
|
</ReviewCard>
|
|
58
59
|
<DefaultOutcome item={item} />
|
|
59
|
-
{incomplete ?
|
|
60
|
-
<
|
|
61
|
-
<h3 className="text-sm font-medium text-foreground">Source preview</h3>
|
|
62
|
-
<ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />
|
|
63
|
-
</section>
|
|
60
|
+
{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}
|
|
61
|
+
<ReferenceCapturePanel capture={capture} showSummary={false} />
|
|
64
62
|
<dl className="grid grid-cols-2 gap-3 text-sm">
|
|
65
63
|
<div>
|
|
66
64
|
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Imported</dt>
|
|
@@ -1,12 +1,11 @@
|
|
|
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";
|
|
12
11
|
import { ReferencesPageLoading } from "./references-page-loading";
|
|
@@ -23,24 +22,15 @@ export function NewsDeskAccessGate({ initialTab, shell, showSectionTabs = false
|
|
|
23
22
|
backHref="/"
|
|
24
23
|
backLabel={SITE_BRAND.backToHomeLabel}
|
|
25
24
|
headerActions={(
|
|
26
|
-
<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>
|
|
27
26
|
)}
|
|
28
27
|
pageTitle={referencesPage ? "References" : showSectionTabs ? "Notification Center" : "Newsroom"}
|
|
29
28
|
showNavigation={showSectionTabs || referencesPage}
|
|
30
29
|
>
|
|
31
|
-
{
|
|
32
|
-
|
|
33
|
-
<div className="h-6 w-48 rounded bg-muted" />
|
|
34
|
-
<div className="h-4 w-3/4 rounded bg-muted" />
|
|
35
|
-
<div className="grid gap-3 sm:grid-cols-2">
|
|
36
|
-
{Array.from({ length: 4 }, (_, index) => <div className="h-32 rounded-xl border bg-card p-4" key={index}>
|
|
37
|
-
<div className="h-4 w-2/3 rounded bg-muted" /><div className="mt-4 h-3 w-1/2 rounded bg-muted" />
|
|
38
|
-
</div>)}
|
|
39
|
-
</div>
|
|
40
|
-
</div>
|
|
41
|
-
)) : (
|
|
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} /> : (
|
|
42
32
|
<section aria-live="polite" data-news-desk-access={accessPhase} data-news-desk-access-phase={accessPhase}>
|
|
43
|
-
<div className="space-y-3 rounded-xl
|
|
33
|
+
<div className="space-y-3 rounded-xl bg-card p-6">
|
|
44
34
|
<p className="m-0 text-[0.7rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground">Access</p>
|
|
45
35
|
<h2 className="m-0 font-sans text-xl font-semibold tracking-tight">{formatAccessTitle(shell)}</h2>
|
|
46
36
|
<p className="m-0 text-sm text-muted-foreground">{formatAccessDetail(shell)}</p>
|
|
@@ -48,7 +38,8 @@ export function NewsDeskAccessGate({ initialTab, shell, showSectionTabs = false
|
|
|
48
38
|
<p className="m-0 text-sm text-foreground">{formatAccessActionDetail(shell)}</p>
|
|
49
39
|
{shell?.phase === "signedOut" || shell?.phase === "error" ? (
|
|
50
40
|
<div className="news-desk-access-panel__auth">
|
|
51
|
-
|
|
41
|
+
{/* No fixed return path: sign-in comes back to the page that asked, so a deep link survives it. */}
|
|
42
|
+
<ReaderAuthControl showIdentity />
|
|
52
43
|
</div>
|
|
53
44
|
) : null}
|
|
54
45
|
</div>
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { SearchIcon } from "lucide-react";
|
|
4
3
|
import { Button } from "./ui/button";
|
|
5
4
|
import { Separator } from "./ui/separator";
|
|
6
5
|
import { cn } from "../lib/utils";
|
|
@@ -179,27 +178,6 @@ export function NewsroomOpsShell({ activeTab, appTitle, backHref = "/", backLabe
|
|
|
179
178
|
</div>;
|
|
180
179
|
}
|
|
181
180
|
|
|
182
|
-
export function NewsroomOpsSearchButton({
|
|
183
|
-
disabled,
|
|
184
|
-
onPress,
|
|
185
|
-
}: {
|
|
186
|
-
disabled?: boolean;
|
|
187
|
-
onPress?: () => void;
|
|
188
|
-
}) {
|
|
189
|
-
return (
|
|
190
|
-
<Button
|
|
191
|
-
aria-label="Search knowledge base"
|
|
192
|
-
disabled={disabled}
|
|
193
|
-
onClick={onPress}
|
|
194
|
-
size="icon-sm"
|
|
195
|
-
type="button"
|
|
196
|
-
variant="outline"
|
|
197
|
-
>
|
|
198
|
-
<SearchIcon className="size-4" />
|
|
199
|
-
</Button>
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
181
|
export function NewsroomOpsStatusBanner({
|
|
204
182
|
children,
|
|
205
183
|
tone = "info",
|
|
@@ -417,3 +417,22 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
|
|
|
417
417
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-review-queue-bar] { background: var(--feed-footer) !important; }
|
|
418
418
|
/* The rating grid keeps its own rounded rectangles in the borderless panes (not the pill shape the flat rules give other buttons). */
|
|
419
419
|
[data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat][data-feed-flat] [data-rating-grid] :is(button, [data-cyclotron-choice]) { border-radius: var(--feed-radius-md) !important; }
|
|
420
|
+
|
|
421
|
+
/* Flat chrome (Ryan, 2026-10-28): the shell has no outlines or rules either. Controls are tone fills in rounded rectangles; on a phone the
|
|
422
|
+
page's own heading names the page, so the bar keeps one search field and the account button, nothing cut off. */
|
|
423
|
+
.newsroom-organizer,.organizer-menu,.organizer-account-popup{--org-control:color-mix(in oklab,var(--org-paper) 90%,var(--org-ink))}
|
|
424
|
+
.organizer-sidebar{border-right:0}
|
|
425
|
+
.organizer-search{border-bottom:0;background:var(--org-control);border-radius:8px;padding:6px 10px}
|
|
426
|
+
.newsroom-organizer button.organizer-toggle,.newsroom-organizer button.organizer-account{border:0;background:var(--org-control);border-radius:8px !important}
|
|
427
|
+
.organizer-account-popup{border:0}
|
|
428
|
+
.organizer-menu .organizer-destinations a{border:0;background:var(--org-control);border-radius:8px !important}
|
|
429
|
+
@media(max-width:900px){
|
|
430
|
+
.organizer-topbar h1{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
431
|
+
.organizer-search{flex:1;max-width:none}
|
|
432
|
+
.organizer-header-settings{display:none}
|
|
433
|
+
.organizer-bottom-nav{border-top:0;background:color-mix(in oklab,var(--org-paper) 95%,var(--org-ink))}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/* Captured text in the reference detail reads in the feed's own type, not the analysis view's serif and small caps. */
|
|
437
|
+
[data-capture-text] .reference-capture-markdown,[data-capture-text] .reference-capture-markdown :is(h1,h2,h3,h4,h5,h6,p,li,th,td){font-family:inherit;letter-spacing:normal;text-transform:none}
|
|
438
|
+
[data-capture-text] .reference-capture-markdown :is(h3,h4,h5){font-size:1em;font-weight:600;margin:1em 0 .25em}
|
|
@@ -9,7 +9,7 @@ import { usePathname } from "next/navigation";
|
|
|
9
9
|
import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
|
|
10
10
|
import { Badge } from "./ui/badge";
|
|
11
11
|
import { Button } from "./ui/button";
|
|
12
|
-
import { Card,
|
|
12
|
+
import { Card, CardDescription, CardHeader, CardTitle } from "./ui/card";
|
|
13
13
|
import { ScrollArea } from "./ui/scroll-area";
|
|
14
14
|
import { Separator } from "./ui/separator";
|
|
15
15
|
import {
|
|
@@ -50,7 +50,7 @@ import { useReferenceCapture, type CorrectCaptureAction } from "./reference-capt
|
|
|
50
50
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
51
51
|
import { captureStatusesByLineage } from "../lib/reference-capture";
|
|
52
52
|
import { applyFeedOrderSnapshot, partitionReferenceFeed, takeFeedOrderSnapshot, type FeedOrderSnapshot, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
|
|
53
|
-
import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
|
|
53
|
+
import { referenceDek, referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
|
|
54
54
|
import { isLocalPreviewEnabled } from "../lib/local-preview";
|
|
55
55
|
import { chooseSyntheticRejects, withSyntheticRejects } from "../lib/local-preview-synthetic-rejects";
|
|
56
56
|
import { cn } from "../lib/utils";
|
|
@@ -262,28 +262,31 @@ function ReferenceDetailPanel({
|
|
|
262
262
|
data-news-desk-reference-detail={lineageId}
|
|
263
263
|
>
|
|
264
264
|
<div className="space-y-4 p-4" data-reference-curation-status={status}>
|
|
265
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
266
|
-
<Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
|
|
267
|
-
{reference.corpusId ? <Badge variant="outline">{reference.corpusId}</Badge> : null}
|
|
268
|
-
</div>
|
|
269
|
-
|
|
270
265
|
<ReviewCard
|
|
271
266
|
decided={status === "accepted" ? "accept" : status === "rejected" ? "reject" : null}
|
|
267
|
+
dek={referenceDek(reference, referencePreviewsByLineage(previewAttachments).get(lineageId))}
|
|
272
268
|
disabledReason={disabled ? "Editor access is required to change this reference." : null}
|
|
273
269
|
initialNote={sourceNote}
|
|
274
270
|
initialReason={sourceReason}
|
|
275
271
|
itemId={lineageId}
|
|
272
|
+
meta={(
|
|
273
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
274
|
+
<Badge variant={referenceStatusBadgeVariant(status)}>{referenceStatusLabel(status)}</Badge>
|
|
275
|
+
{reference.corpusId ? <Badge variant="secondary">{reference.corpusId}</Badge> : null}
|
|
276
|
+
</div>
|
|
277
|
+
)}
|
|
276
278
|
ratings={ratings}
|
|
277
279
|
onArchive={status === "archived" ? undefined : async () => { await onReview("archive"); }}
|
|
278
280
|
onDecide={decide}
|
|
279
281
|
prediction={cardPrediction(decision, predictionState)}
|
|
280
|
-
|
|
282
|
+
sourcePreview={<ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />}
|
|
283
|
+
summary={capture.summary ?? summaryOf(reference)}
|
|
281
284
|
title={referenceDisplayTitle(reference)}
|
|
282
285
|
titleHref={url}
|
|
283
286
|
>
|
|
284
287
|
{decision ? <div data-reference-relevance-decision={lineageId}><ReferenceSuggestionProvenance decision={decision} /></div> : null}
|
|
285
288
|
{sectionSuggestion || ((status === "pending" || status === "accepted") && sections.length) ? (
|
|
286
|
-
<details className="rounded-
|
|
289
|
+
<details className="rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-sm">
|
|
287
290
|
<summary className="cursor-pointer font-semibold">Section placement (optional)</summary>
|
|
288
291
|
<div className="mt-3 space-y-3">
|
|
289
292
|
{sectionSuggestion ? <SectionSuggestionBlock agreement={sectionAgreement} referenceLineageId={lineageId} sections={sections} suggestion={sectionSuggestion} /> : null}
|
|
@@ -304,12 +307,7 @@ function ReferenceDetailPanel({
|
|
|
304
307
|
|
|
305
308
|
<Separator />
|
|
306
309
|
|
|
307
|
-
<ReferenceCapturePanel capture={capture} />
|
|
308
|
-
|
|
309
|
-
<section aria-label="Source preview" className="space-y-2">
|
|
310
|
-
<h3 className="text-sm font-medium text-foreground">Source preview</h3>
|
|
311
|
-
<ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />
|
|
312
|
-
</section>
|
|
310
|
+
<ReferenceCapturePanel capture={capture} showSummary={false} />
|
|
313
311
|
|
|
314
312
|
<dl className="grid gap-3 text-sm">
|
|
315
313
|
<div>
|
|
@@ -663,7 +661,6 @@ export function NewsroomReferencesView({
|
|
|
663
661
|
return (
|
|
664
662
|
<div
|
|
665
663
|
className="flex min-h-0 flex-1 flex-col gap-4 md:max-h-[100dvh] md:flex-row"
|
|
666
|
-
data-detail-reserved={view === "feed" ? "true" : undefined}
|
|
667
664
|
data-news-desk-section="references"
|
|
668
665
|
data-detail-open={selectedReference || routeLineageId ? "true" : "false"}
|
|
669
666
|
>
|
|
@@ -764,8 +761,9 @@ export function NewsroomReferencesView({
|
|
|
764
761
|
</button>
|
|
765
762
|
</div>
|
|
766
763
|
) : null}
|
|
764
|
+
{/* 6px of room on every side, so a selected card's 4px ring (drawn outside the card) is never cut off by the scroller. */}
|
|
767
765
|
<ScrollArea className="min-h-0 flex-1 [overflow-anchor:none]" data-reference-feed-scroll>
|
|
768
|
-
<div className="pb-6 pr-
|
|
766
|
+
<div className="px-1.5 pb-6 pt-1.5 pr-2.5">
|
|
769
767
|
<ReferenceFeed
|
|
770
768
|
disabledReason={feedDisabledReason}
|
|
771
769
|
loading={previewSkeleton || (loading && !references.length)}
|
|
@@ -873,7 +871,9 @@ export function NewsroomReferencesView({
|
|
|
873
871
|
|
|
874
872
|
<div
|
|
875
873
|
className={cn(
|
|
876
|
-
"hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:
|
|
874
|
+
"hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:w-[min(100%,24rem)] md:pb-16 lg:w-[min(100%,28rem)]",
|
|
875
|
+
// The pane appears once a reference is selected (or when the queue is done); until then the feed has the whole width.
|
|
876
|
+
(selectedReference || caughtUp) && "md:flex",
|
|
877
877
|
)}
|
|
878
878
|
data-newsroom-list-detail-shell
|
|
879
879
|
data-news-desk-section="references"
|
|
@@ -909,11 +909,7 @@ export function NewsroomReferencesView({
|
|
|
909
909
|
previewAttachments={previewAttachments}
|
|
910
910
|
reference={selectedReference}
|
|
911
911
|
/>
|
|
912
|
-
) : caughtUp ? caughtUpPanel :
|
|
913
|
-
<CardContent className="p-4 text-sm text-muted-foreground">
|
|
914
|
-
Select a reference to inspect curation details.
|
|
915
|
-
</CardContent>
|
|
916
|
-
)}
|
|
912
|
+
) : caughtUp ? caughtUpPanel : null}
|
|
917
913
|
</ScrollArea>
|
|
918
914
|
</div>
|
|
919
915
|
{queueBar}
|
|
@@ -36,7 +36,9 @@ export function ReaderAuthControl({
|
|
|
36
36
|
if (!nextPath) return;
|
|
37
37
|
if (window.location.pathname !== "/" || window.location.search || window.location.hash) return;
|
|
38
38
|
if (storedPath) window.sessionStorage.removeItem(POST_AUTH_PATH_KEY);
|
|
39
|
-
|
|
39
|
+
if (nextPath === "/") return;
|
|
40
|
+
// Sign-in comes back to "/"; the page asked for (a deep link such as /references/<id>) may be another route, so load it.
|
|
41
|
+
window.location.replace(nextPath);
|
|
40
42
|
}, [postAuthPath]);
|
|
41
43
|
|
|
42
44
|
const refreshAuthState = useCallback(async () => {
|
|
@@ -4,7 +4,9 @@ import { FileCheckIcon, FileClockIcon, FileWarningIcon, FileXIcon, Loader2Icon }
|
|
|
4
4
|
import { useEffect, useState } from "react";
|
|
5
5
|
import { captureLine, type CaptureAssessment, type CaptureState, type CaptureStatus } from "../lib/reference-capture";
|
|
6
6
|
import { cn } from "../lib/utils";
|
|
7
|
+
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
7
8
|
import type { CaptureView } from "./reference-capture-client";
|
|
9
|
+
import { MarkdownContext } from "./markdown-context";
|
|
8
10
|
|
|
9
11
|
const ICON = { full: FileCheckIcon, thin: FileWarningIcon, none: FileXIcon, running: FileClockIcon } as const;
|
|
10
12
|
/** Colour never stands alone: each state has its own icon and words. */
|
|
@@ -66,7 +68,7 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
|
|
|
66
68
|
<div className="space-y-3 rounded-2xl bg-[var(--feed-control)] p-3" data-capture-correction>
|
|
67
69
|
<label className="block text-sm font-medium">
|
|
68
70
|
The text
|
|
69
|
-
<
|
|
71
|
+
<AutoGrowTextarea
|
|
70
72
|
aria-label="Captured text"
|
|
71
73
|
className="mt-1 block min-h-40 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal"
|
|
72
74
|
data-capture-correction-text
|
|
@@ -92,7 +94,7 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
|
|
|
92
94
|
</div>
|
|
93
95
|
<label className="block text-sm font-medium">
|
|
94
96
|
Note (optional)
|
|
95
|
-
<
|
|
97
|
+
<AutoGrowTextarea aria-label="Note about the capture (optional)" className="mt-1 block min-h-16 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal" onChange={(event) => setNote(event.target.value)} value={note} />
|
|
96
98
|
</label>
|
|
97
99
|
{reason ? <p className="text-xs text-muted-foreground" data-capture-correction-reason>{reason}</p> : null}
|
|
98
100
|
{phase === "error" ? <p className="text-xs text-destructive" role="alert">{message}</p> : null}
|
|
@@ -113,7 +115,12 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
/** Our captured record of a Reference: what we stored of its content, said plainly, above the live source preview. */
|
|
116
|
-
export function ReferenceCapturePanel({ capture, startEditing = false }: {
|
|
118
|
+
export function ReferenceCapturePanel({ capture, showSummary = true, startEditing = false }: {
|
|
119
|
+
capture: CaptureView;
|
|
120
|
+
/** False where the summary is already shown above the rating, with the title. */
|
|
121
|
+
showSummary?: boolean;
|
|
122
|
+
startEditing?: boolean;
|
|
123
|
+
}) {
|
|
117
124
|
const [expanded, setExpanded] = useState(false);
|
|
118
125
|
const [editing, setEditing] = useState(startEditing && Boolean(capture.correct));
|
|
119
126
|
const [savedNote, setSavedNote] = useState(false);
|
|
@@ -137,10 +144,13 @@ export function ReferenceCapturePanel({ capture, startEditing = false }: { captu
|
|
|
137
144
|
</div>
|
|
138
145
|
{capture.loading ? <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" role="status"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />Reading the stored text…</p> : null}
|
|
139
146
|
{capture.error ? <p className="text-xs text-destructive" role="alert">The stored text could not be read. {capture.error}</p> : null}
|
|
140
|
-
{text
|
|
147
|
+
{/* While the editor is open it holds the text, so the read-only copy steps aside: the text is never shown twice. */}
|
|
148
|
+
{text && !editing ? (
|
|
141
149
|
<div className="space-y-2">
|
|
142
|
-
|
|
143
|
-
|
|
150
|
+
{/* No scroll box of its own: on a phone a swipe here scrolls the page. "Show all" grows it in place. */}
|
|
151
|
+
{/* Captured pages are stored as Markdown: render it (as React elements, never HTML) so headings and lists read as such. */}
|
|
152
|
+
<div className="rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text>
|
|
153
|
+
<MarkdownContext className="reference-capture-markdown" text={expanded || !long ? text : `${text.slice(0, PREVIEW_CHARACTERS)}…`} />
|
|
144
154
|
</div>
|
|
145
155
|
{long ? (
|
|
146
156
|
<button className="rounded-full bg-[var(--feed-control)] px-3 py-1.5 text-xs font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" data-capture-toggle onClick={() => setExpanded((value) => !value)} type="button">
|
|
@@ -149,7 +159,7 @@ export function ReferenceCapturePanel({ capture, startEditing = false }: { captu
|
|
|
149
159
|
) : null}
|
|
150
160
|
</div>
|
|
151
161
|
) : null}
|
|
152
|
-
{capture.summary ? (
|
|
162
|
+
{showSummary && capture.summary ? (
|
|
153
163
|
<div className="space-y-1" data-capture-summary>
|
|
154
164
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Summary{capture.summaryStale ? " (out of date: the text was replaced)" : ""}</h4>
|
|
155
165
|
<p className="text-sm">{capture.summary}</p>
|
|
@@ -81,6 +81,10 @@ export type ReferenceFeedProps = {
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
/** Columns follow the width the feed has (a container query, not the screen), so they stay put while a detail pane holds its place beside it. */
|
|
84
|
+
/** A selected card: a thick ring drawn outside it on every side (a box-shadow, so it takes no layout space and covers nothing inside
|
|
85
|
+
* the card). The grid's gap leaves room for it between cards. The fallback keeps the ring if a theme has no --primary. */
|
|
86
|
+
export const SELECTED_RING = { boxShadow: "0 0 0 4px var(--primary, currentColor)" } as const;
|
|
87
|
+
|
|
84
88
|
export const FEED_GRID = "grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-5";
|
|
85
89
|
const GRID = FEED_GRID;
|
|
86
90
|
/** The lead spans two columns only where there are at least two to span (two 17rem columns and the gap). */
|
|
@@ -115,7 +119,7 @@ function hueOf(name: string): number {
|
|
|
115
119
|
return Math.abs(hash) % 360;
|
|
116
120
|
}
|
|
117
121
|
|
|
118
|
-
/** A small mark for a source: its own
|
|
122
|
+
/** A small mark for a source: its own icon (found on the server, see lib/source-icon.ts) on a light tile when there is one, else its first letter. */
|
|
119
123
|
export function SourceMark({ host, size = "sm" }: { host: string | null; size?: "sm" | "lg" }) {
|
|
120
124
|
const [failed, setFailed] = useState(false);
|
|
121
125
|
const box = size === "lg" ? "size-16 rounded-2xl text-3xl" : "size-5 rounded-md text-[0.65rem]";
|
|
@@ -124,7 +128,7 @@ export function SourceMark({ host, size = "sm" }: { host: string | null; size?:
|
|
|
124
128
|
<span aria-hidden="true" className={cn("inline-flex shrink-0 items-center justify-center overflow-hidden bg-white font-bold uppercase text-neutral-700", box)} data-feed-source-mark>
|
|
125
129
|
{host && !failed ? (
|
|
126
130
|
// eslint-disable-next-line @next/next/no-img-element
|
|
127
|
-
<img alt="" className={size === "lg" ? "size-10 object-contain" : "size-3.5 object-contain"} loading="lazy" onError={() => setFailed(true)}
|
|
131
|
+
<img alt="" className={size === "lg" ? "size-10 object-contain" : "size-3.5 object-contain"} loading="lazy" onError={() => setFailed(true)} src={`/api/newsroom/source-icon?host=${encodeURIComponent(host)}`} />
|
|
128
132
|
) : letter}
|
|
129
133
|
</span>
|
|
130
134
|
);
|
|
@@ -254,17 +258,17 @@ export function FeedCard({
|
|
|
254
258
|
aria-current={selected ? "true" : undefined}
|
|
255
259
|
className={cn(
|
|
256
260
|
"relative flex flex-col overflow-hidden rounded-[20px] text-card-foreground transition-[background-color,box-shadow] duration-200",
|
|
257
|
-
selected ? "bg-[var(--feed-control)]
|
|
261
|
+
selected ? "bg-[var(--feed-control)]" : "bg-[var(--feed-card)]",
|
|
258
262
|
lead && LEAD_SPAN,
|
|
259
263
|
)}
|
|
260
264
|
data-feed-card={item.lineageId}
|
|
261
265
|
data-feed-lead={lead || undefined}
|
|
262
266
|
data-feed-rated={answer ?? undefined}
|
|
263
267
|
data-feed-selected={selected || undefined}
|
|
268
|
+
style={selected ? SELECTED_RING : undefined}
|
|
264
269
|
data-feed-visit-rated={visitRated || undefined}
|
|
265
270
|
data-newsroom-card-id={item.lineageId}
|
|
266
271
|
>
|
|
267
|
-
{selected ? <span aria-hidden="true" className="absolute inset-y-0 left-0 z-10 w-1.5 bg-[var(--primary)]" data-feed-selected-bar /> : null}
|
|
268
272
|
<button
|
|
269
273
|
aria-label={`Open: ${title}`}
|
|
270
274
|
className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "lucide-react";
|
|
12
12
|
import { useEffect, useRef, useState, type ReactNode, type PointerEvent as ReactPointerEvent } from "react";
|
|
13
13
|
import { Button } from "./ui/button";
|
|
14
|
+
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
14
15
|
import { cn } from "../lib/utils";
|
|
15
16
|
import { RatingGrid } from "./review-rating-grid";
|
|
16
17
|
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
@@ -132,11 +133,14 @@ export function predictionFromDecision(decision: { label: string; positiveLabel:
|
|
|
132
133
|
export function ReviewCard({
|
|
133
134
|
children,
|
|
134
135
|
decided,
|
|
136
|
+
dek,
|
|
135
137
|
disabledReason,
|
|
136
138
|
initialNote = "",
|
|
137
139
|
initialReason = null,
|
|
138
140
|
itemId,
|
|
141
|
+
meta,
|
|
139
142
|
onArchive,
|
|
143
|
+
sourcePreview,
|
|
140
144
|
onDecide,
|
|
141
145
|
prediction,
|
|
142
146
|
summary,
|
|
@@ -149,14 +153,20 @@ export function ReviewCard({
|
|
|
149
153
|
ratings?: RatingController;
|
|
150
154
|
/** The saved human answer, or null while the item is undecided. */
|
|
151
155
|
decided: ReviewChoice | null;
|
|
156
|
+
/** The source's own subtitle or blurb, shown under the title. */
|
|
157
|
+
dek?: string | null;
|
|
152
158
|
/** When set, the controls explain in words why they cannot change anything. */
|
|
153
159
|
disabledReason?: string | null;
|
|
154
160
|
initialNote?: string;
|
|
155
161
|
initialReason?: ReferenceRejectionReasonCode | null;
|
|
156
162
|
itemId: string;
|
|
163
|
+
/** Badges (type, status) shown under the title and link. */
|
|
164
|
+
meta?: ReactNode;
|
|
157
165
|
onArchive?: () => Promise<void> | void;
|
|
158
166
|
onDecide: (choice: ReviewChoice, decision: ReviewCardDecision) => Promise<void> | void;
|
|
159
167
|
prediction: CardPrediction;
|
|
168
|
+
/** The live preview of the source, shown under its link. It renders nothing when there is none. */
|
|
169
|
+
sourcePreview?: ReactNode;
|
|
160
170
|
summary?: string | null;
|
|
161
171
|
title: string;
|
|
162
172
|
titleHref?: string | null;
|
|
@@ -192,15 +202,24 @@ export function ReviewCard({
|
|
|
192
202
|
|
|
193
203
|
return (
|
|
194
204
|
<section aria-label="Review" className="space-y-4" data-review-card={itemId}>
|
|
195
|
-
|
|
205
|
+
{/* Title, blurb, link, badges, summary: what the item is, read top to bottom before the rating. */}
|
|
206
|
+
<header className="space-y-2">
|
|
196
207
|
<h2 className="text-lg font-semibold leading-snug text-foreground">{title}</h2>
|
|
208
|
+
{dek && dek.trim() !== title.trim() ? <p className="text-base text-muted-foreground" data-review-dek>{dek}</p> : null}
|
|
197
209
|
{titleHref ? (
|
|
198
210
|
<a className="inline-flex max-w-full items-center gap-1 truncate text-sm text-primary underline-offset-4 hover:underline" href={titleHref} rel="noreferrer" target="_blank">
|
|
199
211
|
<span className="truncate">{titleHref}</span>
|
|
200
212
|
<ExternalLinkIcon aria-hidden="true" className="size-3.5 shrink-0" />
|
|
201
213
|
</a>
|
|
202
214
|
) : null}
|
|
203
|
-
{
|
|
215
|
+
{sourcePreview}
|
|
216
|
+
{meta}
|
|
217
|
+
{summary ? (
|
|
218
|
+
<div className="space-y-1 pt-1" data-review-summary>
|
|
219
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Summary</h3>
|
|
220
|
+
<p className="text-sm leading-relaxed text-foreground">{summary}</p>
|
|
221
|
+
</div>
|
|
222
|
+
) : null}
|
|
204
223
|
</header>
|
|
205
224
|
|
|
206
225
|
<RatingGrid
|
|
@@ -217,9 +236,9 @@ export function ReviewCard({
|
|
|
217
236
|
|
|
218
237
|
<label className="block text-sm font-medium">
|
|
219
238
|
Why? (optional)
|
|
220
|
-
<
|
|
239
|
+
<AutoGrowTextarea
|
|
221
240
|
aria-label="Why? (optional)"
|
|
222
|
-
className="mt-1 block min-h-
|
|
241
|
+
className="mt-1 block min-h-20 w-full rounded-[var(--feed-radius-lg,10px)] bg-[var(--feed-control)] p-3 text-base font-normal outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
223
242
|
onBlur={commitNote}
|
|
224
243
|
onChange={(event) => store.setNote(itemId, event.target.value)}
|
|
225
244
|
placeholder="Say what made this an easy yes or a clear no. Your note teaches the Cyclotron."
|
|
@@ -232,22 +251,21 @@ export function ReviewCard({
|
|
|
232
251
|
<p className="text-sm font-medium">Reason for rejecting</p>
|
|
233
252
|
<div className="flex flex-wrap gap-2">
|
|
234
253
|
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
235
|
-
<
|
|
254
|
+
<button
|
|
236
255
|
aria-disabled={disabledReason ? true : undefined}
|
|
237
256
|
aria-pressed={reason === code}
|
|
238
|
-
className="min-h-10"
|
|
257
|
+
className={cn("inline-flex min-h-10 items-center gap-1.5 rounded-[var(--feed-radius-pill,8px)] px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", reason === code ? "bg-[var(--primary)] font-semibold text-[var(--primary-foreground)]" : "bg-[var(--feed-control)]")}
|
|
239
258
|
data-review-reason={code}
|
|
240
259
|
key={code}
|
|
241
260
|
onClick={() => chooseReason(code)}
|
|
242
261
|
type="button"
|
|
243
|
-
variant={reason === code ? "default" : "outline"}
|
|
244
262
|
>
|
|
245
263
|
{reason === code ? <CheckIcon aria-hidden="true" className="size-4" /> : null}
|
|
246
264
|
{REASON_LABELS[code]}
|
|
247
|
-
</
|
|
265
|
+
</button>
|
|
248
266
|
))}
|
|
249
267
|
</div>
|
|
250
|
-
{!reason ? <p className="text-sm text-muted-foreground">Choose a reason to save your Reject.</p> : null}
|
|
268
|
+
{!reason && !note.trim() ? <p className="text-sm text-muted-foreground">Choose a reason, or say why above, to save your Reject.</p> : null}
|
|
251
269
|
</div>
|
|
252
270
|
) : null}
|
|
253
271
|
|
|
@@ -87,6 +87,31 @@ function PdfSourcePreview({ preview }: { preview: Extract<ReferenceSourcePreview
|
|
|
87
87
|
);
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/** Session memory of the server's answers, so reopening an item does not ask again. */
|
|
91
|
+
const frameableAnswers = new Map<string, boolean>();
|
|
92
|
+
|
|
93
|
+
/** Whether the page allows being shown in a frame: null while asking. A refused frame would be an empty box, so it is not shown. */
|
|
94
|
+
function useFrameable(href: string | null): boolean | null {
|
|
95
|
+
const [answer, setAnswer] = useState<boolean | null>(() => (href ? frameableAnswers.get(href) ?? null : null));
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
if (!href) return undefined;
|
|
98
|
+
const known = frameableAnswers.get(href);
|
|
99
|
+
if (known !== undefined) { setAnswer(known); return undefined; }
|
|
100
|
+
setAnswer(null);
|
|
101
|
+
let active = true;
|
|
102
|
+
fetch(`/api/newsroom/source-frameable?url=${encodeURIComponent(href)}`)
|
|
103
|
+
.then((response) => (response.ok ? response.json() : { frameable: false }))
|
|
104
|
+
.then((body: { frameable?: unknown }) => { frameableAnswers.set(href, body.frameable === true); if (active) setAnswer(body.frameable === true); })
|
|
105
|
+
.catch(() => { if (active) setAnswer(false); });
|
|
106
|
+
return () => { active = false; };
|
|
107
|
+
}, [href]);
|
|
108
|
+
return answer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The live preview of the source, or nothing at all: a YouTube video or a PDF always has one; a web page only when the page allows
|
|
113
|
+
* being framed (asked of the server first). It sits under the link to the original, with no heading of its own.
|
|
114
|
+
*/
|
|
90
115
|
export function ReferenceSourcePreview({
|
|
91
116
|
attachments = [],
|
|
92
117
|
sourceUri,
|
|
@@ -95,6 +120,7 @@ export function ReferenceSourcePreview({
|
|
|
95
120
|
sourceUri?: string | null;
|
|
96
121
|
}) {
|
|
97
122
|
const preview = resolveReferenceSourcePreview(sourceUri, attachments);
|
|
123
|
+
const frameable = useFrameable(preview?.kind === "html" ? preview.href : null);
|
|
98
124
|
const pendingPdfPreview = !preview && attachments.some(
|
|
99
125
|
(attachment) => isPdfAttachment(attachment) && Boolean(attachment.storagePath),
|
|
100
126
|
);
|
|
@@ -139,6 +165,7 @@ export function ReferenceSourcePreview({
|
|
|
139
165
|
return <PdfSourcePreview preview={preview} />;
|
|
140
166
|
}
|
|
141
167
|
|
|
168
|
+
if (frameable !== true) return null;
|
|
142
169
|
return (
|
|
143
170
|
<div
|
|
144
171
|
className="news-desk-reference-source-preview news-desk-reference-source-preview--html"
|
|
@@ -153,11 +180,6 @@ export function ReferenceSourcePreview({
|
|
|
153
180
|
title={preview.label}
|
|
154
181
|
/>
|
|
155
182
|
</div>
|
|
156
|
-
<p className="news-desk-reference-source-preview__actions">
|
|
157
|
-
<a href={preview.href} rel="noopener noreferrer" target="_blank">
|
|
158
|
-
{preview.label}
|
|
159
|
-
</a>
|
|
160
|
-
</p>
|
|
161
183
|
</div>
|
|
162
184
|
);
|
|
163
185
|
}
|