Spaces:
Runtime error
Runtime error
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>
);
}
|