import { useEffect, useMemo, useState } from "react"; import { docsApi } from "../api/endpoints"; import type { Doc } from "../api/types"; import { formatDate, Loading } from "./ui"; /** Searchable checklist of posts. `single` turns it into a radio list. Pass `docs` to pick from those (a notebook's * posts) instead of the whole archive. Locked posts (not indexed on this plan) are shown but cannot be picked. */ export function DocPicker({ selected, onChange, single = false, exclude = [], max, docs: given, count = true, }: { selected: string[]; onChange: (ids: string[]) => void; single?: boolean; exclude?: string[]; max?: number; docs?: Doc[]; /** Show "N selected" next to the controls (off when the caller says it elsewhere). */ count?: boolean; }) { const [fetched, setFetched] = useState(null); const [q, setQ] = useState(""); const docs = given ?? fetched; useEffect(() => { if (!given) docsApi.list({ limit: 5000 }).then((p) => setFetched(p.items)); }, [given]); const visible = useMemo(() => { const skip = new Set(exclude); const needle = q.trim().toLowerCase(); return (docs ?? []).filter( (d) => !skip.has(d.id) && (!needle || d.title.toLowerCase().includes(needle) || (d.source_title ?? "").toLowerCase().includes(needle)), ); }, [docs, q, exclude]); // The tick-all box: ticked only while every pickable post in view is ticked, or with a `max`, exactly the first `max` // of them. Ticking it picks those, unticking it clears them. const pickable = visible.filter((d) => !d.locked); const full = !!max && selected.length >= max; const firstFew = max ? pickable.slice(0, max).map((d) => d.id) : null; const allOn = pickable.length > 0 && (firstFew ? selected.length === firstFew.length && firstFew.every((id) => selected.includes(id)) : pickable.every((d) => selected.includes(d.id))); if (!docs) return ; if (docs.length === 0) return

No posts yet. Connect a source first.

; const selectAll = () => { if (firstFew) return onChange(firstFew); onChange(Array.from(new Set([...selected, ...pickable.map((d) => d.id)]))); }; const toggle = (id: string) => { if (single) return onChange([id]); if (selected.includes(id)) return onChange(selected.filter((x) => x !== id)); if (max && selected.length >= max) return; onChange([...selected, id]); }; return (
setQ(e.target.value)} aria-label="Search posts" /> {!single && (
{count && {selected.length} selected{max ? ` of ${max}` : ""}}
)}
    {visible.map((d) => (
  • ))} {visible.length === 0 &&
  • No posts match.
  • }
); }