File size: 4,102 Bytes
34b0226
 
 
 
 
070c931
 
34b0226
 
 
 
 
 
070c931
 
34b0226
 
 
 
 
 
070c931
 
 
34b0226
070c931
34b0226
070c931
34b0226
 
070c931
 
34b0226
 
 
 
 
 
 
 
 
db8dd62
 
070c931
db8dd62
 
 
 
 
070c931
34b0226
 
 
070c931
db8dd62
 
070c931
 
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
070c931
 
 
db8dd62
070c931
 
34b0226
 
 
 
 
 
070c931
db8dd62
070c931
34b0226
 
070c931
34b0226
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
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<Doc[] | null>(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 <Loading label="Loading posts" />;
  if (docs.length === 0) return <p className="muted">No posts yet. Connect a source first.</p>;

  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 (
    <div className="picker">
      <div className="picker-bar">
        <input className="input input-sm" placeholder="Search posts" value={q} onChange={(e) => setQ(e.target.value)} aria-label="Search posts" />
        {!single && (
          <div className="picker-actions mono muted">
            <label className="picker-all">
              <input type="checkbox" checked={allOn} disabled={pickable.length === 0}
                     onChange={() => (allOn ? onChange(selected.filter((id) => !pickable.some((d) => d.id === id))) : selectAll())} />
              {max && pickable.length > max ? `Select first ${max}` : "Select All"}
            </label>
            {count && <span>{selected.length} selected{max ? ` of ${max}` : ""}</span>}
          </div>
        )}
      </div>
      <ul className="picker-list">
        {visible.map((d) => (
          <li key={d.id}>
            <label className={`picker-row${selected.includes(d.id) ? " on" : ""}${d.locked ? " locked" : ""}`}>
              <input type={single ? "radio" : "checkbox"} name="doc-picker" checked={selected.includes(d.id)} disabled={d.locked || (!single && full && !selected.includes(d.id))}
                     onChange={() => toggle(d.id)} />
              <span className="picker-title">{d.title}</span>
              <span className="mono muted picker-meta">
                {formatDate(d.published_at)} · {d.locked ? "Not indexed on your plan" : `${d.words} words`}
              </span>
            </label>
          </li>
        ))}
        {visible.length === 0 && <li className="muted">No posts match.</li>}
      </ul>
    </div>
  );
}