@anthusai/papyrus 5.50.0 → 5.52.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/functions/content-actions/requirements.txt +1 -0
- package/amplify/functions/content-actions/resource.js +1 -1
- package/amplify/functions/content-actions/resource.ts +1 -1
- 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-feed.tsx +36 -14
- package/components/newsroom-access-gate.tsx +3 -3
- package/components/newsroom-ops-shell.tsx +0 -22
- package/components/newsroom-organizer.css +19 -0
- package/components/newsroom-references-view.tsx +7 -9
- package/components/reader-auth-control.tsx +3 -1
- package/components/reference-capture-client.ts +34 -6
- package/components/reference-capture-panel.tsx +108 -28
- package/components/reference-feed.tsx +8 -4
- package/components/topic-steering-workspace.tsx +3 -428
- package/lib/content-actions-client.ts +3 -1
- package/lib/newsroom-index-filters.ts +18 -0
- package/lib/newsroom-references.ts +25 -1
- package/lib/reference-capture.ts +4 -3
- package/lib/source-frameable.ts +15 -7
- package/lib/source-icon.ts +104 -0
- package/package.json +4 -1
- package/routes.manifest.json +12 -1
|
@@ -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 {
|
|
@@ -661,7 +661,6 @@ export function NewsroomReferencesView({
|
|
|
661
661
|
return (
|
|
662
662
|
<div
|
|
663
663
|
className="flex min-h-0 flex-1 flex-col gap-4 md:max-h-[100dvh] md:flex-row"
|
|
664
|
-
data-detail-reserved={view === "feed" ? "true" : undefined}
|
|
665
664
|
data-news-desk-section="references"
|
|
666
665
|
data-detail-open={selectedReference || routeLineageId ? "true" : "false"}
|
|
667
666
|
>
|
|
@@ -762,8 +761,9 @@ export function NewsroomReferencesView({
|
|
|
762
761
|
</button>
|
|
763
762
|
</div>
|
|
764
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. */}
|
|
765
765
|
<ScrollArea className="min-h-0 flex-1 [overflow-anchor:none]" data-reference-feed-scroll>
|
|
766
|
-
<div className="pb-6 pr-
|
|
766
|
+
<div className="px-1.5 pb-6 pt-1.5 pr-2.5">
|
|
767
767
|
<ReferenceFeed
|
|
768
768
|
disabledReason={feedDisabledReason}
|
|
769
769
|
loading={previewSkeleton || (loading && !references.length)}
|
|
@@ -871,7 +871,9 @@ export function NewsroomReferencesView({
|
|
|
871
871
|
|
|
872
872
|
<div
|
|
873
873
|
className={cn(
|
|
874
|
-
"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",
|
|
875
877
|
)}
|
|
876
878
|
data-newsroom-list-detail-shell
|
|
877
879
|
data-news-desk-section="references"
|
|
@@ -907,11 +909,7 @@ export function NewsroomReferencesView({
|
|
|
907
909
|
previewAttachments={previewAttachments}
|
|
908
910
|
reference={selectedReference}
|
|
909
911
|
/>
|
|
910
|
-
) : caughtUp ? caughtUpPanel :
|
|
911
|
-
<CardContent className="p-4 text-sm text-muted-foreground">
|
|
912
|
-
Select a reference to inspect curation details.
|
|
913
|
-
</CardContent>
|
|
914
|
-
)}
|
|
912
|
+
) : caughtUp ? caughtUpPanel : null}
|
|
915
913
|
</ScrollArea>
|
|
916
914
|
</div>
|
|
917
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 () => {
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
captureStatus, countWords, curationCardOf, grobidRows, primaryTextAttachment, structuredRows,
|
|
8
8
|
type CaptureAssessment as Assessment, type CaptureStatus, type CurationCard,
|
|
9
9
|
} from "../lib/reference-capture";
|
|
10
|
-
import { loadModelPayloadsForOwner, loadStoragePathText } from "./news-desk-taxonomy-client";
|
|
10
|
+
import { loadModelPayloadsForOwner, loadStoragePathText, uploadModelPayloadForOwner } from "./news-desk-taxonomy-client";
|
|
11
11
|
|
|
12
12
|
export type CaptureAssessment = Assessment;
|
|
13
13
|
|
|
@@ -23,8 +23,16 @@ export type CaptureView = {
|
|
|
23
23
|
rows: Array<{ label: string; value: string }>;
|
|
24
24
|
/** Saves an editor's pasted or corrected text and their verdict on the capture (the `correctReferenceCapture` action). */
|
|
25
25
|
correct?: (input: { text?: string; assessment: CaptureAssessment; note?: string }) => Promise<void>;
|
|
26
|
+
/** True when the current text was read from a PDF the editor uploaded. */
|
|
27
|
+
fromPdf: boolean;
|
|
28
|
+
/** Stores a PDF the editor downloaded as an attachment of the Reference, then has the server read its text (`correctReferenceCapture` with `pdfAttachmentId`). */
|
|
29
|
+
uploadPdf?: (input: { file: File; assessment: CaptureAssessment; note?: string }, onStep?: (step: "uploading" | "reading") => void) => Promise<void>;
|
|
26
30
|
};
|
|
27
31
|
|
|
32
|
+
/** The server reads PDFs up to this size (ideas.source_text.MAXIMUM_PDF_BYTES); say so before a slow upload that would be refused. */
|
|
33
|
+
export const MAXIMUM_PDF_UPLOAD_BYTES = 8_000_000;
|
|
34
|
+
export const PDF_UPLOAD_ROLE = "curation-upload-pdf";
|
|
35
|
+
|
|
28
36
|
function previewText(attachment: ReferenceAttachmentRecord): string | null {
|
|
29
37
|
// Local preview only: a fixture can carry the captured text inline instead of in storage.
|
|
30
38
|
if (!isLocalPreviewEnabled()) return null;
|
|
@@ -42,7 +50,7 @@ type Payloads = { key: string; text: string | null; grobid: unknown; summary: st
|
|
|
42
50
|
* Reads what the newsroom stored for one Reference. The curation card comes first (status, word count, signals, summary, GROBID fields,
|
|
43
51
|
* and the `curation-text` body its `current` points at); the older `extracted_text` attachment is the fallback.
|
|
44
52
|
*/
|
|
45
|
-
export type CorrectCaptureAction = (input: { referenceId: string; text?: string; assessment: CaptureAssessment; note?: string }) => Promise<unknown>;
|
|
53
|
+
export type CorrectCaptureAction = (input: { referenceId: string; text?: string; pdfAttachmentId?: string; assessment: CaptureAssessment; note?: string }) => Promise<unknown>;
|
|
46
54
|
|
|
47
55
|
export function useReferenceCapture(reference: ReferenceRecord, attachments: readonly ReferenceAttachmentRecord[], teaserLine: number, correctAction?: CorrectCaptureAction): CaptureView {
|
|
48
56
|
const card: CurationCard | null = useMemo(() => curationCardOf(attachments), [attachments]);
|
|
@@ -51,7 +59,7 @@ export function useReferenceCapture(reference: ReferenceRecord, attachments: rea
|
|
|
51
59
|
const grobidSortKey = card?.current?.grobidSortKey ?? card?.grobid?.fieldsSortKey ?? null;
|
|
52
60
|
const [payloads, setPayloads] = useState<Payloads>({ key: "", text: null, grobid: null, summary: null, loaded: false });
|
|
53
61
|
const [fallback, setFallback] = useState<{ key: string; text: string | null; error: string | null; loading: boolean }>({ key: "", text: null, error: null, loading: false });
|
|
54
|
-
const [corrected, setCorrected] = useState<{ key: string; text: string; assessment: CaptureAssessment } | null>(null);
|
|
62
|
+
const [corrected, setCorrected] = useState<{ key: string; text: string; assessment: CaptureAssessment; fromPdf?: boolean } | null>(null);
|
|
55
63
|
const referenceKey = `${reference.id}:${textSortKey ?? ""}:${grobidSortKey ?? ""}`;
|
|
56
64
|
|
|
57
65
|
// The card's bodies and the Reference's metadata.json, in one read of the Reference's ModelAttachments.
|
|
@@ -92,6 +100,7 @@ export function useReferenceCapture(reference: ReferenceRecord, attachments: rea
|
|
|
92
100
|
|
|
93
101
|
const current = payloads.key === referenceKey ? payloads : null;
|
|
94
102
|
const mine = corrected && corrected.key === reference.id ? corrected : null;
|
|
103
|
+
const fromPdf = mine ? mine.fromPdf === true : card?.current?.textVia === "editor_pdf";
|
|
95
104
|
const text = mine?.text ?? (textSortKey ? current?.text ?? null : fallback.key === fallbackKey ? fallback.text : null);
|
|
96
105
|
const loading = mine ? false : textSortKey ? !(current?.loaded) : useFallback && fallback.key === fallbackKey ? fallback.loading : useFallback;
|
|
97
106
|
const error = textSortKey || mine ? null : fallback.key === fallbackKey ? fallback.error : null;
|
|
@@ -103,8 +112,27 @@ export function useReferenceCapture(reference: ReferenceRecord, attachments: rea
|
|
|
103
112
|
|
|
104
113
|
const correct = useCallback(async (input: { text?: string; assessment: CaptureAssessment; note?: string }) => {
|
|
105
114
|
if (correctAction) await correctAction({ referenceId: reference.id, ...(input.text !== undefined ? { text: input.text } : {}), assessment: input.assessment, ...(input.note ? { note: input.note } : {}) });
|
|
106
|
-
setCorrected({ key: reference.id, text: input.text ?? text ?? "", assessment: input.assessment });
|
|
107
|
-
}, [reference.id, text, correctAction]);
|
|
115
|
+
setCorrected({ key: reference.id, text: input.text ?? text ?? "", assessment: input.assessment, fromPdf: input.text === undefined && fromPdf });
|
|
116
|
+
}, [reference.id, text, fromPdf, correctAction]);
|
|
117
|
+
|
|
118
|
+
const uploadPdf = useCallback(async (input: { file: File; assessment: CaptureAssessment; note?: string }, onStep?: (step: "uploading" | "reading") => void) => {
|
|
119
|
+
if (!correctAction) throw new Error("Uploading is not available here.");
|
|
120
|
+
if (input.file.size > MAXIMUM_PDF_UPLOAD_BYTES) throw new Error(`That PDF is larger than we read (${MAXIMUM_PDF_UPLOAD_BYTES / 1_000_000} MB). Paste the text instead.`);
|
|
121
|
+
onStep?.("uploading");
|
|
122
|
+
const attachment = await uploadModelPayloadForOwner({
|
|
123
|
+
ownerKind: "reference", ownerId: reference.id, ownerLineageId: reference.lineageId ?? reference.id, ownerVersionNumber: reference.versionNumber ?? null,
|
|
124
|
+
ownerVersionKey: `reference#${reference.id}`, role: PDF_UPLOAD_ROLE, sortKey: `${PDF_UPLOAD_ROLE}-${Date.now().toString(36)}`,
|
|
125
|
+
filename: input.file.name || "upload.pdf", mediaType: "application/pdf", content: input.file,
|
|
126
|
+
});
|
|
127
|
+
onStep?.("reading");
|
|
128
|
+
const result = await correctAction({ referenceId: reference.id, pdfAttachmentId: attachment.id, assessment: input.assessment, ...(input.note ? { note: input.note } : {}) });
|
|
129
|
+
// The server read the text; fetch the new body it stored so the panel shows it.
|
|
130
|
+
const sortKey = (result as { edits?: Array<{ sortKey?: string; kind?: string }> } | null | undefined)?.edits?.find((edit) => edit.kind === "text")?.sortKey;
|
|
131
|
+
const rows = sortKey ? await loadModelPayloadsForOwner("reference", reference.id, ["metadata"]) : [];
|
|
132
|
+
const stored = rows.find((row) => row.attachment.sortKey === sortKey)?.text;
|
|
133
|
+
if (typeof stored !== "string") throw new Error("The PDF was read, but its text could not be loaded. Reload to see it.");
|
|
134
|
+
setCorrected({ key: reference.id, text: stored, assessment: input.assessment, fromPdf: true });
|
|
135
|
+
}, [reference.id, reference.lineageId, reference.versionNumber, correctAction]);
|
|
108
136
|
|
|
109
|
-
return { status, text, loading, error, summary, summaryStale: Boolean(mine) || status.summaryStale === true, rows, correct: correctAction || isLocalPreviewEnabled() ? correct : undefined };
|
|
137
|
+
return { status, text, loading, error, summary, summaryStale: Boolean(mine) || status.summaryStale === true, rows, fromPdf, correct: correctAction || isLocalPreviewEnabled() ? correct : undefined, uploadPdf: correctAction ? uploadPdf : undefined };
|
|
110
138
|
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { FileCheckIcon, FileClockIcon, FileWarningIcon, FileXIcon, Loader2Icon } from "lucide-react";
|
|
4
|
-
import { useEffect, useState } from "react";
|
|
3
|
+
import { FileCheckIcon, FileClockIcon, FileUpIcon, FileWarningIcon, FileXIcon, Loader2Icon } from "lucide-react";
|
|
4
|
+
import { useEffect, useRef, 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
7
|
import { AutoGrowTextarea } from "./ui/auto-grow-textarea";
|
|
8
8
|
import type { CaptureView } from "./reference-capture-client";
|
|
9
|
+
import { MarkdownContext } from "./markdown-context";
|
|
9
10
|
|
|
10
11
|
const ICON = { full: FileCheckIcon, thin: FileWarningIcon, none: FileXIcon, running: FileClockIcon } as const;
|
|
11
12
|
/** Colour never stands alone: each state has its own icon and words. */
|
|
@@ -41,6 +42,87 @@ const ASSESSMENTS: Array<{ key: CaptureAssessment; label: string; hint: string }
|
|
|
41
42
|
{ key: "incorrect", label: "Incorrect", hint: "This is not the right text." },
|
|
42
43
|
];
|
|
43
44
|
|
|
45
|
+
/** The editor's verdict on the machine capture; asked the same way whether they paste text or upload a PDF. */
|
|
46
|
+
function AssessmentChoice({ value, onChange }: { value: CaptureAssessment | null; onChange: (value: CaptureAssessment) => void }) {
|
|
47
|
+
return (
|
|
48
|
+
<div aria-label="Was the capture complete?" className="flex flex-wrap gap-2" role="group">
|
|
49
|
+
{ASSESSMENTS.map((option) => (
|
|
50
|
+
<button
|
|
51
|
+
aria-pressed={value === option.key}
|
|
52
|
+
className={cn("inline-flex min-h-9 items-center rounded-full px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", value === option.key ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--feed-card)]")}
|
|
53
|
+
data-capture-assessment={option.key}
|
|
54
|
+
key={option.key}
|
|
55
|
+
onClick={() => onChange(option.key)}
|
|
56
|
+
title={option.hint}
|
|
57
|
+
type="button"
|
|
58
|
+
>
|
|
59
|
+
{option.label}
|
|
60
|
+
</button>
|
|
61
|
+
))}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "Upload a PDF": the editor's verdict, then the PDF they downloaded themselves; the server reads its text. */
|
|
67
|
+
function PdfUploadForm({ capture, onDone, onCancel }: { capture: CaptureView; onDone: () => void; onCancel: () => void }) {
|
|
68
|
+
const [assessment, setAssessment] = useState<CaptureAssessment | null>(null);
|
|
69
|
+
const [note, setNote] = useState("");
|
|
70
|
+
const [phase, setPhase] = useState<"idle" | "uploading" | "reading" | "error">("idle");
|
|
71
|
+
const [message, setMessage] = useState<string | null>(null);
|
|
72
|
+
const input = useRef<HTMLInputElement>(null);
|
|
73
|
+
const reason = !assessment ? "Say whether the capture was complete, incomplete or incorrect, then choose the PDF." : null;
|
|
74
|
+
const busy = phase === "uploading" || phase === "reading";
|
|
75
|
+
async function upload(file: File) {
|
|
76
|
+
if (!assessment || !capture.uploadPdf) return;
|
|
77
|
+
setMessage(null);
|
|
78
|
+
try {
|
|
79
|
+
await capture.uploadPdf({ file, assessment, ...(note.trim() ? { note: note.trim() } : {}) }, (step) => setPhase(step));
|
|
80
|
+
onDone();
|
|
81
|
+
} catch (failure) {
|
|
82
|
+
setPhase("error");
|
|
83
|
+
setMessage(failure instanceof Error ? failure.message : "The PDF could not be uploaded.");
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return (
|
|
87
|
+
<div className="space-y-3 rounded-2xl bg-[var(--feed-control)] p-3" data-capture-upload-form>
|
|
88
|
+
<p className="text-sm font-medium">Upload the PDF you downloaded. Its text replaces the capture, and the capture is kept.</p>
|
|
89
|
+
<AssessmentChoice onChange={setAssessment} value={assessment} />
|
|
90
|
+
<label className="block text-sm font-medium">
|
|
91
|
+
Note (optional)
|
|
92
|
+
<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} />
|
|
93
|
+
</label>
|
|
94
|
+
<input
|
|
95
|
+
accept="application/pdf"
|
|
96
|
+
aria-label="PDF file"
|
|
97
|
+
className="hidden"
|
|
98
|
+
data-capture-upload-input
|
|
99
|
+
onChange={(event) => {
|
|
100
|
+
const file = event.target.files?.[0];
|
|
101
|
+
event.target.value = "";
|
|
102
|
+
if (file) void upload(file);
|
|
103
|
+
}}
|
|
104
|
+
ref={input}
|
|
105
|
+
type="file"
|
|
106
|
+
/>
|
|
107
|
+
{reason && !busy ? <p className="text-xs text-muted-foreground" data-capture-upload-reason>{reason}</p> : null}
|
|
108
|
+
{busy ? <p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground" data-capture-upload-status role="status"><Loader2Icon aria-hidden="true" className="size-3.5 animate-spin" />{phase === "uploading" ? "Uploading the PDF…" : "Reading the text from the PDF…"}</p> : null}
|
|
109
|
+
{phase === "error" ? <p className="text-xs text-destructive" data-capture-upload-error role="alert">{message}</p> : null}
|
|
110
|
+
<div className="flex gap-2">
|
|
111
|
+
<button
|
|
112
|
+
aria-disabled={reason || busy ? true : undefined}
|
|
113
|
+
className={cn("inline-flex min-h-9 items-center gap-1.5 rounded-full bg-[var(--primary)] px-4 text-sm font-semibold text-[var(--primary-foreground)] outline-none focus-visible:ring-2 focus-visible:ring-ring", (reason || busy) && "opacity-60")}
|
|
114
|
+
data-capture-upload-choose
|
|
115
|
+
onClick={() => { if (!reason && !busy) input.current?.click(); }}
|
|
116
|
+
type="button"
|
|
117
|
+
>
|
|
118
|
+
<FileUpIcon aria-hidden="true" className="size-4" />Choose the PDF
|
|
119
|
+
</button>
|
|
120
|
+
<button className="inline-flex min-h-9 items-center rounded-full bg-[var(--feed-card)] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" disabled={busy} onClick={onCancel} type="button">Cancel</button>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
44
126
|
/** "Paste or correct the text": an editor's own text and verdict on the capture, saved with `correctReferenceCapture`. */
|
|
45
127
|
function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: () => void }) {
|
|
46
128
|
const [text, setText] = useState(capture.text ?? "");
|
|
@@ -76,21 +158,7 @@ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: ()
|
|
|
76
158
|
value={text}
|
|
77
159
|
/>
|
|
78
160
|
</label>
|
|
79
|
-
<
|
|
80
|
-
{ASSESSMENTS.map((option) => (
|
|
81
|
-
<button
|
|
82
|
-
aria-pressed={assessment === option.key}
|
|
83
|
-
className={cn("inline-flex min-h-9 items-center rounded-full px-3 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring", assessment === option.key ? "bg-[var(--primary)] text-[var(--primary-foreground)]" : "bg-[var(--feed-card)]")}
|
|
84
|
-
data-capture-assessment={option.key}
|
|
85
|
-
key={option.key}
|
|
86
|
-
onClick={() => setAssessment(option.key)}
|
|
87
|
-
title={option.hint}
|
|
88
|
-
type="button"
|
|
89
|
-
>
|
|
90
|
-
{option.label}
|
|
91
|
-
</button>
|
|
92
|
-
))}
|
|
93
|
-
</div>
|
|
161
|
+
<AssessmentChoice onChange={setAssessment} value={assessment} />
|
|
94
162
|
<label className="block text-sm font-medium">
|
|
95
163
|
Note (optional)
|
|
96
164
|
<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} />
|
|
@@ -122,6 +190,7 @@ export function ReferenceCapturePanel({ capture, showSummary = true, startEditin
|
|
|
122
190
|
}) {
|
|
123
191
|
const [expanded, setExpanded] = useState(false);
|
|
124
192
|
const [editing, setEditing] = useState(startEditing && Boolean(capture.correct));
|
|
193
|
+
const [uploading, setUploading] = useState(false);
|
|
125
194
|
const [savedNote, setSavedNote] = useState(false);
|
|
126
195
|
const line = captureLine(capture.status);
|
|
127
196
|
const Icon = ICON[line.state];
|
|
@@ -144,11 +213,13 @@ export function ReferenceCapturePanel({ capture, showSummary = true, startEditin
|
|
|
144
213
|
{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}
|
|
145
214
|
{capture.error ? <p className="text-xs text-destructive" role="alert">The stored text could not be read. {capture.error}</p> : null}
|
|
146
215
|
{/* While the editor is open it holds the text, so the read-only copy steps aside: the text is never shown twice. */}
|
|
216
|
+
{text && capture.fromPdf ? <p className="text-xs font-medium" data-capture-from-pdf>Text from a PDF you uploaded</p> : null}
|
|
147
217
|
{text && !editing ? (
|
|
148
218
|
<div className="space-y-2">
|
|
149
219
|
{/* No scroll box of its own: on a phone a swipe here scrolls the page. "Show all" grows it in place. */}
|
|
150
|
-
|
|
151
|
-
|
|
220
|
+
{/* Captured pages are stored as Markdown: render it (as React elements, never HTML) so headings and lists read as such. */}
|
|
221
|
+
<div className="rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text>
|
|
222
|
+
<MarkdownContext className="reference-capture-markdown" text={expanded || !long ? text : `${text.slice(0, PREVIEW_CHARACTERS)}…`} />
|
|
152
223
|
</div>
|
|
153
224
|
{long ? (
|
|
154
225
|
<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">
|
|
@@ -173,15 +244,24 @@ export function ReferenceCapturePanel({ capture, showSummary = true, startEditin
|
|
|
173
244
|
))}
|
|
174
245
|
</dl>
|
|
175
246
|
) : null}
|
|
176
|
-
{capture.correct ? (
|
|
177
|
-
editing ? <CorrectionForm capture={capture} onDone={() => { setEditing(false); setSavedNote(true); }} />
|
|
178
|
-
<
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
247
|
+
{capture.correct || capture.uploadPdf ? (
|
|
248
|
+
editing && capture.correct ? <CorrectionForm capture={capture} onDone={() => { setEditing(false); setSavedNote(true); }} />
|
|
249
|
+
: uploading && capture.uploadPdf ? <PdfUploadForm capture={capture} onCancel={() => setUploading(false)} onDone={() => { setUploading(false); setSavedNote(true); }} />
|
|
250
|
+
: (
|
|
251
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
252
|
+
{capture.correct ? (
|
|
253
|
+
<button className="inline-flex min-h-9 items-center rounded-full bg-[var(--feed-control)] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" data-capture-correct onClick={() => { setSavedNote(false); setEditing(true); }} type="button">
|
|
254
|
+
Paste or correct the text
|
|
255
|
+
</button>
|
|
256
|
+
) : null}
|
|
257
|
+
{capture.uploadPdf ? (
|
|
258
|
+
<button className="inline-flex min-h-9 items-center gap-1.5 rounded-full bg-[var(--feed-control)] px-4 text-sm font-medium outline-none focus-visible:ring-2 focus-visible:ring-ring" data-capture-upload onClick={() => { setSavedNote(false); setUploading(true); }} type="button">
|
|
259
|
+
<FileUpIcon aria-hidden="true" className="size-4" />Upload a PDF
|
|
260
|
+
</button>
|
|
261
|
+
) : null}
|
|
262
|
+
{savedNote ? <span className="text-xs text-muted-foreground" data-capture-correction-saved role="status">Saved. Your verdict trains the completeness check.</span> : null}
|
|
263
|
+
</div>
|
|
264
|
+
)
|
|
185
265
|
) : null}
|
|
186
266
|
</section>
|
|
187
267
|
);
|
|
@@ -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"
|