diff --git a/src/components-claims.tsx b/src/components-claims.tsx index 1ef414e..8ecfaf6 100644 --- a/src/components-claims.tsx +++ b/src/components-claims.tsx @@ -200,9 +200,12 @@ export const ClaimsReviewQueue: FC = ({ const inConflict = new Set(conflictGroups.flat().map((c) => c.claim_id)); const proposed = claims.filter((c) => c.status === "proposed" && !inConflict.has(c.claim_id)); - const unreviewed = claims.filter( - (c) => c.status === "active" && c.review_state === "unreviewed" && !inConflict.has(c.claim_id), - ); + // Everything the queue returned that isn't a "proposed" card or a rendered + // conflict pair belongs in "Awaiting review". Filtering narrowly (active + + // unreviewed only) silently dropped anything else the endpoint surfaced — + // e.g. a claim flagged open-conflict whose counterpart isn't in this payload + // (a singleton group, not rendered as a pair) rendered nowhere at all. + const rest = claims.filter((c) => c.status !== "proposed" && !inConflict.has(c.claim_id)); if (claims.length === 0) { return <>{emptyState ?? }; @@ -230,8 +233,8 @@ export const ClaimsReviewQueue: FC = ({ ))} -
- {unreviewed.map((c) => ( +
+ {rest.map((c) => ( ))}
diff --git a/src/components-object.tsx b/src/components-object.tsx index cf8fa5e..f7409de 100644 --- a/src/components-object.tsx +++ b/src/components-object.tsx @@ -32,34 +32,52 @@ import { export interface ObjectListProps { objects: ObjectSummary[]; onOpen?: (object: ObjectSummary) => void; + /** When set, renders a "Load more" affordance (the list is paginated). */ + onLoadMore?: () => void; + hasMore?: boolean; + loadingMore?: boolean; emptyState?: ReactNode; className?: string; } -export const ObjectList: FC = ({ objects, onOpen, emptyState, className }) => { +export const ObjectList: FC = ({ objects, onOpen, onLoadMore, hasMore, loadingMore, emptyState, className }) => { if (objects.length === 0 && emptyState) return <>{emptyState}; return ( -
    - {objects.map((o) => ( -
  • - -
  • - ))} -
+
+
    + {objects.map((o) => ( +
  • + +
  • + ))} +
+ {hasMore && onLoadMore && ( + + )} +
); }; @@ -237,28 +255,41 @@ export interface TextReaderProps { export const TextReader: FC = ({ outline, readText, highlight, activeSection, className }) => { const [slice, setSlice] = useState(null); const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); const markRef = useRef(null); + // Monotonic request id: rapid section/citation nav fires overlapping reads; + // only the newest may commit, so a slow earlier response can't clobber it. + const reqRef = useRef(0); const load = useCallback( async (sel?: { section?: string; start?: number; end?: number }) => { + const req = ++reqRef.current; setLoading(true); + setError(null); try { - setSlice(await readText(sel)); + const next = await readText(sel); + if (reqRef.current === req) setSlice(next); + } catch { + // A failed read must surface, not blank the panel (and never leak as an + // unhandled rejection). + if (reqRef.current === req) setError("Couldn't load this text. Try again."); } finally { - setLoading(false); + if (reqRef.current === req) setLoading(false); } }, [readText], ); - // Initial + reactive loads: prefer an explicit highlight span, then an active - // section, else the head of the document. + // Initial + reactive loads: an explicit outline selection wins (so clicking a + // section works even on a citation-opened view), then the highlight span, + // else the head of the document. useEffect(() => { - if (highlight) { + if (activeSection) { + void load({ section: activeSection }); + } else if (highlight) { const sec = outline.outline.find((s) => highlight.start >= s.start && highlight.end <= s.end); void load(sec ? { section: sec.id } : { start: Math.max(0, highlight.start - 200), end: highlight.end + 200 }); - } else if (activeSection) { - void load({ section: activeSection }); } else { void load(); } @@ -270,13 +301,20 @@ export const TextReader: FC = ({ outline, readText, highlight, }, [slice]); async function loadMore() { - if (!slice?.range) return; - const next = await readText({ start: slice.range.end }); - setSlice((prev) => - prev && prev.range && next.range - ? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } } - : next, - ); + if (loadingMore || !slice?.range) return; + setLoadingMore(true); + try { + const next = await readText({ start: slice.range.end }); + setSlice((prev) => + prev && prev.range && next.range + ? { ...next, text: (prev.text ?? "") + (next.text ?? ""), range: { start: prev.range.start, end: next.range.end } } + : next, + ); + } catch { + setError("Couldn't load more text. Try again."); + } finally { + setLoadingMore(false); + } } if (loading && !slice) { @@ -286,6 +324,21 @@ export const TextReader: FC = ({ outline, readText, highlight, ); } + if (error && !slice) { + return ( +
+

{error}

+ +
+ ); + } if (!slice) return null; const canReadMore = outline.extracted_chars != null && slice.range != null && slice.range.end < outline.extracted_chars; @@ -295,14 +348,17 @@ export const TextReader: FC = ({ outline, readText, highlight,
{renderWithHighlight(slice, highlight, markRef)}
+ {error &&

{error}

} {canReadMore && ( )} @@ -396,12 +452,24 @@ export const ObjectViewer: FC = ({ !!actions && (actions.onEditMeta || actions.onVerify || actions.onDownload || actions.onArchive || actions.onSupersede || actions.onExtractClaims); + // Navigating object→object (citation click) reuses this component: clear the + // per-object view state so a stale section selection or the previous image + // can't bleed into the new object. + useEffect(() => { + setActiveSection(undefined); + }, [outline.object_id]); + useEffect(() => { let live = true; + setBlob(null); if (isImage && outline.has_blob && resolveBlobUrl) { - void resolveBlobUrl().then((u) => { - if (live) setBlob(u); - }); + void resolveBlobUrl() + .then((u) => { + if (live) setBlob(u); + }) + .catch(() => { + /* leave the placeholder frame; the download action still works */ + }); } return () => { live = false;