Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535import React, { useEffect, useState } from "react";import { ArrowSquareOut, BookOpen, CheckCircle, NotePencil, PencilSimple, Plus, Trash,} from "@phosphor-icons/react";import { api, downloadText } from "./api.js";import { Empty, IconButton, LoadingButton, Modal, Spinner, handleTabListKeyDown,} from "./ui.jsx";import { Markdown } from "./Markdown.jsx";import { messageSources } from "./messageSources.js";
function MarkdownField({ label, value, onChange, limit = 100000, materialId, sources = [],}) { const [preview, setPreview] = useState(false); return ( <div className="markdown-field"> <div className="markdown-field-heading"> <span>{label}</span> <div className="segmented editor-tabs" role="tablist" aria-label={`${label} view`} data-selected={preview ? "second" : "first"} onKeyDown={handleTabListKeyDown} > <button type="button" role="tab" aria-selected={!preview} tabIndex={preview ? -1 : 0} onClick={() => setPreview(false)} > Write </button> <button type="button" role="tab" aria-selected={preview} tabIndex={preview ? 0 : -1} onClick={() => setPreview(true)} > Preview </button> </div> </div> {preview ? ( <div className="markdown-preview article-prose"> <Markdown content={value} materialId={materialId} sources={sources} compact /> </div> ) : ( <textarea aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} rows={7} maxLength={limit} placeholder="Markdown and LaTeX…" required /> )} </div> );}
export function LearningEditor({ editor, onClose, onSave, material }) { const [draft, setDraft] = useState({}), [busy, setBusy] = useState(false), [error, setError] = useState(""); useEffect(() => { setDraft(editor?.data || {}); setError(""); }, [editor]); if (!editor) return null; const card = editor.type === "card", highlight = editor.type === "highlight"; const set = (key, value) => setDraft((v) => ({ ...v, [key]: value })); return ( <Modal open={!!editor} onOpenChange={(open) => { if (!open && !busy) onClose(); }} title={ card ? draft.id ? "Edit review card" : "Create review card" : highlight ? "Passage note" : draft.id ? "Edit note" : "Save a note" } className="learning-editor" > <form onSubmit={async (e) => { e.preventDefault(); if (busy) return; setBusy(true); setError(""); try { await onSave(draft); onClose(); } catch (e) { setError(e.message); } finally { setBusy(false); } }} > <div className="learning-editor-fields"> {draft.quote && ( <details className="note-source"> <summary> Source passage{draft.page ? ` · page ${draft.page}` : ""} </summary> <blockquote>{draft.quote}</blockquote> </details> )} {!card && !highlight && ( <label className="floating-field"> <input value={draft.title || ""} onChange={(e) => set("title", e.target.value)} placeholder=" " maxLength={200} required /> <span>Title</span> </label> )} {card && ( <MarkdownField label="Question" value={draft.question || ""} onChange={(v) => set("question", v)} limit={12000} materialId={material?.id} /> )} <MarkdownField label={card ? "Answer" : "Note"} value={(card ? draft.answer : draft.body) || ""} onChange={(v) => set(card ? "answer" : "body", v)} limit={card ? 20000 : highlight ? 10000 : 100000} materialId={material?.id} sources={messageSources( { citations: draft.citations?.filter((c) => !c.comparison) }, material?.sources, )} /> {error && ( <p role="alert" className="inline-error"> {error} </p> )} </div> <div className="form-actions"> <button type="button" disabled={busy} onClick={onClose}> Cancel </button> <LoadingButton busy={busy} className="primary" type="submit" disabled={ !(card ? draft.question?.trim() && draft.answer?.trim() : draft.body?.trim() && (highlight || draft.title?.trim())) } > {card ? "Save card" : "Save note"} </LoadingButton> </div> </form> </Modal> );}
export function NotebookDialog({ open, onOpenChange, material, notes, onEdit, onCreate, onDelete, onOrigin, onCard, onCapture,}) { const [capturing, setCapturing] = useState(""); const [captureError, setCaptureError] = useState(""); return ( <Modal open={open} onOpenChange={onOpenChange} title="Notes" description={material?.title} wide className="notebook-dialog" > <div className="notebook-toolbar"> <span className="muted"> {notes.length} {notes.length === 1 ? "note" : "notes"} </span> <button onClick={onCreate}> <Plus size={16} /> New note </button> </div> {captureError && <p className="inline-error" role="alert">{captureError}</p>} {notes.length ? ( <div className="notebook-list"> {notes.map((note) => ( <section className="notebook-note" key={note.id} id={`note-${note.id}`} > <header> <h3>{note.title}</h3> <div className="entity-actions"> <IconButton label="Edit note" onClick={() => onEdit(note)}> <PencilSimple size={16} /> </IconButton> <IconButton label="Delete note" onClick={() => onDelete(note)} > <Trash size={16} /> </IconButton> </div> </header> {note.quote && <blockquote>{note.quote}</blockquote>} <div className="article-prose note-prose"> <Markdown content={note.body} materialId={material?.id} compact sources={messageSources( { citations: note.citations?.filter((c) => !c.comparison) }, material?.sources, )} comparisonCitations={note.citations} onComparisonPage={(page) => onOrigin({ material_id: note.citations.find( (c) => c.comparison && c.page === page, )?.materialId, page, }) } onPage={(page) => onOrigin({ ...note, page, chat_id: null })} /> </div> <footer> {onCapture && <button className="text-button" type="button" disabled={!!capturing} onClick={async () => { setCapturing(note.id); setCaptureError(""); try { await onCapture(note); } catch (error) { setCaptureError(error.message || "Unable to save this note."); } finally { setCapturing(""); } }}>{capturing === note.id ? "Saving…" : "Save to Notes"}</button>} <button className="text-button" onClick={() => onOrigin(note)}> <ArrowSquareOut size={14} /> {note.chat_id ? "Original conversation" : note.page ? `Page ${note.page}` : "Open material"} </button> <button className="text-button" onClick={() => onCard({ question: note.title, answer: note.body, quote: note.quote, page: note.page, note_id: note.id, }) } > Create review card </button> </footer> </section> ))} </div> ) : ( <Empty icon={NotePencil} title="Your notes, connected to what you read"> Save a passage or an assistant answer here. </Empty> )} {!!notes.length && ( <div className="form-actions"> <button onClick={() => downloadText( "notes.md", notes .map( (n) => `# ${n.title}\n\n${n.quote ? "> " + n.quote.replace(/\n/g, "\n> ") + "\n\n" : ""}${n.body}`, ) .join("\n\n---\n\n"), ) } > Export notes </button> </div> )} </Modal> );}
function interval(card, grade) { return grade === "again" ? "10m" : `${Math.round(grade === "hard" ? Math.max(1, card.interval_days * 1.2) : grade === "good" ? (card.interval_days ? Math.max(3, card.interval_days * 2) : 1) : card.interval_days ? Math.max(4, card.interval_days * 2.5) : 4)}d`;}export function ReviewDialog({ open, onOpenChange, onEdit, onDelete, onSource, revision = 0,}) { const [cards, setCards] = useState([]), [loading, setLoading] = useState(false), [revealed, setRevealed] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(""), [tab, setTab] = useState("today"), [completed, setCompleted] = useState(0); useEffect(() => { if (!open) return; let live = true; setLoading(true); setError(""); api("/review") .then((data) => { if (live) setCards(data.cards); }) .catch((e) => { if (live) setError(e.message); }) .finally(() => { if (live) setLoading(false); }); return () => { live = false; }; }, [open, revision]); useEffect(() => { if (open) { setRevealed(false); setCompleted(0); setTab("today"); } }, [open]); const due = cards.filter((c) => c.due_at <= new Date().toISOString()), card = due[0]; useEffect(() => setRevealed(false), [card?.id]); return ( <Modal open={open} onOpenChange={onOpenChange} title="Review" wide className="review-dialog" > <div className="segmented review-tabs" role="tablist" aria-label="Review views" data-selected={tab === "all" ? "second" : "first"} onKeyDown={handleTabListKeyDown} > <button role="tab" aria-selected={tab === "today"} tabIndex={tab === "today" ? 0 : -1} onClick={() => setTab("today")} > Today <span className="count">{due.length}</span> </button> <button role="tab" aria-selected={tab === "all"} tabIndex={tab === "all" ? 0 : -1} onClick={() => setTab("all")} > All cards <span className="count">{cards.length}</span> </button> </div> {error && ( <p className="inline-error" role="alert"> {error} </p> )} {loading ? ( <Spinner /> ) : tab === "all" ? ( <div className="review-card-list"> {cards.map((c) => ( <section key={c.id}> <div> <strong>{c.question}</strong> <span> {c.material_title} ·{" "} {c.due_at <= new Date().toISOString() ? "Due now" : new Date(c.due_at).toLocaleDateString()} </span> </div> <IconButton label="Edit card" onClick={() => onEdit(c)}> <PencilSimple size={16} /> </IconButton> <IconButton label="Delete card" onClick={() => onDelete(c)}> <Trash size={16} /> </IconButton> </section> ))} {!cards.length && ( <Empty title="No review cards yet"> Create one from an exercise, highlight or note. </Empty> )} </div> ) : card ? ( <> <div className="review-progress"> <span>{card.material_title}</span> <span> {completed} reviewed · {due.length} remaining </span> </div> <div className="review-content article-prose"> <Markdown content={card.question} materialId={card.material_id} /> {revealed && ( <div className="review-answer"> <Markdown content={card.answer} materialId={card.material_id} /> </div> )} </div> <div className="review-source"> <button className="text-button" onClick={() => onSource(card)}> <BookOpen size={15} /> Open source </button> <IconButton label="Edit card" onClick={() => onEdit(card)}> <PencilSimple size={16} /> </IconButton> </div> {!revealed ? ( <button className="primary review-reveal" onClick={() => setRevealed(true)} > Show answer </button> ) : ( <div className="review-grades"> {["again", "hard", "good", "easy"].map((grade) => ( <button key={grade} disabled={busy} onClick={async () => { if (busy) return; setBusy(true); setError(""); try { const updated = await api(`/cards/${card.id}/review`, { method: "POST", body: { grade }, }); setCards((list) => list.map((c) => c.id === card.id ? { ...c, ...updated } : c, ), ); setCompleted((n) => n + 1); } catch (e) { setError(e.message); } finally { setBusy(false); } }} > <span>{grade[0].toUpperCase() + grade.slice(1)}</span> <small>{interval(card, grade)}</small> </button> ))} </div> )} </> ) : ( <Empty icon={CheckCircle} title="You're up to date"> {completed ? `${completed} cards reviewed.` : cards.length ? "Your next cards will appear when they are due." : "Create a card from an exercise, highlight or note."} </Empty> )} </Modal> );}