File size: 7,336 Bytes
34b0226
 
3c705c5
 
34b0226
3c705c5
 
34b0226
3c705c5
34b0226
3c705c5
 
34b0226
3c705c5
34b0226
 
 
 
3c705c5
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
 
 
3c705c5
34b0226
 
 
 
 
 
 
 
 
 
 
3c705c5
 
34b0226
3c705c5
34b0226
3c705c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
3c705c5
 
 
34b0226
3c705c5
34b0226
 
 
 
 
 
 
 
3c705c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34b0226
 
 
 
 
 
 
 
3c705c5
 
34b0226
 
 
 
 
 
 
 
 
bcfc897
3c705c5
34b0226
3c705c5
34b0226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c705c5
34b0226
 
3c705c5
 
 
 
 
34b0226
 
3c705c5
 
 
34b0226
 
 
3c705c5
34b0226
 
 
3c705c5
 
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
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { docsApi, notebooksApi } from "../api/endpoints";
import type { Doc, NotebookSummary } from "../api/types";
import { DocPicker } from "../components/DocPicker";
import { PlanetIcon } from "../components/icons/Icons";
import { ConfirmButton, errorMessage, formatDate, Loading, Modal, PageHeader } from "../components/ui";

/** New notebook from a topic: typing it finds and pre-selects the matching posts; hand picking is the fallback. */
export function NewNotebookModal({ onClose, initialDocs = [] }: { onClose: () => void; initialDocs?: string[] }) {
  const [topic, setTopic] = useState("");
  const [matches, setMatches] = useState<Doc[]>([]);
  const [selected, setSelected] = useState<string[]>(initialDocs);
  const [manual, setManual] = useState(initialDocs.length > 0);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const navigate = useNavigate();

  useEffect(() => {
    if (manual || topic.trim().length < 2) {
      if (!manual) setMatches([]);
      return;
    }
    const t = setTimeout(async () => {
      const page = await docsApi.list({ q: topic.trim(), limit: 50 });
      const found = page.items.filter((d) => !d.locked);
      setMatches(found);
      setSelected(found.map((d) => d.id));
    }, 250);
    return () => clearTimeout(t);
  }, [topic, manual]);

  const submit = async (e: FormEvent) => {
    e.preventDefault();
    setBusy(true);
    setError(null);
    try {
      const nb = await notebooksApi.create(topic.trim() || "Untitled notebook", selected);
      navigate(`/app/notebooks/${nb.id}`);
    } catch (err) {
      setError(errorMessage(err));
      setBusy(false);
    }
  };

  return (
    <Modal title="New notebook" onClose={onClose} wide>
      <form onSubmit={submit} className="stack">
        <label className="field">
          <span>What is it about?</span>
          <input className="input" autoFocus value={topic} onChange={(e) => setTopic(e.target.value)} placeholder="Pricing, paid tiers, 2024 essays..." />
        </label>
        {manual ? (
          <DocPicker selected={selected} onChange={setSelected} />
        ) : (
          <>
            {topic.trim().length >= 2 && (
              <p className="muted small">
                {matches.length ? `${matches.length} posts mention it. Untick any that do not belong.` : "No posts mention that yet."}
              </p>
            )}
            {matches.length > 0 && (
              <ul className="picker-list nb-matches">
                {matches.map((d) => (
                  <li key={d.id}>
                    <label className={`picker-row${selected.includes(d.id) ? " on" : ""}`}>
                      <input
                        type="checkbox"
                        checked={selected.includes(d.id)}
                        onChange={(e) => setSelected(e.target.checked ? [...selected, d.id] : selected.filter((x) => x !== d.id))}
                      />
                      <span className="picker-title">{d.title}</span>
                      <span className="mono muted picker-meta">{formatDate(d.published_at)}</span>
                    </label>
                  </li>
                ))}
              </ul>
            )}
          </>
        )}
        {error && <p className="error-text">{error}</p>}
        <div className="row between">
          <button type="button" className="link-btn small" onClick={() => setManual(!manual)}>
            {manual ? "Find posts by topic instead" : "Pick posts by hand"}
          </button>
          <button className="btn btn-primary" disabled={busy || !selected.length}>
            {busy ? "Creating..." : `Create with ${selected.length} posts`}
          </button>
        </div>
      </form>
    </Modal>
  );
}

function CardMenu({ onRename, onDelete }: { onRename: () => void; onDelete: () => Promise<void> }) {
  const [open, setOpen] = useState(false);
  return (
    <div className="card-menu">
      <button type="button" className="icon-btn" aria-label="Notebook options" aria-expanded={open} onClick={() => setOpen(!open)}>
        ⋯
      </button>
      {open && (
        <div className="card-menu-list" onMouseLeave={() => setOpen(false)}>
          <button
            type="button"
            onClick={() => {
              setOpen(false);
              onRename();
            }}
          >
            Rename
          </button>
          <ConfirmButton onConfirm={onDelete}>Delete</ConfirmButton>
        </div>
      )}
    </div>
  );
}

export default function Notebooks() {
  const [notebooks, setNotebooks] = useState<NotebookSummary[] | null>(null);
  const [creating, setCreating] = useState(false);
  const [renaming, setRenaming] = useState<string | null>(null);
  const [name, setName] = useState("");

  const load = useCallback(() => notebooksApi.list().then(setNotebooks), []);
  useEffect(() => {
    // "All posts" always exists, so asking the whole archive never needs setup.
    notebooksApi.archive().finally(load);
  }, [load]);

  return (
    <div className="page-wrap">
      <PageHeader eyebrow="Notebooks" title="Research notebooks">
        <button className="btn btn-primary" onClick={() => setCreating(true)}>
          New notebook
        </button>
      </PageHeader>
      {!notebooks && <Loading center="page" />}
      <section className="nb-grid">
        {notebooks?.map((n) => (
          <article key={n.id} className={`card nb-card${n.is_archive ? " nb-archive" : ""}`}>
            {renaming === n.id ? (
              <form
                className="inline-form"
                onSubmit={async (e) => {
                  e.preventDefault();
                  await notebooksApi.update(n.id, { title: name });
                  setRenaming(null);
                  load();
                }}
              >
                <input className="input input-sm" autoFocus value={name} onChange={(e) => setName(e.target.value)} aria-label="Notebook title" />
                <button className="btn btn-small btn-primary">Save</button>
              </form>
            ) : (
              <Link to={`/app/notebooks/${n.id}`} className="nb-card-link">
                {n.is_archive && <PlanetIcon size={26} />}
                <h3>{n.title}</h3>
                {(n.description || n.summary) && <p className="muted clamp-3">{n.description || n.summary}</p>}
                <p className="mono muted">
                  {n.document_count} posts
                  {n.chat_count ? ` · ${n.chat_count} chats` : ""}
                  {n.artifact_count ? ` · ${n.artifact_count} made` : ""} · {formatDate(n.updated_at)}
                </p>
              </Link>
            )}
            {!n.is_archive && renaming !== n.id && (
              <CardMenu
                onRename={() => {
                  setRenaming(n.id);
                  setName(n.title);
                }}
                onDelete={async () => {
                  await notebooksApi.remove(n.id);
                  load();
                }}
              />
            )}
          </article>
        ))}
      </section>
      {creating && <NewNotebookModal onClose={() => setCreating(false)} />}
    </div>
  );
}