@anthusai/papyrus 5.45.0 → 5.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/amplify/data/resource.js +6 -0
- package/amplify/data/resource.ts +7 -0
- package/amplify/data/schema.js +6 -0
- package/amplify/data/schema.ts +7 -0
- package/amplify/functions/content-actions/handler.py +8 -0
- package/bin/papyrus-app.mjs +12 -1
- package/components/news-desk-client-provider.tsx +2 -2
- package/components/newsroom-ops-overview.tsx +63 -60
- package/components/newsroom-ops-shell.tsx +3 -1
- package/components/newsroom-organizer.css +1 -2
- package/components/newsroom-references-view.tsx +119 -7
- package/components/notification-cards.tsx +364 -0
- package/components/notification-center.tsx +319 -331
- package/components/reference-capture-client.ts +110 -0
- package/components/reference-capture-panel.tsx +180 -0
- package/components/reference-feed.tsx +93 -60
- package/components/reference-review-card.tsx +21 -66
- package/components/references-prefetch.ts +5 -0
- package/components/review-rating-grid.tsx +27 -14
- package/components/topic-steering-workspace.tsx +12 -2
- package/components/use-reference-ratings.ts +132 -0
- package/lib/category-repository.ts +2 -0
- package/lib/content-actions-client.ts +18 -0
- package/lib/local-preview-feed-fixture.ts +20 -0
- package/lib/newsroom-nav.ts +1 -1
- package/lib/newsroom-organizer-nav.ts +1 -1
- package/lib/notification-feed.ts +250 -0
- package/lib/reference-capture.ts +293 -0
- package/lib/reference-feed-cache.ts +37 -0
- package/lib/reference-feed-ranking.ts +44 -0
- package/lib/review-drafts.ts +8 -3
- package/lib/site-brand.ts +2 -0
- package/package.json +7 -1
- package/routes.manifest.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -21,6 +21,9 @@ import type { RelevanceDecisionView } from "../lib/relevance-decisions";
|
|
|
21
21
|
import { cn } from "../lib/utils";
|
|
22
22
|
import { predictionFromDecision, type ReviewChoice } from "./reference-review-card";
|
|
23
23
|
import { RatingGrid } from "./review-rating-grid";
|
|
24
|
+
import { CaptureBadge } from "./reference-capture-panel";
|
|
25
|
+
import { useRatingStore, type RatingController } from "./use-reference-ratings";
|
|
26
|
+
import type { CaptureStatus } from "../lib/reference-capture";
|
|
24
27
|
|
|
25
28
|
/**
|
|
26
29
|
* Flat surfaces, no borders: the page tone, a card tone one step away from it, a footer tone, and a control tone for chips and
|
|
@@ -45,7 +48,7 @@ const REASON_LABELS: Record<ReferenceRejectionReasonCode, string> = {
|
|
|
45
48
|
const TAB_LABELS: Record<ReferenceFeedTabKey, string> = { feed: "Feed", rated: "Rated", earlier: "Earlier" };
|
|
46
49
|
const GROUP_HEADINGS: Record<FeedGroupKey, string> = { top: "Top predictions", more: "More for you", least: "Least likely" };
|
|
47
50
|
|
|
48
|
-
const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-full px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
|
|
51
|
+
export const PILL = "inline-flex min-h-9 items-center justify-center gap-1.5 rounded-full px-3.5 text-sm font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring";
|
|
49
52
|
|
|
50
53
|
export type ReferenceFeedProps = {
|
|
51
54
|
/** Read-only reason shown in words when answering is not possible yet (for example while the session is confirmed). */
|
|
@@ -62,20 +65,33 @@ export type ReferenceFeedProps = {
|
|
|
62
65
|
predictionLoading?: boolean;
|
|
63
66
|
previews: ReadonlyMap<string, ReferencePreview>;
|
|
64
67
|
selectedLineageId?: string;
|
|
68
|
+
/** Capture status per Reference lineage, for the small badge on each card. Optional. */
|
|
69
|
+
captures?: ReadonlyMap<string, CaptureStatus>;
|
|
70
|
+
/** The sections and lead, when the caller keeps the order stable (a snapshot). Without them the feed groups its own partition. */
|
|
71
|
+
groups?: Array<{ key: FeedGroupKey; items: FeedItem[] }>;
|
|
72
|
+
leadId?: string;
|
|
73
|
+
/** Scrolls the grid to a card, deliberately (Previous / Next). A new nonce is a new request; selecting alone never scrolls. */
|
|
74
|
+
scrollTarget?: { id: string; nonce: number } | null;
|
|
75
|
+
/** A rating store shared with the detail pane, so a vote or reason in either shows in both. Optional. */
|
|
76
|
+
ratings?: RatingController;
|
|
65
77
|
/** True when the cards are a saved copy that is being refreshed. */
|
|
66
78
|
stale?: boolean;
|
|
67
79
|
tab: ReferenceFeedTabKey;
|
|
68
80
|
visitRated: ReadonlySet<string>;
|
|
69
81
|
};
|
|
70
82
|
|
|
71
|
-
|
|
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
|
+
export const FEED_GRID = "grid grid-cols-[repeat(auto-fill,minmax(17rem,1fr))] gap-5";
|
|
85
|
+
const GRID = FEED_GRID;
|
|
86
|
+
/** The lead spans two columns only where there are at least two to span (two 17rem columns and the gap). */
|
|
87
|
+
const LEAD_SPAN = "@[36rem]:col-span-2";
|
|
72
88
|
|
|
73
89
|
/** Pulsing placeholder cards: the page shell is on screen at once, with no "checking" screen in front of it. */
|
|
74
90
|
export function ReferenceFeedSkeleton({ count = 6 }: { count?: number }) {
|
|
75
91
|
return (
|
|
76
|
-
<div aria-busy="true" aria-label="Loading references" className={GRID} data-reference-feed-skeleton role="status" {...FEED_SURFACE_PROPS}>
|
|
92
|
+
<div aria-busy="true" aria-label="Loading references" className={cn("@container", GRID)} data-reference-feed-skeleton role="status" {...FEED_SURFACE_PROPS}>
|
|
77
93
|
{Array.from({ length: count }, (_, index) => (
|
|
78
|
-
<div className={cn("overflow-hidden rounded-[20px] bg-[var(--feed-card)]", index === 0 &&
|
|
94
|
+
<div className={cn("overflow-hidden rounded-[20px] bg-[var(--feed-card)]", index === 0 && LEAD_SPAN)} key={index}>
|
|
79
95
|
<div className={cn("animate-pulse bg-[var(--feed-control)]", index === 0 ? "aspect-[2/1]" : "aspect-[16/10]")} />
|
|
80
96
|
<div className="space-y-3 p-5">
|
|
81
97
|
<div className="h-3 w-1/3 animate-pulse rounded-full bg-[var(--feed-control)]" />
|
|
@@ -100,7 +116,7 @@ function hueOf(name: string): number {
|
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
/** A small mark for a source: its own favicon on a light tile when it loads, else its first letter. */
|
|
103
|
-
function SourceMark({ host, size = "sm" }: { host: string | null; size?: "sm" | "lg" }) {
|
|
119
|
+
export function SourceMark({ host, size = "sm" }: { host: string | null; size?: "sm" | "lg" }) {
|
|
104
120
|
const [failed, setFailed] = useState(false);
|
|
105
121
|
const box = size === "lg" ? "size-16 rounded-2xl text-3xl" : "size-5 rounded-md text-[0.65rem]";
|
|
106
122
|
const letter = (host ?? "?")[0];
|
|
@@ -134,7 +150,7 @@ function FallbackTile({ host, name }: { host: string | null; name: string }) {
|
|
|
134
150
|
* Photos fill the frame (cover). A text card (a repository social card, or a 2:1 picture) is shown whole instead: the frame takes
|
|
135
151
|
* the picture's own ratio and the picture is contained, so no words are cut off at the edges.
|
|
136
152
|
*/
|
|
137
|
-
function FeedImage({ host, lead, name, src }: { host: string | null; lead: boolean; name: string; src: string | null }) {
|
|
153
|
+
export function FeedImage({ host, lead, name, src }: { host: string | null; lead: boolean; name: string; src: string | null }) {
|
|
138
154
|
const [state, setState] = useState<"loading" | "loaded" | "failed">(src ? "loading" : "failed");
|
|
139
155
|
const [ratio, setRatio] = useState<number | null>(null);
|
|
140
156
|
const ref = useRef<HTMLImageElement | null>(null);
|
|
@@ -145,14 +161,17 @@ function FeedImage({ host, lead, name, src }: { host: string | null; lead: boole
|
|
|
145
161
|
const element = ref.current;
|
|
146
162
|
if (element?.complete && element.naturalWidth > 0) { setState("loaded"); measure(element); }
|
|
147
163
|
}, [src]);
|
|
148
|
-
|
|
164
|
+
// The frame is decided before the picture loads (from its address), so loading never moves the cards around. A picture found to be
|
|
165
|
+
// a 2:1 text card afterwards is contained inside the same frame instead of changing it.
|
|
166
|
+
const hostCard = isCardStyleImage(src, null);
|
|
167
|
+
const card = hostCard || isCardStyleImage(src, ratio);
|
|
149
168
|
return (
|
|
150
169
|
<div
|
|
151
|
-
className={cn("relative w-full overflow-hidden bg-[var(--feed-control)]", !
|
|
170
|
+
className={cn("relative w-full overflow-hidden bg-[var(--feed-control)]", !hostCard && (lead ? "aspect-[16/10] @[36rem]:aspect-[2/1] @[60rem]:aspect-[21/9]" : "aspect-[16/10]"))}
|
|
152
171
|
data-feed-image
|
|
153
172
|
data-feed-image-fit={card ? "contain" : "cover"}
|
|
154
173
|
data-feed-image-state={state}
|
|
155
|
-
style={
|
|
174
|
+
style={hostCard ? { aspectRatio: "2" } : undefined}
|
|
156
175
|
>
|
|
157
176
|
{state === "loading" ? <div aria-hidden="true" className="absolute inset-0 animate-pulse bg-[var(--feed-footer)] blur-sm" data-feed-image-placeholder /> : null}
|
|
158
177
|
{src && state !== "failed" ? (
|
|
@@ -175,9 +194,8 @@ function FeedImage({ host, lead, name, src }: { host: string | null; lead: boole
|
|
|
175
194
|
);
|
|
176
195
|
}
|
|
177
196
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
function FeedCard({
|
|
197
|
+
export function FeedCard({
|
|
198
|
+
capture,
|
|
181
199
|
decision,
|
|
182
200
|
disabledReason,
|
|
183
201
|
item,
|
|
@@ -187,9 +205,12 @@ function FeedCard({
|
|
|
187
205
|
onRate,
|
|
188
206
|
predictionLoading,
|
|
189
207
|
preview,
|
|
208
|
+
ratings,
|
|
190
209
|
selected,
|
|
191
210
|
visitRated,
|
|
192
211
|
}: {
|
|
212
|
+
/** Optional: the capture badge. */
|
|
213
|
+
capture?: CaptureStatus | undefined;
|
|
193
214
|
decision: RelevanceDecisionView | undefined;
|
|
194
215
|
disabledReason: string | null;
|
|
195
216
|
item: FeedItem;
|
|
@@ -199,6 +220,8 @@ function FeedCard({
|
|
|
199
220
|
onRate: ReferenceFeedProps["onRate"];
|
|
200
221
|
predictionLoading: boolean;
|
|
201
222
|
preview: ReferencePreview | undefined;
|
|
223
|
+
/** Optional: a shared rating store. Without it the card keeps its own. */
|
|
224
|
+
ratings?: RatingController | undefined;
|
|
202
225
|
selected: boolean;
|
|
203
226
|
visitRated: boolean;
|
|
204
227
|
}) {
|
|
@@ -206,8 +229,15 @@ function FeedCard({
|
|
|
206
229
|
const titleId = useId();
|
|
207
230
|
const status = normalizeReferenceCurationStatus(reference.curationStatus);
|
|
208
231
|
const answer: ReviewChoice | null = status === "accepted" ? "accept" : status === "rejected" ? "reject" : null;
|
|
209
|
-
|
|
210
|
-
const
|
|
232
|
+
// Rating state lives in a store: the shared one when given (so the detail pane and this card stay in step), else a private one.
|
|
233
|
+
const local = useRatingStore({
|
|
234
|
+
save: async (_id, choice, detail) => { await onRate(reference, choice, detail.reasonCode); },
|
|
235
|
+
saved: () => answer,
|
|
236
|
+
seed: () => ({ note: "", reasonCode: null }),
|
|
237
|
+
});
|
|
238
|
+
const store = ratings ?? local;
|
|
239
|
+
const rating = store.get(item.lineageId);
|
|
240
|
+
const chosen = rating.pending ?? answer;
|
|
211
241
|
const host = referenceSourceHost(reference);
|
|
212
242
|
const site = preview?.siteName ?? host ?? "Unknown source";
|
|
213
243
|
const title = referenceDisplayTitle(reference);
|
|
@@ -215,42 +245,26 @@ function FeedCard({
|
|
|
215
245
|
const showDek = dekAddsInformation(title, dek, preview?.imageUrl ?? null);
|
|
216
246
|
const time = referenceDisplayTime(reference);
|
|
217
247
|
const age = formatFeedAge(time, now);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (disabledReason || inFlight.current) return;
|
|
221
|
-
inFlight.current = true;
|
|
222
|
-
setRate({ phase: "saving" });
|
|
223
|
-
try {
|
|
224
|
-
await onRate(reference, choice, reasonCode);
|
|
225
|
-
setRate({ phase: "saved" });
|
|
226
|
-
} catch (failure) {
|
|
227
|
-
setRate({ phase: "error", message: failure instanceof Error ? failure.message : "Your answer could not be saved. Tap it again to retry." });
|
|
228
|
-
} finally {
|
|
229
|
-
inFlight.current = false;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function choose(choice: ReviewChoice) {
|
|
234
|
-
if (disabledReason) return;
|
|
235
|
-
if (answer === choice) return; // the chosen answer never unselects itself; the other choice is the undo
|
|
236
|
-
if (choice === "reject") { setRate({ phase: "reason" }); return; }
|
|
237
|
-
void save("accept", null);
|
|
238
|
-
}
|
|
248
|
+
const feedback = rating.feedback.phase === "idle" && visitRated && answer ? { phase: "saved" as const } : rating.feedback;
|
|
249
|
+
const reasonLabel = answer === "reject" && rating.reasonCode ? REASON_LABELS[rating.reasonCode] : null;
|
|
239
250
|
|
|
240
251
|
return (
|
|
241
252
|
<article
|
|
242
253
|
aria-labelledby={titleId}
|
|
254
|
+
aria-current={selected ? "true" : undefined}
|
|
243
255
|
className={cn(
|
|
244
|
-
"flex flex-col overflow-hidden rounded-[20px] text-card-foreground",
|
|
245
|
-
selected ? "bg-[var(--feed-control)]" : "bg-[var(--feed-card)]",
|
|
246
|
-
lead &&
|
|
256
|
+
"relative flex flex-col overflow-hidden rounded-[20px] text-card-foreground transition-[background-color,box-shadow] duration-200",
|
|
257
|
+
selected ? "bg-[var(--feed-control)] shadow-xl" : "bg-[var(--feed-card)]",
|
|
258
|
+
lead && LEAD_SPAN,
|
|
247
259
|
)}
|
|
248
260
|
data-feed-card={item.lineageId}
|
|
249
261
|
data-feed-lead={lead || undefined}
|
|
250
262
|
data-feed-rated={answer ?? undefined}
|
|
263
|
+
data-feed-selected={selected || undefined}
|
|
251
264
|
data-feed-visit-rated={visitRated || undefined}
|
|
252
265
|
data-newsroom-card-id={item.lineageId}
|
|
253
266
|
>
|
|
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}
|
|
254
268
|
<button
|
|
255
269
|
aria-label={`Open: ${title}`}
|
|
256
270
|
className="flex flex-1 flex-col text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
@@ -259,42 +273,48 @@ function FeedCard({
|
|
|
259
273
|
type="button"
|
|
260
274
|
>
|
|
261
275
|
<FeedImage host={host} lead={lead} name={site} src={preview?.imageUrl ?? null} />
|
|
262
|
-
<span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5
|
|
276
|
+
<span className={cn("flex flex-1 flex-col gap-2.5", lead ? "p-5 @[36rem]:p-7" : "p-5")}>
|
|
263
277
|
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground" data-feed-source>
|
|
264
278
|
<SourceMark host={host} />
|
|
265
279
|
<span className="truncate">{site}</span>
|
|
266
280
|
</span>
|
|
267
|
-
<span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl
|
|
281
|
+
<span className={cn("font-bold leading-[1.15] tracking-tight text-foreground", lead ? "line-clamp-3 text-2xl @[36rem]:text-3xl @[60rem]:text-4xl" : "line-clamp-3 text-xl")} data-feed-title id={titleId}>
|
|
268
282
|
{title}
|
|
269
283
|
</span>
|
|
270
284
|
{showDek ? <span className={cn("text-muted-foreground", lead ? "line-clamp-3 text-base" : "line-clamp-2 text-sm")} data-feed-dek>{dek}</span> : null}
|
|
271
|
-
<span className="mt-auto pt-1 text-xs text-muted-foreground">
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
285
|
+
<span className="mt-auto flex flex-wrap items-center gap-x-2 gap-y-1 pt-1 text-xs text-muted-foreground">
|
|
286
|
+
<span>
|
|
287
|
+
{age ? <time dateTime={new Date(time ?? now).toISOString()} title={new Date(time ?? now).toLocaleString()}>{age}</time> : null}
|
|
288
|
+
{age && host ? <span aria-hidden="true"> · </span> : null}
|
|
289
|
+
{host}
|
|
290
|
+
</span>
|
|
291
|
+
{capture ? <CaptureBadge status={capture} /> : null}
|
|
275
292
|
</span>
|
|
276
293
|
</span>
|
|
277
294
|
</button>
|
|
278
295
|
|
|
279
|
-
<div className="
|
|
296
|
+
<div className="relative bg-[var(--feed-footer)] p-3" data-feed-actions>
|
|
280
297
|
<RatingGrid
|
|
281
|
-
answer={
|
|
298
|
+
answer={chosen}
|
|
299
|
+
decided={answer}
|
|
282
300
|
disabled={Boolean(disabledReason)}
|
|
283
301
|
disabledReason={disabledReason}
|
|
284
|
-
feedback={
|
|
285
|
-
|
|
302
|
+
feedback={feedback}
|
|
303
|
+
note={reasonLabel ? `Reason: ${reasonLabel}` : null}
|
|
304
|
+
onChoose={(choice) => { if (!disabledReason) store.choose(item.lineageId, choice); }}
|
|
286
305
|
prediction={decision ? predictionFromDecision(decision) : predictionLoading ? { state: "loading" } : { state: "none" }}
|
|
287
306
|
/>
|
|
288
|
-
{
|
|
289
|
-
|
|
307
|
+
{rating.askReason ? (
|
|
308
|
+
// Covers the footer rather than adding to it, so asking for a reason never pushes the cards below it down.
|
|
309
|
+
<div className="absolute inset-0 z-10 flex flex-col justify-center space-y-2 bg-[var(--feed-footer)] p-3" data-review-reasons>
|
|
290
310
|
<p className="text-xs font-medium">Reason for rejecting. Choose one to save your Reject.</p>
|
|
291
311
|
<div className="flex flex-wrap gap-1.5">
|
|
292
312
|
{REFERENCE_REJECTION_REASON_CODES.map((code) => (
|
|
293
|
-
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() =>
|
|
313
|
+
<button className={cn(PILL, "min-h-8 bg-[var(--feed-control)] px-3 text-xs")} data-review-reason={code} key={code} onClick={() => store.setReason(item.lineageId, code)} type="button">
|
|
294
314
|
{REASON_LABELS[code]}
|
|
295
315
|
</button>
|
|
296
316
|
))}
|
|
297
|
-
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() =>
|
|
317
|
+
<button className={cn(PILL, "min-h-8 px-3 text-xs text-muted-foreground")} onClick={() => store.cancelReason(item.lineageId)} type="button">Cancel</button>
|
|
298
318
|
</div>
|
|
299
319
|
</div>
|
|
300
320
|
) : null}
|
|
@@ -364,7 +384,7 @@ function RankingPopover({ freshness }: { freshness: FeedFreshness }) {
|
|
|
364
384
|
);
|
|
365
385
|
}
|
|
366
386
|
|
|
367
|
-
function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
|
|
387
|
+
export function SectionHeading({ children, id }: { children: ReactNode; id: string }) {
|
|
368
388
|
return <h3 className="mb-5 mt-10 text-3xl font-bold tracking-tight text-primary first:mt-2 sm:mt-12 sm:text-4xl" id={id}>{children}</h3>;
|
|
369
389
|
}
|
|
370
390
|
|
|
@@ -380,6 +400,11 @@ export function ReferenceFeed({
|
|
|
380
400
|
predictionLoading = false,
|
|
381
401
|
previews,
|
|
382
402
|
selectedLineageId = "",
|
|
403
|
+
captures,
|
|
404
|
+
groups: stableGroups,
|
|
405
|
+
leadId: stableLeadId,
|
|
406
|
+
ratings,
|
|
407
|
+
scrollTarget = null,
|
|
383
408
|
stale = false,
|
|
384
409
|
tab,
|
|
385
410
|
visitRated,
|
|
@@ -388,6 +413,7 @@ export function ReferenceFeed({
|
|
|
388
413
|
|
|
389
414
|
const renderCard = (item: FeedItem, lead: boolean) => (
|
|
390
415
|
<FeedCard
|
|
416
|
+
capture={captures?.get(item.lineageId)}
|
|
391
417
|
decision={item.decision}
|
|
392
418
|
disabledReason={disabledReason}
|
|
393
419
|
item={item}
|
|
@@ -398,6 +424,7 @@ export function ReferenceFeed({
|
|
|
398
424
|
onRate={async (reference, choice, reasonCode) => { onVisitRated(item.lineageId); await onRate(reference, choice, reasonCode); }}
|
|
399
425
|
predictionLoading={predictionLoading}
|
|
400
426
|
preview={previews.get(item.lineageId)}
|
|
427
|
+
ratings={ratings}
|
|
401
428
|
selected={selectedLineageId === item.lineageId}
|
|
402
429
|
visitRated={visitRated.has(item.lineageId)}
|
|
403
430
|
/>
|
|
@@ -407,12 +434,18 @@ export function ReferenceFeed({
|
|
|
407
434
|
<div className="rounded-[20px] bg-[var(--feed-card)] p-10 text-center text-sm text-muted-foreground" data-feed-empty>{label}</div>
|
|
408
435
|
);
|
|
409
436
|
|
|
410
|
-
|
|
437
|
+
// Previous / Next in the detail pane scrolls the grid to the same card, smoothly. Selecting a card by clicking it never scrolls.
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
if (!scrollTarget || typeof document === "undefined") return;
|
|
440
|
+
document.querySelector(`[data-feed-card="${CSS.escape(scrollTarget.id)}"]`)?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
441
|
+
}, [scrollTarget]);
|
|
442
|
+
|
|
443
|
+
const groups = stableGroups ?? groupFeedItems(partition.feed);
|
|
411
444
|
const headings = groups.length > 1;
|
|
412
|
-
const leadId = groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
|
|
445
|
+
const leadId = stableGroups ? stableLeadId : groups[0]?.key !== "least" ? groups[0]?.items[0]?.lineageId : undefined;
|
|
413
446
|
|
|
414
447
|
return (
|
|
415
|
-
<div className="space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
|
|
448
|
+
<div className="@container space-y-5" data-reference-feed data-feed-tab={tab} {...FEED_SURFACE_PROPS}>
|
|
416
449
|
<div className="flex items-center justify-between gap-2">
|
|
417
450
|
<div aria-label="Feed sections" className="inline-flex rounded-full bg-[var(--feed-card)] p-1" role="tablist">
|
|
418
451
|
{(Object.keys(TAB_LABELS) as ReferenceFeedTabKey[]).map((key) => (
|
|
@@ -448,20 +481,20 @@ export function ReferenceFeed({
|
|
|
448
481
|
{groups.length ? groups.map((group) => (
|
|
449
482
|
<section aria-labelledby={headings ? `feed-group-${group.key}` : undefined} data-feed-group={group.key} key={group.key}>
|
|
450
483
|
{headings ? <SectionHeading id={`feed-group-${group.key}`}>{GROUP_HEADINGS[group.key]}</SectionHeading> : null}
|
|
451
|
-
<div className={
|
|
484
|
+
<div className={FEED_GRID} data-feed-grid>{group.items.map((item) => renderCard(item, item.lineageId === leadId))}</div>
|
|
452
485
|
</section>
|
|
453
486
|
)) : empty(counts.rated || counts.earlier ? "You are all caught up. Check Earlier and Rated." : "No references yet.")}
|
|
454
487
|
{partition.earlier.length ? (
|
|
455
488
|
<section aria-labelledby="feed-earlier-heading" data-feed-earlier>
|
|
456
489
|
<SectionHeading id="feed-earlier-heading">Earlier</SectionHeading>
|
|
457
490
|
<p className="-mt-3 mb-5 text-sm text-muted-foreground">Unrated and past their freshness. Still yours to rate.</p>
|
|
458
|
-
<div className={
|
|
491
|
+
<div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div>
|
|
459
492
|
</section>
|
|
460
493
|
) : null}
|
|
461
494
|
</div>
|
|
462
495
|
) : null}
|
|
463
|
-
{tab === "rated" ? (partition.rated.length ? <div className={
|
|
464
|
-
{tab === "earlier" ? (partition.earlier.length ? <div className={
|
|
496
|
+
{tab === "rated" ? (partition.rated.length ? <div className={FEED_GRID} data-feed-grid>{partition.rated.map((item) => renderCard(item, false))}</div> : empty("Nothing rated yet. Your Accepts and Rejects collect here, newest first.")) : null}
|
|
497
|
+
{tab === "earlier" ? (partition.earlier.length ? <div className={FEED_GRID} data-feed-grid>{partition.earlier.map((item) => renderCard(item, false))}</div> : empty("Nothing has aged out. Unrated items that fall past their freshness collect here.")) : null}
|
|
465
498
|
</div>
|
|
466
499
|
)}
|
|
467
500
|
</div>
|