@anthusai/papyrus 5.43.0-next.16 → 5.43.0-next.17

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.
@@ -6,7 +6,7 @@ import { ReferenceCurationControls, useReferenceImport } from "./reference-curat
6
6
 
7
7
  import { ExternalLinkIcon, LayoutGridIcon, ListIcon, PlusIcon } from "lucide-react";
8
8
  import { usePathname } from "next/navigation";
9
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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
12
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card";
@@ -43,7 +43,11 @@ import { normalizeReferenceCurationStatus, referenceReasonCode, type ReferenceRe
43
43
  import { newsroomListRowClassName } from "../lib/newsroom-list-selection";
44
44
  import { ReferenceSourcePreview } from "./reference-source-preview";
45
45
  import { FEED_SURFACE_PROPS, FEED_SURFACE_STYLE, ReferenceFeed } from "./reference-feed";
46
- import { partitionReferenceFeed, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
46
+ import { ReferenceCapturePanel } from "./reference-capture-panel";
47
+ import { useReferenceCapture, type CorrectCaptureAction } from "./reference-capture-client";
48
+ import { useRatingStore, type RatingController } from "./use-reference-ratings";
49
+ import { captureStatusesByLineage } from "../lib/reference-capture";
50
+ import { applyFeedOrderSnapshot, partitionReferenceFeed, takeFeedOrderSnapshot, type FeedOrderSnapshot, type ReferenceFeedTabKey } from "../lib/reference-feed-ranking";
47
51
  import { referencePreviewsByLineage, type ReferencePreview } from "../lib/reference-preview";
48
52
  import { isLocalPreviewEnabled } from "../lib/local-preview";
49
53
  import { chooseSyntheticRejects, withSyntheticRejects } from "../lib/local-preview-synthetic-rejects";
@@ -128,6 +132,8 @@ type NewsroomReferencesViewProps = {
128
132
  onReview: (reference: ReferenceRecord, action: ReferenceCurationAction, options?: ReferenceReviewOptions) => Promise<void> | void;
129
133
  referenceAttachments?: ReferenceAttachmentRecord[];
130
134
  references: ReferenceRecord[];
135
+ /** Saves an editor's pasted or corrected capture text and their verdict on it. Optional: without it the capture panel only reads. */
136
+ onCorrectCapture?: CorrectCaptureAction;
131
137
  /** True while the References are still being read: the feed shows card skeletons instead of an empty page. */
132
138
  loading?: boolean;
133
139
  /** The last view this browser saved. Shown, read only, until the live read arrives. */
@@ -291,6 +297,10 @@ function cardPrediction(decision: RelevanceDecisionView | undefined, predictionS
291
297
  }
292
298
 
293
299
  function ReferenceDetailPanel({
300
+ captureTeaserLine,
301
+ correctCapture,
302
+ ratings,
303
+ sectionChoices,
294
304
  countsAsTrainingLabel,
295
305
  delegated,
296
306
  decision,
@@ -306,6 +316,13 @@ function ReferenceDetailPanel({
306
316
  sectionSuggestion,
307
317
  sections = [],
308
318
  }: {
319
+ captureTeaserLine: number;
320
+ /** Saves a pasted or corrected capture (the `correctReferenceCapture` action). Without it the panel has no edit action. */
321
+ correctCapture?: CorrectCaptureAction;
322
+ /** The rating store shared with the grid cards. */
323
+ ratings?: RatingController;
324
+ /** Where this panel records the Section chosen for an accept, so the shared store's save can include it. */
325
+ sectionChoices?: MutableRefObject<Map<string, string | null>>;
309
326
  countsAsTrainingLabel?: boolean;
310
327
  delegated?: DelegatedCuration;
311
328
  decision?: RelevanceDecisionView;
@@ -333,6 +350,8 @@ function ReferenceDetailPanel({
333
350
  if (!sectionEdited.current) setChosenSectionId(reviewSectionId ?? null);
334
351
  }, [reviewSectionId]);
335
352
  const url = referenceUrl(reference);
353
+ useEffect(() => { sectionChoices?.current.set(lineageId, chosenSectionId); }, [chosenSectionId, lineageId, sectionChoices]);
354
+ const capture = useReferenceCapture(reference, previewAttachments, captureTeaserLine, correctCapture);
336
355
 
337
356
  async function decide(choice: ReviewChoice, review: ReviewCardDecision) {
338
357
  const action: ReferenceCurationAction = choice;
@@ -362,6 +381,7 @@ function ReferenceDetailPanel({
362
381
  initialNote={sourceNote}
363
382
  initialReason={sourceReason}
364
383
  itemId={lineageId}
384
+ ratings={ratings}
365
385
  onArchive={status === "archived" ? undefined : async () => { await onReview("archive"); }}
366
386
  onDecide={decide}
367
387
  prediction={cardPrediction(decision, predictionState)}
@@ -392,6 +412,8 @@ function ReferenceDetailPanel({
392
412
 
393
413
  <Separator />
394
414
 
415
+ <ReferenceCapturePanel capture={capture} />
416
+
395
417
  <section aria-label="Source preview" className="space-y-2">
396
418
  <h3 className="text-sm font-medium text-foreground">Source preview</h3>
397
419
  <ReferenceSourcePreview attachments={previewAttachments} sourceUri={reference.sourceUri} />
@@ -477,6 +499,7 @@ export function NewsroomReferencesView({
477
499
  referenceAttachments = [],
478
500
  references: liveReferences,
479
501
  loading = false,
502
+ onCorrectCapture,
480
503
  savedCopy = null,
481
504
  }: NewsroomReferencesViewProps) {
482
505
  const pathname = usePathname();
@@ -544,10 +567,21 @@ export function NewsroomReferencesView({
544
567
  );
545
568
 
546
569
  const canonicalReferences = useMemo(() => selectCanonicalReferenceRecords(references), [references]);
547
- const partition = useMemo(
570
+ const livePartition = useMemo(
548
571
  () => partitionReferenceFeed(canonicalReferences, decisions, { now: mountedAt, visitRated, tauDaysOverride }),
549
572
  [canonicalReferences, decisions, mountedAt, tauDaysOverride, visitRated],
550
573
  );
574
+ // The feed's order is taken ONCE per visit, when the feed has loaded (References and predictions), and then left alone. Selecting an
575
+ // item, rating one, a background refresh or a cache revalidation changes cards' content, never their places. Items that arrive later
576
+ // wait behind a "N new items: show" pill. (The Rated tab is a live list: it is the one place a new answer is meant to appear.)
577
+ const orderSnapshot = useRef<FeedOrderSnapshot | null>(null);
578
+ const [, bumpOrder] = useState(0);
579
+ const orderReady = !loading && !showingSavedCopy && !previewSkeleton && predictionState !== "loading" && canonicalReferences.length > 0;
580
+ if (!orderSnapshot.current && orderReady) orderSnapshot.current = takeFeedOrderSnapshot(livePartition);
581
+ const stable = orderSnapshot.current ? applyFeedOrderSnapshot(orderSnapshot.current, livePartition) : null;
582
+ const partition = stable?.partition ?? livePartition;
583
+ const showNewItems = () => { orderSnapshot.current = takeFeedOrderSnapshot(livePartition); bumpOrder((count) => count + 1); };
584
+ const [scrollTarget, setScrollTarget] = useState<{ id: string; nonce: number } | null>(null);
551
585
  const feedOrderIds = useMemo(
552
586
  () => (feedTab === "feed" ? [...partition.feed, ...partition.earlier] : feedTab === "rated" ? partition.rated : partition.earlier).map((item) => item.lineageId),
553
587
  [feedTab, partition],
@@ -635,7 +669,7 @@ export function NewsroomReferencesView({
635
669
  };
636
670
 
637
671
  const goNext = () => {
638
- if (nextLineageId) selectReference(nextLineageId);
672
+ if (nextLineageId) { selectReference(nextLineageId); setScrollTarget({ id: nextLineageId, nonce: Date.now() }); }
639
673
  else if (queue.length && !caughtUp) {
640
674
  // Past the last item: say so, rather than showing an empty page.
641
675
  setCaughtUp(true);
@@ -644,7 +678,7 @@ export function NewsroomReferencesView({
644
678
  } else if (!queue.length) setCaughtUp(true);
645
679
  };
646
680
  const goPrevious = () => {
647
- if (previousLineageId) selectReference(previousLineageId);
681
+ if (previousLineageId) { selectReference(previousLineageId); setScrollTarget({ id: previousLineageId, nonce: Date.now() }); }
648
682
  };
649
683
  const gestures = useQueueGestures({ onNext: goNext, onPrevious: goPrevious });
650
684
 
@@ -671,6 +705,47 @@ export function NewsroomReferencesView({
671
705
  ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
672
706
  });
673
707
  };
708
+ // One rating store for the whole view: the grid cards and the detail pane read and write the same state, so a vote or a reason
709
+ // entered in either shows in both. Reason then vote, or vote then reason, both end up on one decision.
710
+ const sectionChoices = useRef<Map<string, string | null>>(new Map());
711
+ const referenceById = (lineageId: string) => selectedReferenceRecordByLineage(canonicalReferences, lineageId);
712
+ const ratings = useRatingStore({
713
+ save: async (lineageId, choice, detail) => {
714
+ const reference = referenceById(lineageId);
715
+ if (!reference) throw new Error("This reference is no longer in the list.");
716
+ markVisitRated(lineageId);
717
+ const decision = decisions?.get(lineageId);
718
+ const sectionId = choice === "accept" ? sectionChoices.current.get(lineageId) : null;
719
+ await onReview(reference, choice, {
720
+ note: detail.note || undefined,
721
+ reasonCode: choice === "reject" ? detail.reasonCode : null,
722
+ ...(sectionId ? { sectionId } : {}),
723
+ ...(decision ? { decisionRelationId: decision.decisionRelationId, shareable: false } : {}),
724
+ });
725
+ },
726
+ saved: (lineageId) => {
727
+ const status = normalizeReferenceCurationStatus(referenceById(lineageId)?.curationStatus);
728
+ return status === "accepted" ? "accept" : status === "rejected" ? "reject" : null;
729
+ },
730
+ seed: (lineageId) => {
731
+ const reference = referenceById(lineageId);
732
+ const status = normalizeReferenceCurationStatus(reference?.curationStatus);
733
+ return {
734
+ note: reference && status !== "pending" ? reference.curationStatusReason ?? "" : "",
735
+ reasonCode: reviewReasons?.get(lineageId) ?? (reference ? referenceReasonCode(reference) : null) ?? null,
736
+ };
737
+ },
738
+ });
739
+ const { statuses: captureStatuses, teaserLine } = useMemo(
740
+ () => captureStatusesByLineage(referenceAttachments, canonicalReferences.map(referenceLineageId)),
741
+ [canonicalReferences, referenceAttachments],
742
+ );
743
+ // Preview only: a fixture can carry captured text inline; say so, since production stores none for monitored References.
744
+ const previewCapturedText = useMemo(
745
+ () => isLocalPreviewEnabled() && referenceAttachments.some((attachment) => attachment.role === "extracted_text" && String(attachment.storagePath ?? "").startsWith("preview://")),
746
+ [referenceAttachments],
747
+ );
748
+ const showCaptures = !showingSavedCopy && !loading && !previewSkeleton;
674
749
  const feedDisabledReason = disabled
675
750
  ? "Editor access is required to change this reference."
676
751
  : showingSavedCopy ? "Confirming your sign-in. You can answer in a moment." : null;
@@ -689,9 +764,14 @@ export function NewsroomReferencesView({
689
764
  ? "No references match this filter."
690
765
  : "No private references imported yet.";
691
766
 
767
+ // The grid must not move when a detail opens, so in the feed the detail pane's place is reserved all the time (from the md breakpoint
768
+ // up): opening it changes nothing, and the grid's column count follows the width it has beside that place, never the selection. Below
769
+ // md the detail is a sheet over the grid. There is no overlay mode: an overlay would hide the cards Previous / Next scrolls to.
770
+
692
771
  return (
693
772
  <div
694
773
  className="flex min-h-0 flex-1 flex-col gap-4 md:max-h-[calc(100dvh-8.5rem)] md:flex-row"
774
+ data-detail-reserved={view === "feed" ? "true" : undefined}
695
775
  data-news-desk-section="references"
696
776
  data-detail-open={selectedReference || routeLineageId ? "true" : "false"}
697
777
  >
@@ -744,6 +824,11 @@ export function NewsroomReferencesView({
744
824
  Preview only: {synthetic.changed} of these predictions are made up to show the Reject end of the feed. Real data has none yet.
745
825
  </p>
746
826
  ) : null}
827
+ {previewCapturedText ? (
828
+ <p className="rounded-2xl bg-[var(--feed-card)] p-3 text-sm" data-preview-captured-text role="status" {...FEED_SURFACE_PROPS}>
829
+ Preview only: the captured text here was fetched live for this preview. In the newsroom record none of these References has stored text yet.
830
+ </p>
831
+ ) : null}
747
832
  {predictionState === "loading" && view === "list" ? <p role="status">Loading predictions…</p> : null}
748
833
  {predictionState === "error" ? (
749
834
  <div role="alert" className="space-y-2 rounded-2xl bg-[var(--feed-card)] p-4" {...FEED_SURFACE_PROPS}>
@@ -773,7 +858,21 @@ export function NewsroomReferencesView({
773
858
  </div>
774
859
 
775
860
  {view === "feed" ? (
776
- <ScrollArea className="min-h-0 flex-1" data-reference-feed-scroll>
861
+ <div className="relative flex min-h-0 flex-1 flex-col" {...FEED_SURFACE_PROPS}>
862
+ {stable && stable.newCount > 0 && feedTab === "feed" ? (
863
+ // Floats over the top of the grid, outside the scrolling content: announcing new items never moves a card.
864
+ <div className="pointer-events-none absolute inset-x-0 top-2 z-20 flex justify-center">
865
+ <button
866
+ className="pointer-events-auto inline-flex min-h-9 items-center rounded-full bg-[var(--feed-control)] px-4 text-sm font-medium text-foreground shadow-lg outline-none focus-visible:ring-2 focus-visible:ring-ring"
867
+ data-feed-new-items
868
+ onClick={showNewItems}
869
+ type="button"
870
+ >
871
+ {stable.newCount} new {stable.newCount === 1 ? "item" : "items"}: show
872
+ </button>
873
+ </div>
874
+ ) : null}
875
+ <ScrollArea className="min-h-0 flex-1 [overflow-anchor:none]" data-reference-feed-scroll>
777
876
  <div className="pb-6 pr-1">
778
877
  <ReferenceFeed
779
878
  disabledReason={feedDisabledReason}
@@ -786,6 +885,11 @@ export function NewsroomReferencesView({
786
885
  partition={partition}
787
886
  predictionLoading={predictionState === "loading"}
788
887
  previews={previews}
888
+ captures={showCaptures ? captureStatuses : undefined}
889
+ groups={stable?.groups}
890
+ leadId={stable?.leadId}
891
+ scrollTarget={scrollTarget}
892
+ ratings={ratings}
789
893
  selectedLineageId={selectedLineageId}
790
894
  stale={showingSavedCopy || (loading && references.length > 0)}
791
895
  tab={feedTab}
@@ -793,6 +897,7 @@ export function NewsroomReferencesView({
793
897
  />
794
898
  </div>
795
899
  </ScrollArea>
900
+ </div>
796
901
  ) : (
797
902
  <>
798
903
  <Tabs
@@ -877,7 +982,6 @@ export function NewsroomReferencesView({
877
982
  <div
878
983
  className={cn(
879
984
  "hidden min-h-0 w-full shrink-0 flex-col overflow-hidden rounded-[20px] bg-[var(--feed-card)] md:flex md:w-[min(100%,24rem)] lg:w-[min(100%,28rem)]",
880
- view === "feed" && !selectedReference && "md:hidden",
881
985
  )}
882
986
  data-newsroom-list-detail-shell
883
987
  data-news-desk-section="references"
@@ -893,6 +997,10 @@ export function NewsroomReferencesView({
893
997
  {selectedReference ? (
894
998
  <ReferenceDetailPanel
895
999
  key={referenceLineageId(selectedReference)}
1000
+ captureTeaserLine={teaserLine}
1001
+ correctCapture={onCorrectCapture}
1002
+ ratings={ratings}
1003
+ sectionChoices={sectionChoices}
896
1004
  predictionState={predictionState}
897
1005
  reviewReason={reviewReasons?.get(referenceLineageId(selectedReference))}
898
1006
  reviewSectionId={reviewSectionIds?.get(referenceLineageId(selectedReference))}
@@ -935,6 +1043,10 @@ export function NewsroomReferencesView({
935
1043
  <div className="min-w-0" data-review-gestures onPointerDown={gestures.onPointerDown} onPointerUp={gestures.onPointerUp} style={gestures.style}><ScrollArea className="max-h-[70dvh]">
936
1044
  <ReferenceDetailPanel
937
1045
  key={referenceLineageId(selectedReference)}
1046
+ captureTeaserLine={teaserLine}
1047
+ correctCapture={onCorrectCapture}
1048
+ ratings={ratings}
1049
+ sectionChoices={sectionChoices}
938
1050
  predictionState={predictionState}
939
1051
  reviewReason={reviewReasons?.get(referenceLineageId(selectedReference))}
940
1052
  reviewSectionId={reviewSectionIds?.get(referenceLineageId(selectedReference))}
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import type { ReferenceAttachmentRecord, ReferenceRecord } from "../lib/category-repository";
5
+ import { isLocalPreviewEnabled } from "../lib/local-preview";
6
+ import {
7
+ captureStatus, countWords, curationCardOf, grobidRows, primaryTextAttachment, structuredRows,
8
+ type CaptureAssessment as Assessment, type CaptureStatus, type CurationCard,
9
+ } from "../lib/reference-capture";
10
+ import { loadModelPayloadsForOwner, loadStoragePathText } from "./news-desk-taxonomy-client";
11
+
12
+ export type CaptureAssessment = Assessment;
13
+
14
+ export type CaptureView = {
15
+ status: CaptureStatus;
16
+ /** The stored text, once read. Null while loading, or when there is none. */
17
+ text: string | null;
18
+ loading: boolean;
19
+ error: string | null;
20
+ summary: string | null;
21
+ /** True when an editor replaced the text and the summary awaits the next curation run. */
22
+ summaryStale: boolean;
23
+ rows: Array<{ label: string; value: string }>;
24
+ /** Saves an editor's pasted or corrected text and their verdict on the capture (the `correctReferenceCapture` action). */
25
+ correct?: (input: { text?: string; assessment: CaptureAssessment; note?: string }) => Promise<void>;
26
+ };
27
+
28
+ function previewText(attachment: ReferenceAttachmentRecord): string | null {
29
+ // Local preview only: a fixture can carry the captured text inline instead of in storage.
30
+ if (!isLocalPreviewEnabled()) return null;
31
+ const raw = attachment.metadata;
32
+ try {
33
+ const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
34
+ const text = (parsed as { previewText?: unknown } | null)?.previewText;
35
+ return typeof text === "string" ? text : null;
36
+ } catch { return null; }
37
+ }
38
+
39
+ type Payloads = { key: string; text: string | null; grobid: unknown; summary: string | null; loaded: boolean };
40
+
41
+ /**
42
+ * Reads what the newsroom stored for one Reference. The curation card comes first (status, word count, signals, summary, GROBID fields,
43
+ * and the `curation-text` body its `current` points at); the older `extracted_text` attachment is the fallback.
44
+ */
45
+ export type CorrectCaptureAction = (input: { referenceId: string; text?: string; assessment: CaptureAssessment; note?: string }) => Promise<unknown>;
46
+
47
+ export function useReferenceCapture(reference: ReferenceRecord, attachments: readonly ReferenceAttachmentRecord[], teaserLine: number, correctAction?: CorrectCaptureAction): CaptureView {
48
+ const card: CurationCard | null = useMemo(() => curationCardOf(attachments), [attachments]);
49
+ const primary = useMemo(() => primaryTextAttachment(attachments), [attachments]);
50
+ const textSortKey = card?.current?.textSortKey ?? card?.capture.textSortKey ?? null;
51
+ const grobidSortKey = card?.current?.grobidSortKey ?? card?.grobid?.fieldsSortKey ?? null;
52
+ const [payloads, setPayloads] = useState<Payloads>({ key: "", text: null, grobid: null, summary: null, loaded: false });
53
+ 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);
55
+ const referenceKey = `${reference.id}:${textSortKey ?? ""}:${grobidSortKey ?? ""}`;
56
+
57
+ // The card's bodies and the Reference's metadata.json, in one read of the Reference's ModelAttachments.
58
+ useEffect(() => {
59
+ if (isLocalPreviewEnabled()) { setPayloads({ key: referenceKey, text: null, grobid: null, summary: null, loaded: true }); return; }
60
+ let active = true;
61
+ void loadModelPayloadsForOwner("reference", reference.id, ["metadata"])
62
+ .then((rows) => {
63
+ if (!active) return;
64
+ const bySortKey = (key: string | null) => (key ? rows.find((row) => row.attachment.sortKey === key) : undefined);
65
+ const metadata = rows.find((row) => row.attachment.sortKey === "metadata")?.json as { summary?: unknown } | null | undefined;
66
+ setPayloads({
67
+ key: referenceKey,
68
+ text: bySortKey(textSortKey)?.text ?? null,
69
+ grobid: bySortKey(grobidSortKey)?.json ?? null,
70
+ summary: typeof metadata?.summary === "string" && metadata.summary.trim() ? metadata.summary.trim() : null,
71
+ loaded: true,
72
+ });
73
+ })
74
+ .catch(() => { if (active) setPayloads({ key: referenceKey, text: null, grobid: null, summary: null, loaded: true }); });
75
+ return () => { active = false; };
76
+ }, [reference.id, referenceKey, textSortKey, grobidSortKey]);
77
+
78
+ // Fallback: the older extracted_text attachment, when the card has no text of its own.
79
+ const fallbackKey = primary ? `${primary.id}:${primary.storagePath ?? ""}` : "";
80
+ const useFallback = Boolean(primary) && !textSortKey;
81
+ useEffect(() => {
82
+ if (!useFallback || !primary) { setFallback({ key: "", text: null, error: null, loading: false }); return; }
83
+ const inline = previewText(primary);
84
+ if (inline !== null) { setFallback({ key: fallbackKey, text: inline, error: null, loading: false }); return; }
85
+ let active = true;
86
+ setFallback({ key: fallbackKey, text: null, error: null, loading: true });
87
+ void loadStoragePathText(primary.storagePath)
88
+ .then((result) => { if (active) setFallback({ key: fallbackKey, text: result.text, error: result.error, loading: false }); })
89
+ .catch((error) => { if (active) setFallback({ key: fallbackKey, text: null, error: error instanceof Error ? error.message : "Could not read the stored text.", loading: false }); });
90
+ return () => { active = false; };
91
+ }, [primary, fallbackKey, useFallback]);
92
+
93
+ const current = payloads.key === referenceKey ? payloads : null;
94
+ const mine = corrected && corrected.key === reference.id ? corrected : null;
95
+ const text = mine?.text ?? (textSortKey ? current?.text ?? null : fallback.key === fallbackKey ? fallback.text : null);
96
+ const loading = mine ? false : textSortKey ? !(current?.loaded) : useFallback && fallback.key === fallbackKey ? fallback.loading : useFallback;
97
+ const error = textSortKey || mine ? null : fallback.key === fallbackKey ? fallback.error : null;
98
+ const loadedWords = text !== null ? countWords(text) : null;
99
+ const base = captureStatus(attachments, teaserLine, loadedWords);
100
+ const status: CaptureStatus = mine ? { ...base, state: base.state === "none" || base.state === "running" ? (loadedWords !== null && loadedWords < teaserLine ? "thin" : "full") : base.state, words: loadedWords, estimated: false, assessment: mine.assessment, summaryStale: true, ...(loadedWords !== null && loadedWords < teaserLine ? { thinBy: "length" as const } : {}) } : base;
101
+ const summary = card?.summary?.status === "completed" && card.summary.text ? card.summary.text : current?.summary ?? null;
102
+ const rows = card ? grobidRows(current?.grobid, card) : structuredRows(attachments);
103
+
104
+ const correct = useCallback(async (input: { text?: string; assessment: CaptureAssessment; note?: string }) => {
105
+ 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]);
108
+
109
+ return { status, text, loading, error, summary, summaryStale: Boolean(mine) || status.summaryStale === true, rows, correct: correctAction || isLocalPreviewEnabled() ? correct : undefined };
110
+ }
@@ -0,0 +1,180 @@
1
+ "use client";
2
+
3
+ import { FileCheckIcon, FileClockIcon, FileWarningIcon, FileXIcon, Loader2Icon } from "lucide-react";
4
+ import { useEffect, useState } from "react";
5
+ import { captureLine, type CaptureAssessment, type CaptureState, type CaptureStatus } from "../lib/reference-capture";
6
+ import { cn } from "../lib/utils";
7
+ import type { CaptureView } from "./reference-capture-client";
8
+
9
+ const ICON = { full: FileCheckIcon, thin: FileWarningIcon, none: FileXIcon, running: FileClockIcon } as const;
10
+ /** Colour never stands alone: each state has its own icon and words. */
11
+ const TONE: Record<CaptureState, string> = {
12
+ full: "bg-emerald-600/15 text-emerald-900 dark:bg-emerald-400/15 dark:text-emerald-100",
13
+ thin: "bg-amber-500/20 text-amber-900 dark:bg-amber-300/15 dark:text-amber-100",
14
+ none: "bg-red-600/15 text-red-900 dark:bg-red-400/15 dark:text-red-100",
15
+ running: "bg-[var(--feed-control)] text-foreground",
16
+ };
17
+
18
+ /** A small badge for a grid card, so a thin or missing capture shows before the item is opened. It follows the curation status when there is one. */
19
+ export function CaptureBadge({ status }: { status: CaptureStatus }) {
20
+ const Icon = ICON[status.state];
21
+ const text = status.state === "none" ? "No text" : status.state === "running" ? "Capturing" : status.state === "thin" ? (status.words === null || status.thinBy === "signals" ? "Maybe cut off" : `${status.words.toLocaleString("en-US")} words`) : "Full text";
22
+ return (
23
+ <span
24
+ className={cn("inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.7rem] font-medium", TONE[status.state])}
25
+ data-capture-badge={status.state}
26
+ data-capture-curation={status.curation ?? undefined}
27
+ data-capture-source={status.source}
28
+ title={captureLine(status).label}
29
+ >
30
+ <Icon aria-hidden="true" className="size-3.5" />
31
+ {text}
32
+ </span>
33
+ );
34
+ }
35
+
36
+ const PREVIEW_CHARACTERS = 1600;
37
+ const ASSESSMENTS: Array<{ key: CaptureAssessment; label: string; hint: string }> = [
38
+ { key: "complete", label: "Complete", hint: "This is the whole item." },
39
+ { key: "incomplete", label: "Incomplete", hint: "Something is missing or cut off." },
40
+ { key: "incorrect", label: "Incorrect", hint: "This is not the right text." },
41
+ ];
42
+
43
+ /** "Paste or correct the text": an editor's own text and verdict on the capture, saved with `correctReferenceCapture`. */
44
+ function CorrectionForm({ capture, onDone }: { capture: CaptureView; onDone: () => void }) {
45
+ const [text, setText] = useState(capture.text ?? "");
46
+ const [assessment, setAssessment] = useState<CaptureAssessment | null>(null);
47
+ const [note, setNote] = useState("");
48
+ const [phase, setPhase] = useState<"idle" | "saving" | "error">("idle");
49
+ const [message, setMessage] = useState<string | null>(null);
50
+ useEffect(() => { setText((current) => current || capture.text || ""); }, [capture.text]);
51
+ const changedText = text.trim() !== (capture.text ?? "").trim() && text.trim().length > 0;
52
+ const reason = !assessment ? "Say whether the capture was complete, incomplete or incorrect to save." : null;
53
+ async function save() {
54
+ if (!assessment || !capture.correct) return;
55
+ setPhase("saving");
56
+ setMessage(null);
57
+ try {
58
+ await capture.correct({ ...(changedText ? { text: text.trim() } : {}), assessment, ...(note.trim() ? { note: note.trim() } : {}) });
59
+ onDone();
60
+ } catch (failure) {
61
+ setPhase("error");
62
+ setMessage(failure instanceof Error ? failure.message : "The correction could not be saved.");
63
+ }
64
+ }
65
+ return (
66
+ <div className="space-y-3 rounded-2xl bg-[var(--feed-control)] p-3" data-capture-correction>
67
+ <label className="block text-sm font-medium">
68
+ The text
69
+ <textarea
70
+ aria-label="Captured text"
71
+ className="mt-1 block min-h-40 w-full rounded-2xl bg-[var(--feed-card)] p-3 text-sm font-normal"
72
+ data-capture-correction-text
73
+ onChange={(event) => setText(event.target.value)}
74
+ placeholder="Paste the whole article here, or fix what was captured."
75
+ value={text}
76
+ />
77
+ </label>
78
+ <div aria-label="Was the capture complete?" className="flex flex-wrap gap-2" role="group">
79
+ {ASSESSMENTS.map((option) => (
80
+ <button
81
+ aria-pressed={assessment === option.key}
82
+ 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)]")}
83
+ data-capture-assessment={option.key}
84
+ key={option.key}
85
+ onClick={() => setAssessment(option.key)}
86
+ title={option.hint}
87
+ type="button"
88
+ >
89
+ {option.label}
90
+ </button>
91
+ ))}
92
+ </div>
93
+ <label className="block text-sm font-medium">
94
+ Note (optional)
95
+ <textarea 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
+ </label>
97
+ {reason ? <p className="text-xs text-muted-foreground" data-capture-correction-reason>{reason}</p> : null}
98
+ {phase === "error" ? <p className="text-xs text-destructive" role="alert">{message}</p> : null}
99
+ <div className="flex gap-2">
100
+ <button
101
+ aria-disabled={reason ? true : undefined}
102
+ 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 && "opacity-60")}
103
+ data-capture-correction-save
104
+ onClick={() => { if (!reason && phase !== "saving") void save(); }}
105
+ type="button"
106
+ >
107
+ {phase === "saving" ? <><Loader2Icon aria-hidden="true" className="size-4 animate-spin" />Saving…</> : changedText ? "Save the text and my verdict" : "Save my verdict"}
108
+ </button>
109
+ <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" onClick={onDone} type="button">Cancel</button>
110
+ </div>
111
+ </div>
112
+ );
113
+ }
114
+
115
+ /** Our captured record of a Reference: what we stored of its content, said plainly, above the live source preview. */
116
+ export function ReferenceCapturePanel({ capture }: { capture: CaptureView }) {
117
+ const [expanded, setExpanded] = useState(false);
118
+ const [editing, setEditing] = useState(false);
119
+ const [savedNote, setSavedNote] = useState(false);
120
+ const line = captureLine(capture.status);
121
+ const Icon = ICON[line.state];
122
+ const text = capture.text ?? "";
123
+ const long = text.length > PREVIEW_CHARACTERS;
124
+ return (
125
+ <section aria-label="Our captured record" className="space-y-3" data-capture-panel={line.state}>
126
+ <h3 className="text-sm font-semibold text-foreground">Our captured record</h3>
127
+ <div className={cn("flex items-start gap-3 rounded-2xl p-3", TONE[line.state])} data-capture-status={line.state} data-capture-source={capture.status.source}>
128
+ <Icon aria-hidden="true" className="mt-0.5 size-5 shrink-0" />
129
+ <div className="min-w-0">
130
+ <p className="text-sm font-semibold" data-capture-label>{line.label}</p>
131
+ {line.state === "full" ? null : <p className="text-xs opacity-90" data-capture-detail>{line.detail}</p>}
132
+ {capture.status.words !== null ? <p className="text-xs font-medium" data-capture-words>{capture.status.estimated ? "about " : ""}{capture.status.words.toLocaleString("en-US")} words</p> : null}
133
+ {line.state === "full" && capture.status.flags?.length ? <p className="text-xs opacity-90" data-capture-flags>{capture.status.flags.join(" ")}</p> : null}
134
+ {capture.status.curation === "degraded" ? <p className="text-xs opacity-90" data-capture-curation-note>Curation finished all but one stage (usually the summary).</p> : null}
135
+ {capture.status.assessment ? <p className="text-xs font-medium" data-capture-assessment-note>An editor marked this capture {capture.status.assessment}.</p> : null}
136
+ </div>
137
+ </div>
138
+ {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
+ {capture.error ? <p className="text-xs text-destructive" role="alert">The stored text could not be read. {capture.error}</p> : null}
140
+ {text ? (
141
+ <div className="space-y-2">
142
+ <div className="max-h-80 overflow-y-auto whitespace-pre-wrap rounded-2xl bg-[var(--feed-control)] p-3 text-sm leading-relaxed" data-capture-text tabIndex={0}>
143
+ {expanded || !long ? text : `${text.slice(0, PREVIEW_CHARACTERS)}…`}
144
+ </div>
145
+ {long ? (
146
+ <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">
147
+ {expanded ? "Show less" : "Show all of the captured text"}
148
+ </button>
149
+ ) : null}
150
+ </div>
151
+ ) : null}
152
+ {capture.summary ? (
153
+ <div className="space-y-1" data-capture-summary>
154
+ <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Summary{capture.summaryStale ? " (out of date: the text was replaced)" : ""}</h4>
155
+ <p className="text-sm">{capture.summary}</p>
156
+ </div>
157
+ ) : capture.summaryStale ? <p className="text-xs text-muted-foreground" data-capture-summary-stale>The summary will be rewritten from the new text on the next curation run.</p> : null}
158
+ {capture.rows.length ? (
159
+ <dl className="grid gap-2 rounded-2xl bg-[var(--feed-control)] p-3 text-sm" data-capture-structured>
160
+ {capture.rows.map((row) => (
161
+ <div key={`${row.label}:${row.value}`}>
162
+ <dt className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{row.label}</dt>
163
+ <dd className="mt-0.5">{row.value}</dd>
164
+ </div>
165
+ ))}
166
+ </dl>
167
+ ) : null}
168
+ {capture.correct ? (
169
+ editing ? <CorrectionForm capture={capture} onDone={() => { setEditing(false); setSavedNote(true); }} /> : (
170
+ <div className="flex flex-wrap items-center gap-2">
171
+ <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">
172
+ Paste or correct the text
173
+ </button>
174
+ {savedNote ? <span className="text-xs text-muted-foreground" data-capture-correction-saved role="status">Saved. Your verdict trains the completeness check.</span> : null}
175
+ </div>
176
+ )
177
+ ) : null}
178
+ </section>
179
+ );
180
+ }