Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366import React, { useCallback, useEffect, useRef, useState } from "react";import { BookOpen, SidebarSimple, ChatCircle, MagnifyingGlass, PencilSimple, X, CheckCircle, NotePencil, Stack,} from "@phosphor-icons/react";import { api, readPreference, savePreference } from "./api.js";import { IconButton, Modal, Spinner } from "./ui.jsx";import { slug } from "./Markdown.jsx";import { Assistant } from "./Assistant.jsx";import { ImportDialog } from "./ImportDialog.jsx";import { ConfirmationDialog } from "./ConfirmationDialog.jsx";import { useConfirmation } from "./hooks/useConfirmation.js";import { HighlightsDialog } from "./HighlightsDialog.jsx";import { MaterialEditorDialog } from "./MaterialEditorDialog.jsx";import { LibraryPanel } from "./LibraryPanel.jsx";import { ReaderPane } from "./ReaderPane.jsx";import { CommandPalette } from "./CommandPalette.jsx";import { captureSelection, applyArticleHighlights, scrollToArticleText,} from "./selection.js";import { readThemePreference, setDocumentTheme, ReaderThemeContext } from "./theme.js";import { GenerationDialog, RevisionDialog } from "./AuthoringDialogs.jsx";import "./styles.css";import { LearningContext } from "./LearningContext.jsx";import { LearningEditor, NotebookDialog, ReviewDialog,} from "./LearningDialogs.jsx";import { ComparePane, ComparePicker } from "./ComparePane.jsx";
/** @param {{ initialMaterialId?: string, onMaterialChange?: (id: string) => void, onOpenSettings?: () => void, onOpenNote?: (id: string) => void }} props */export function ReaderWorkspace({ initialMaterialId, onMaterialChange, onOpenSettings = () => {}, onOpenNote }) { const [materials, setMaterials] = useState([]), [providers, setProviders] = useState([]), [kind, setKind] = useState(readPreference("kind", "article")), [activeId, setActiveId] = useState(initialMaterialId || readPreference("materialId", "")), [material, setMaterial] = useState(null), [highlights, setHighlights] = useState([]), [libraryOpen, setLibraryOpen] = useState( readPreference("libraryOpen", true), ), [assistantOpen, setAssistantOpen] = useState( readPreference("assistantOpen", true), ), [libraryWidth, setLibraryWidth] = useState( readPreference("libraryWidth", 250), ), [assistantWidth, setAssistantWidth] = useState( readPreference("assistantWidth", 420), ), [assistantExpanded, setAssistantExpanded] = useState(false), [readingMode, setReadingMode] = useState(false), [viewportWidth, setViewportWidth] = useState(window.innerWidth), [theme] = useState(readThemePreference()), [generation, setGeneration] = useState(null), [revisionsOpen, setRevisionsOpen] = useState(false), [importing, setImporting] = useState(false), [highlightOpen, setHighlightOpen] = useState(false), [outlineOpen, setOutlineOpen] = useState(false), [outline, setOutline] = useState([]), [context, setContext] = useState(null), [selection, setSelection] = useState(null), [search, setSearch] = useState(""), [commandOpen, setCommandOpen] = useState(false), [learning, setLearning] = useState({ notes: [], cards: [], attempts: [] }), [learningEditor, setLearningEditor] = useState(null), [notebookOpen, setNotebookOpen] = useState(false), [reviewOpen, setReviewOpen] = useState(false), [learningRevision, setLearningRevision] = useState(0), [comparePickerOpen, setComparePickerOpen] = useState(false), [compareId, setCompareId] = useState(""), [compareWidth, setCompareWidth] = useState( Math.max(300, window.innerWidth * 0.45), ), [comparePage, setComparePage] = useState(1), [compareNavigation, setCompareNavigation] = useState(null), [draftRequest, setDraftRequest] = useState(null), [chatRequest, setChatRequest] = useState(null), [pendingHit, setPendingHit] = useState(null), [page, setPage] = useState(1), [pageNavigation, setPageNavigation] = useState(0), [loading, setLoading] = useState(true), [toast, setToast] = useState(null), [editing, setEditing] = useState(false), [edit, setEdit] = useState({}), [narrow, setNarrow] = useState(window.innerWidth < 1000); const initialMaterialRef = useRef(initialMaterialId), mounted = useRef(true), scopeRef = useRef(null), articlePositionRef = useRef(null), articleSaveQueue = useRef(Promise.resolve()), articleRef = useRef(), readerRef = useRef(), searchInputRef = useRef(null), screenshotRef = useRef(), initial = useRef(true), scrollTimer = useRef(), toastTimer = useRef(), positionReady = useRef(false), activeRef = useRef(activeId); activeRef.current = activeId; initialMaterialRef.current = initialMaterialId; const { confirm, dialogProps } = useConfirmation(); const noteUpdates = useRef(new Map()); useEffect(() => { mounted.current = true; const previousTitle = document.title; return () => { mounted.current = false; document.title = previousTitle; }; }, []); useEffect(() => { setDocumentTheme(theme, scopeRef.current); }, [theme]); useEffect(() => { document.title = material?.title ? material.title + " — Odre" : "Odre"; }, [material?.title]); const notify = useCallback((text, type = "success") => { if (!mounted.current) return; setToast({ text, type }); clearTimeout(toastTimer.current); toastTimer.current = setTimeout(() => setToast(null), 6000); }, []); const refreshProviders = useCallback( async () => { const rows = await api("/providers"); if (mounted.current) setProviders(rows); }, [], ); const refreshLibrary = useCallback(async () => { const list = await api("/materials"); if (!mounted.current) return list; setMaterials(list); if (initial.current && !initialMaterialRef.current) { initial.current = false; const stored = new URLSearchParams(location.search).get("material") || readPreference("materialId", ""); const selected = list.find((m) => m.id === stored) || list.find((m) => m.kind === readPreference("kind", "article")); if (selected) { setActiveId(selected.id); setKind(selected.kind); } else setActiveId(""); } return list; }, []); useEffect(() => { Promise.all([refreshLibrary(), refreshProviders()]) .catch((e) => notify(e.message, "error")) .finally(() => { if (mounted.current) setLoading(false); }); return () => { clearTimeout(toastTimer.current); clearTimeout(scrollTimer.current); }; }, []); useEffect(() => { if (initialMaterialId && initialMaterialId !== activeRef.current) setActiveId(initialMaterialId); }, [initialMaterialId]); useEffect(() => { const resize = () => { const root = scopeRef.current; const width = root?.clientWidth || window.innerWidth; if (root) root.style.setProperty("--reader-height", `${Math.max(180, window.innerHeight - Math.max(0, root.getBoundingClientRect().top))}px`); setViewportWidth(width); setNarrow(width < 1000); }; const observer = new ResizeObserver(resize); if (scopeRef.current) observer.observe(scopeRef.current); resize(); window.addEventListener("resize", resize); return () => { observer.disconnect(); window.removeEventListener("resize", resize); }; }, []); useEffect(() => { if (narrow) { setAssistantExpanded(false); setLibraryOpen(false); setAssistantOpen(false); } }, [narrow]); useEffect(() => { savePreference("materialId", activeId); setReadingMode(false); setAssistantExpanded(false); setContext(null); setSelection(null); setGeneration(null); setRevisionsOpen(false); setEditing(false); setHighlightOpen(false); setOutlineOpen(false); if (compareId === activeId) setCompareId(""); setMaterial(null); setHighlights([]); setLearning({ notes: [], cards: [], attempts: [] }); setLearningEditor(null); setNotebookOpen(false); setOutline([]); positionReady.current = false; if (!activeId) { setLoading(false); return; } const controller = new AbortController(); setLoading(true); Promise.all([ api("/materials/" + activeId, { signal: controller.signal }), api("/materials/" + activeId + "/highlights", { signal: controller.signal, }), api(`/materials/${activeId}/learning`, { signal: controller.signal }), ]) .then(([m, h, l]) => { if (controller.signal.aborted || !mounted.current) return; if ( new URLSearchParams(location.search).get("material") === m.id && new URLSearchParams(location.search).get("reading") === "1" ) setReadingMode(true); setMaterial(m); setKind(m.kind); setPage(m.position.page || 1); setHighlights(h); setLearning(l); document.title = m.title + " — Odre"; }) .catch((e) => { if (e.name !== "AbortError") { notify(e.message, "error"); setActiveId(""); } }) .finally(() => { if (!controller.signal.aborted) setLoading(false); }); return () => { controller.abort(); clearTimeout(scrollTimer.current); saveArticlePosition(); CSS.highlights?.delete("odre-saved"); }; }, [activeId]); useEffect(() => { if (material?.kind !== "article" || !articleRef.current) return; setOutline( [...material.body.matchAll(/^(#{2,3})\s+(.+)$/gm)].map((m) => ({ title: m[2].replace(/[*_`]/g, ""), depth: m[1].length - 2, anchor: slug(m[2].replace(/[*_`]/g, "")), })), ); const root = articleRef.current; const paint = () => applyArticleHighlights(root, highlights); paint(); const observer = new MutationObserver(paint); observer.observe(root, { childList: true, subtree: true }); let live = true; const restore = () => { if (!live || positionReady.current) return; const scroll = readerRef.current; if (scroll) scroll.scrollTop = (material.position.scrollRatio || 0) * Math.max(0, scroll.scrollHeight - scroll.clientHeight); }; const layoutObserver = new ResizeObserver(restore); layoutObserver.observe(root); requestAnimationFrame(restore); document.fonts.ready.then(restore); return () => { live = false; observer.disconnect(); layoutObserver.disconnect(); }; }, [material?.body, highlights, activeId]); useEffect(() => { if (!materials.some((m) => m.status === "indexing")) return; const timer = setInterval(async () => { try { const list = await refreshLibrary(); const row = list.find((m) => m.id === activeRef.current); if (row) setMaterial((current) => current && current.id === row.id ? { ...current, ...row, sources: current.sources } : current, ); } catch (e) { notify(e.message, "error"); } }, 1500); return () => clearInterval(timer); }, [materials.some((m) => m.status === "indexing")]); const toggleLibrary = () => { setReadingMode(false); setLibraryOpen((v) => { const next = !v; savePreference("libraryOpen", next); if (narrow && next) setAssistantOpen(false); return next; }); }; const toggleAssistant = () => { setReadingMode(false); setAssistantExpanded(false); setAssistantOpen((v) => { const next = !v; savePreference("assistantOpen", next); if (narrow && next) setLibraryOpen(false); return next; }); }; useEffect(() => { const keys = (e) => { const target = e.target; const editingText = target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)); const command = e.metaKey || e.ctrlKey; if (command && e.key.toLowerCase() === "k") { e.preventDefault(); e.stopImmediatePropagation(); if (!document.querySelector('[role="dialog"]')) { setCommandOpen(true); } return; } if (e.key === "Escape" && !document.querySelector('[role="dialog"]')) { if (readingMode) { setReadingMode(false); return; } if ( document.activeElement === searchInputRef.current && searchInputRef.current?.value ) { setSearch(""); return; } setSelection(null); if (narrow) { setLibraryOpen(false); setAssistantOpen(false); } return; } if (editingText) return; if (command && e.key.toLowerCase() === "b") { e.preventDefault(); toggleLibrary(); } if (command && e.key.toLowerCase() === "j") { e.preventDefault(); toggleAssistant(); } }; document.addEventListener("keydown", keys, true); return () => document.removeEventListener("keydown", keys, true); }, [narrow, readingMode]); const changeKind = (value) => { setKind(value); savePreference("kind", value); const last = readPreference("last-" + value, ""); const nextId = materials.some((m) => m.id === last && m.kind === value) ? last : ""; setActiveId(nextId); if (nextId && nextId !== activeId) onMaterialChange?.(nextId); setSearch(""); }; const openLibrary = () => { if (onMaterialChange) { saveArticlePosition(); onMaterialChange(""); return; } setActiveId(""); setSearch(""); setLibraryOpen(true); savePreference("libraryOpen", true); if (narrow) setAssistantOpen(false); }; const openAddMaterial = (nextKind) => { if (nextKind && nextKind !== kind) changeKind(nextKind); setImporting(true); }; const openMaterial = (m) => { if (m.id === compareId) setCompareId(""); setKind(m.kind); setActiveId(m.id); savePreference("kind", m.kind); savePreference("last-" + m.kind, m.id); onMaterialChange?.(m.id); if (narrow) setLibraryOpen(false); }; const onImported = async (m) => { await refreshLibrary(); if (m.id) openMaterial(m); }; const chooseContext = (s) => { setReadingMode(false); setContext(s); setSelection(null); setAssistantOpen(true); if (narrow) setLibraryOpen(false); }; const saveHighlight = async () => { if (!selection || !material) return; try { const { toolbar, ...body } = selection; const saved = await api("/materials/" + material.id + "/highlights", { method: "POST", body, }); setHighlights((v) => [...v, saved]); setSelection(null); window.getSelection()?.removeAllRanges(); notify("Highlight saved."); } catch (e) { notify(e.message, "error"); } }; const goPage = useCallback( (value) => { if (!material || material.kind !== "book") return; const number = Math.max( 1, Math.min(material.page_count || 5000, Number(value) || 1), ); setPage(number); setPageNavigation((value) => value + 1); setSelection(null); }, [material?.kind, material?.page_count], ); const visiblePdfPage = useCallback((number) => { setPage(number); setSelection(null); }, []); const captureArticleSelection = () => { const selected = captureSelection(articleRef.current); if (selected) setSelection(selected); }; const saveArticlePosition = () => { clearTimeout(scrollTimer.current); const pending = articlePositionRef.current; if (!pending) return; articlePositionRef.current = null; articleSaveQueue.current = articleSaveQueue.current.catch(() => {}).then(() => api(`/materials/${pending.id}`, { method: "PATCH", body: { position: pending.position } }), ).catch((error) => notify(error.message, "error")); }; const scrollArticle = () => { setSelection(null); if (!material || !positionReady.current || !readerRef.current) return; const el = readerRef.current; const ratio = el.scrollTop / Math.max(1, el.scrollHeight - el.clientHeight); articlePositionRef.current = { id: material.id, position: { scrollRatio: Math.max(0, Math.min(1, ratio)) } }; clearTimeout(scrollTimer.current); scrollTimer.current = setTimeout(saveArticlePosition, 500); }; const updateDiagram = useCallback( async (oldCode, newCode) => { const updated = await api("/materials/" + material.id, { method: "PATCH", body: { content: material.body.replace(oldCode, newCode), expected_version: material.version }, }); setMaterial(updated); }, [material?.id, material?.body], ); const startEdit = () => { setEdit({ title: material.title, subject: material.subject, content: material.body, sources: material.sources.map((source) => ({ ...source })), }); setEditing(true); }; const saveMaterialEdit = async (event) => { event.preventDefault(); try { const updated = await api("/materials/" + material.id, { method: "PATCH", body: { ...edit, expected_version: material.version }, }); setMaterial(updated); setEditing(false); await refreshLibrary(); notify("Material saved."); } catch (error) { notify(error.message, "error"); } }; const removeMaterial = () => confirm({ title: `Delete ${material.kind === "book" ? "book" : "article"}?`, description: material.title, detail: "This material, its conversations, highlights, reading notes, and review cards will be removed. Download an export or library backup first if you want to keep them.", label: material.kind === "book" ? "Delete book" : "Delete article", action: async () => { await api("/materials/" + material.id, { method: "DELETE" }); setEditing(false); setActiveId(""); onMaterialChange?.(""); await refreshLibrary(); notify("Material deleted."); }, }); const removeSource = (index) => confirm({ title: "Delete source?", description: material.sources[index].title, detail: "This reference will be removed from future answers. Existing conversations keep their saved citations. Links written in the article are kept.", label: "Delete source", action: async () => { setMaterial( await api(`/materials/${material.id}/sources/${index}`, { method: "DELETE", body: { expected_version: material.version }, }), ); await refreshLibrary(); notify("Source deleted."); }, }); const updateHighlightNote = (highlightId, note) => { setHighlights((items) => items.map((item) => (item.id === highlightId ? { ...item, note } : item)), ); }; const saveHighlightNote = (highlightId, note) => { const pending = noteUpdates.current.get(highlightId) || Promise.resolve(); const next = pending .catch(() => {}) .then(() => api("/highlights/" + highlightId, { method: "PATCH", body: { note } }), ); noteUpdates.current.set(highlightId, next); next .finally(() => { if (noteUpdates.current.get(highlightId) === next) noteUpdates.current.delete(highlightId); }) .catch(() => {}); return next; }; const removeHighlight = (highlight) => confirm({ title: "Delete highlight?", description: highlight.quote.slice(0, 180), detail: "The saved passage and its note will be deleted. The original material is kept.", label: "Delete highlight", action: async () => { await noteUpdates.current.get(highlight.id)?.catch(() => {}); await api("/highlights/" + highlight.id, { method: "DELETE" }); setHighlights((items) => items.filter((item) => item.id !== highlight.id), ); notify("Highlight deleted."); }, }); const clearHighlightNote = (highlight) => confirm({ title: "Delete note?", description: "Keep the highlighted passage and remove only your note.", label: "Delete note", action: async () => { await saveHighlightNote(highlight.id, ""); updateHighlightNote(highlight.id, ""); notify("Note deleted."); }, }); const assistantLimit = narrow ? Math.max(280, viewportWidth - 24) : Math.max( 320, viewportWidth - (libraryOpen ? Math.min(libraryWidth, viewportWidth * 0.22) + 8 : 0) - 392, ); const assistantDisplayWidth = Math.min(assistantWidth, assistantLimit); const resizePanel = (side, e) => { e.preventDefault(); const start = e.clientX, initialWidth = side === "left" ? libraryWidth : assistantDisplayWidth; scopeRef.current?.classList.add("panel-resizing"); const move = (ev) => { const max = side === "left" ? Math.min( 370, viewportWidth - (assistantOpen ? assistantDisplayWidth : 0) - 400, ) : assistantLimit; const width = Math.max( side === "left" ? 220 : Math.min(320, assistantLimit), Math.min( max, initialWidth + (ev.clientX - start) * (side === "left" ? 1 : -1), ), ); if (side === "left") setLibraryWidth(width); else setAssistantWidth(width); }; const end = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", end); window.removeEventListener("pointercancel", end); scopeRef.current?.classList.remove("panel-resizing"); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", end, { once: true }); window.addEventListener("pointercancel", end, { once: true }); }; useEffect(() => { savePreference("libraryWidth", libraryWidth); savePreference("assistantWidth", assistantWidth); }, [libraryWidth, assistantWidth]); const filtered = materials.filter( (m) => m.kind === kind && `${m.title} ${m.subject}`.toLowerCase().includes(search.toLowerCase()), ); const jumpHighlight = (h) => { positionReady.current = true; if (h.page) { goPage(h.page); setHighlightOpen(false); return; } const root = articleRef.current; if (!root) return; if (scrollToArticleText(root, h.quote, { behavior: "smooth" })) { setHighlightOpen(false); return; } notify("This passage has changed since it was highlighted.", "error"); }; const refreshLearning = async (mid = activeId) => { const next = await api(`/materials/${mid}/learning`); if (!mounted.current) return; if (activeRef.current === mid) setLearning(next); setLearningRevision((v) => v + 1); }; const openCard = (data) => setLearningEditor({ type: "card", data }); const askFeedback = ({ exercise, answer }) => { setReadingMode(false); const text = `Review my answer to this exercise. Explain what is correct, identify specific mistakes, and guide me through any missing steps. Use the material as evidence and cite the relevant passage or source. Answer in the language of my response.\n\nQuestion:\n${exercise.question}\n\nMy answer:\n${answer}\n\nReference solution:\n${exercise.solution || "No reference solution supplied."}`; if (text.length > 20000) { notify( "This exercise and answer exceed the chat limit. Shorten your answer before requesting feedback.", "error", ); return; } setContext(null); setAssistantExpanded(false); setAssistantOpen(true); if (narrow) setLibraryOpen(false); setDraftRequest({ materialId: material.id, token: Date.now(), text, }); }; const saveAttempt = async (exerciseKey, answer) => { const mid = material.id; await api(`/materials/${mid}/attempts`, { method: "POST", body: { exerciseKey, answer }, }); if (activeRef.current === mid) setLearning((v) => ({ ...v, attempts: [ ...v.attempts.filter((a) => a.exercise_key !== exerciseKey), { exercise_key: exerciseKey, answer }, ], })); }; const saveLearning = async (draft) => { const { type } = learningEditor, mid = learningEditor.materialId || material.id; if (type === "highlight") { if (draft.id) { await saveHighlightNote(draft.id, draft.body); updateHighlightNote(draft.id, draft.body); } else { const { toolbar, ...location } = draft.selection; const saved = await api(`/materials/${mid}/highlights`, { method: "POST", body: { ...location, note: draft.body }, }); if (activeRef.current === mid) setHighlights((v) => [...v, saved]); setSelection(null); window.getSelection()?.removeAllRanges(); } } else { const resource = type === "card" ? "cards" : "notes"; const saved = await api( draft.id ? `/${resource}/${draft.id}` : `/materials/${mid}/${resource}`, { method: draft.id ? "PATCH" : "POST", body: type === "card" ? { question: draft.question, answer: draft.answer, quote: draft.quote || "", page: draft.page || null, noteId: draft.note_id || null, } : { title: draft.title, body: draft.body, quote: draft.quote || "", page: draft.page || null, chatId: draft.chat_id || null, messageId: draft.message_id || null, }, }, ); if (activeRef.current === mid) setLearning((current) => ({ ...current, [resource]: [ saved, ...current[resource].filter((item) => item.id !== saved.id), ], })); setLearningRevision((v) => v + 1); } notify(type === "card" ? "Review card saved." : "Note saved."); }; const removeLearning = (type, item) => confirm({ title: type === "card" ? "Delete review card?" : "Delete note?", description: (item.title || item.question).slice(0, 160), label: "Delete", action: async () => { await api(`/${type === "card" ? "cards" : "notes"}/${item.id}`, { method: "DELETE", }); await refreshLearning(item.material_id); notify("Deleted."); }, }); const chooseComparison = (m) => { setReadingMode(false); setCompareId(m.id); setCompareWidth(Math.max(300, Math.min(viewportWidth * 0.45, 700))); setCompareNavigation(null); setLibraryOpen(false); setAssistantOpen(false); setAssistantExpanded(false); }; const openComparisonPage = (mid, p) => { if (!mid || !materials.some((m) => m.id === mid)) { notify("The comparison material is no longer available.", "error"); return; } setCompareId(mid); setCompareNavigation({ page: p, token: Date.now() }); if (narrow) setAssistantOpen(false); }; const askBoth = (s) => { setReadingMode(false); setAssistantOpen(true); setAssistantExpanded(false); if (narrow) setLibraryOpen(false); if (s.quote) setContext({ quote: s.quote }); else setDraftRequest({ materialId: material.id, token: Date.now(), text: "Compare the explanations in these two materials. Identify where they agree, how their approaches differ, and cite each source separately.", }); }; const openSearchHit = (hit) => { setReadingMode(false); const m = materials.find( (m) => m.id === (hit.material_id || hit.materialId), ); if (!m) return; openMaterial(m); setPendingHit({ ...hit, token: Date.now() }); setCommandOpen(false); setNotebookOpen(false); setReviewOpen(false); }; useEffect(() => { if (!pendingHit || loading || material?.id !== pendingHit.material_id) return; const hit = pendingHit; positionReady.current = true; const timer = setTimeout(() => { if (hit.type === "chat" || (hit.type === "origin" && hit.chat_id)) { setAssistantOpen(true); if (narrow) setLibraryOpen(false); setChatRequest({ materialId: material.id, chatId: hit.chat_id, messageId: hit.message_id, token: Date.now(), }); } else if (hit.type === "note") { setNotebookOpen(true); setTimeout( () => document .getElementById(`note-${hit.item_id}`) ?.scrollIntoView({ block: "center" }), 100, ); } else if (hit.page) goPage(hit.page); else if (articleRef.current) { const root = articleRef.current; const anchor = hit.heading ? root.querySelector("#" + CSS.escape(slug(hit.heading))) : null; if (anchor) anchor.scrollIntoView({ block: "start" }); else { const term = (hit.quote || hit.query || "") .slice(0, 65) .toLocaleLowerCase(); if (term) scrollToArticleText(root, term); } } setPendingHit(null); }, 160); return () => clearTimeout(timer); }, [pendingHit, loading, material?.id]); return ( <ReaderThemeContext.Provider value={theme}> <div className="odre-reader" ref={scopeRef} data-reader-theme={theme}> <div className={ "app " + (readingMode && material?.kind === "article" ? "reading-mode" : "") } > <header className="app-header"> <button className="reader-back" onClick={openLibrary} aria-label="Reader library"> <BookOpen size={20} /> </button> <div className="panel-toggles"> <button className={libraryOpen ? "active" : ""} onClick={toggleLibrary} aria-label="Library" aria-expanded={libraryOpen} title="Toggle library (⌘B)" > <SidebarSimple size={21} /> <span>Library</span> </button> <button className={material && assistantOpen ? "active" : ""} onClick={toggleAssistant} aria-label="Assistant" aria-expanded={!!material && assistantOpen} disabled={!material} title={ material ? "Toggle assistant (⌘J)" : "Open a material to use the assistant" } > <ChatCircle size={21} /> <span>Assistant</span> </button> </div> <button type="button" className="reader-generate" onClick={() => setGeneration({})}>Generate article</button> <IconButton label="Review cards" onClick={() => setReviewOpen(true)}> <Stack size={20} /> </IconButton> <nav className="breadcrumb" aria-label="Breadcrumb"> <button onClick={() => setActiveId("")}> {kind === "article" ? "Articles" : "Books"} </button> {material && ( <> <span>/</span> {material.subject && ( <> <span className="breadcrumb-subject">{material.subject}</span> <span>/</span> </> )} <span className="breadcrumb-current">{material.title}</span> </> )} </nav> <form className="global-search" role="search" aria-label="Search library" onSubmit={(e) => { e.preventDefault(); setCommandOpen(true); }} > <MagnifyingGlass size={18} aria-hidden="true" /> <input ref={searchInputRef} aria-label="Search library" aria-keyshortcuts="Meta+K Control+K" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search library…" title="Search library. Quick access: ⌘K / Ctrl+K" /> {search && ( <IconButton label="Clear search" onClick={() => { setSearch(""); searchInputRef.current?.focus(); }} > <X size={15} /> </IconButton> )} </form> </header> <div className="workspace"> <LibraryPanel inactive={assistantExpanded || readingMode} open={libraryOpen} width={libraryWidth} kind={kind} materials={filtered} activeId={activeId} loading={loading} search={search} highlightCount={highlights.length} onKindChange={changeKind} onClose={toggleLibrary} onAdd={() => setImporting(true)} onOpenMaterial={openMaterial} onShowHighlights={() => { if (material) setHighlightOpen(true); else notify("Open a material to see its highlights."); }} onSettings={onOpenSettings} onResize={(event) => resizePanel("left", event)} /> {narrow && (libraryOpen || assistantOpen) && ( <button className="drawer-backdrop" aria-label="Close side panels" onClick={() => { setLibraryOpen(false); setAssistantOpen(false); }} /> )} <LearningContext.Provider value={{ materialId: material?.id, attempts: learning.attempts, onAttempt: saveAttempt, onFeedback: askFeedback, onCard: openCard, }} > <ReaderPane inactive={assistantExpanded && !readingMode} narrow={narrow} sidePanelsOpen={!readingMode && (libraryOpen || assistantOpen)} readingMode={readingMode} onReadingMode={() => { setReadingMode((v) => !v); setAssistantExpanded(false); }} loading={loading} material={material} kind={kind} materials={filtered} onAddMaterial={() => setImporting(true)} onOpenMaterial={openMaterial} onOpenOutline={() => setOutlineOpen(true)} onOpenHighlights={() => setHighlightOpen(true)} onStartEdit={startEdit} onDeleteMaterial={removeMaterial} onNotes={() => setNotebookOpen(true)} onCompare={() => setComparePickerOpen(true)} onGenerate={() => setGeneration({})} onRewrite={() => setGeneration({ material })} onRevisions={() => setRevisionsOpen(true)} readerRef={readerRef} articleRef={articleRef} onArticleScroll={scrollArticle} onMarkPositionReady={() => { positionReady.current = true; }} onArticleSelection={captureArticleSelection} onDiagramChange={updateDiagram} page={page} onPage={goPage} onVisiblePage={visiblePdfPage} navigationToken={pageNavigation} highlights={highlights} onSelection={setSelection} onContext={chooseContext} onOutline={setOutline} screenshotRef={screenshotRef} notify={notify} /> </LearningContext.Provider> {compareId && material && ( <ComparePane inactive={ assistantExpanded || (narrow && (assistantOpen || libraryOpen)) } materialId={compareId} width={compareWidth} onWidth={setCompareWidth} onClose={() => setCompareId("")} onChoose={() => setComparePickerOpen(true)} onAsk={askBoth} onPageChange={setComparePage} navigation={compareNavigation} notify={notify} /> )} {material && ( <div className={ "assistant-wrap " + (!assistantOpen ? "hidden-panel " : "") + (assistantExpanded ? "expanded" : "") } style={{ width: assistantDisplayWidth, "--assistant-width": `${assistantDisplayWidth}px`, }} inert={!assistantOpen || readingMode ? "" : undefined} > <div className="resize-handle left" role="separator" aria-orientation="vertical" aria-label="Resize assistant" tabIndex={assistantExpanded ? -1 : 0} aria-valuemin={Math.min(320, assistantLimit)} aria-valuemax={assistantLimit} aria-valuenow={assistantDisplayWidth} onKeyDown={(event) => { if ( !["ArrowLeft", "ArrowRight", "Home", "End"].includes( event.key, ) ) return; event.preventDefault(); setAssistantWidth( event.key === "Home" ? Math.min(320, assistantLimit) : event.key === "End" ? assistantLimit : Math.max( Math.min(320, assistantLimit), Math.min( assistantLimit, assistantDisplayWidth + (event.key === "ArrowLeft" ? 32 : -32), ), ), ); }} onPointerDown={(e) => resizePanel("right", e)} /> <Assistant material={material} providers={providers} context={context} onClearContext={() => setContext(null)} currentPage={page} screenshotRef={screenshotRef} onClose={toggleAssistant} expanded={assistantExpanded} onExpand={() => setAssistantExpanded((value) => !value)} onSettings={onOpenSettings} onPage={goPage} onRemoveSource={removeSource} onRemoveMaterial={removeMaterial} notify={notify} onSaveNote={(message, chatId) => setLearningEditor({ type: "note", data: { title: ( message.content.split("\n").find((line) => line.trim()) || "Assistant note" ) .replace(/^#+\s*/, "") .slice(0, 200), body: message.content, chat_id: chatId, message_id: message.id, citations: message.citations, }, }) } draftRequest={draftRequest} chatRequest={chatRequest} comparison={ compareId ? { materialId: compareId, ...(materials.find((m) => m.id === compareId)?.kind === "book" ? { page: comparePage } : {}), } : undefined } onComparisonPage={openComparisonPage} /> </div> )} </div> {selection && ( <div className="selection-toolbar" style={selection.toolbar} onPointerDown={(e) => e.preventDefault()} > <button onClick={saveHighlight}> <PencilSimple size={16} /> Highlight </button> <button onClick={() => setLearningEditor({ type: "highlight", data: { quote: selection.quote, page: selection.page, body: "", selection: { ...selection }, }, }) } > <NotePencil size={16} /> Note </button> {material?.kind === "article" && <button onClick={() => { setGeneration({ material, selection: { quote: selection.quote, prefix: selection.prefix, suffix: selection.suffix } }); setSelection(null); }}>Rewrite</button>} <button className="ask-action" onClick={() => chooseContext(selection)} > <ChatCircle size={17} /> Ask </button> <IconButton label="Dismiss selection actions" onClick={() => setSelection(null)} > <X size={14} /> </IconButton> </div> )} <CommandPalette open={commandOpen} onOpenChange={setCommandOpen} materials={materials} onOpenMaterial={openMaterial} onOpenLibrary={openLibrary} onAddMaterial={openAddMaterial} onSettings={onOpenSettings} initialQuery={search} onSearchHit={openSearchHit} onReview={() => setReviewOpen(true)} /> <ImportDialog open={importing} onOpenChange={setImporting} kind={kind} onImported={onImported} notify={notify} /> <Modal open={outlineOpen} onOpenChange={setOutlineOpen} title="Table of contents" > <div className="outline-list"> {outline.length ? ( outline.map((item, i) => ( <button key={i} style={{ paddingLeft: 18 + (item.depth || 0) * 18 }} onClick={() => { positionReady.current = true; if (item.page) goPage(item.page); else if (item.anchor) articleRef.current ?.querySelector("#" + CSS.escape(item.anchor)) ?.scrollIntoView({ behavior: "smooth", block: "start" }); setOutlineOpen(false); }} > {item.title} {item.page && <span>{item.page}</span>} </button> )) ) : ( <p className="muted"> {material?.kind === "book" ? "This PDF has no embedded table of contents. Use the page control or book search." : "Add Markdown headings to organize your article."} </p> )} </div> </Modal> <HighlightsDialog open={highlightOpen} onOpenChange={setHighlightOpen} material={material} highlights={highlights} onJump={jumpHighlight} onNoteChange={updateHighlightNote} onRemove={removeHighlight} onClearNote={clearHighlightNote} onNoteSave={(highlightId, note) => saveHighlightNote(highlightId, note).catch((error) => notify(error.message, "error"), ) } onEditNote={(h) => setLearningEditor({ type: "highlight", data: { ...h, body: h.note } }) } onCard={openCard} /> <NotebookDialog open={notebookOpen} onOpenChange={setNotebookOpen} material={material} notes={learning.notes} onEdit={(data) => setLearningEditor({ type: "note", data })} onCreate={() => setLearningEditor({ type: "note", data: { title: "", body: "" } }) } onDelete={(n) => removeLearning("note", n)} onOrigin={(n) => openSearchHit({ ...n, type: "origin" })} onCard={openCard} onCapture={async (note) => { const saved = await api(`/notes/${note.id}/capture`, { method: "POST", body: {} }); notify("Saved to Notes."); onOpenNote?.(saved.id); }} /> <LearningEditor editor={learningEditor} onClose={() => setLearningEditor(null)} onSave={saveLearning} material={ learningEditor?.materialId && learningEditor.materialId !== material?.id ? { id: learningEditor.materialId, sources: [] } : material } /> <ReviewDialog open={reviewOpen} onOpenChange={setReviewOpen} revision={learningRevision} onEdit={(data) => setLearningEditor({ type: "card", data, materialId: data.material_id, }) } onDelete={(c) => removeLearning("card", c)} onSource={(c) => openSearchHit({ ...c, type: c.note_id ? "note" : "origin", item_id: c.note_id, }) } /> <ComparePicker open={comparePickerOpen} onOpenChange={setComparePickerOpen} materials={materials} currentId={activeId} onChoose={chooseComparison} /> <MaterialEditorDialog open={editing} onOpenChange={setEditing} material={material} edit={edit} onFieldChange={(field, value) => setEdit((current) => ({ ...current, [field]: value })) } onSourcesChange={(sources) => setEdit((current) => ({ ...current, sources })) } onSave={saveMaterialEdit} onDelete={removeMaterial} /> <GenerationDialog open={generation !== null} onOpenChange={(open) => { if (!open) setGeneration(null); }} material={generation?.material} selection={generation?.selection} providers={providers} onSettings={onOpenSettings} onApplied={async (updated) => { if (updated.id === activeRef.current) { setMaterial(updated); positionReady.current = true; } else openMaterial(updated); setGeneration(null); await refreshLibrary(); notify("Article saved."); }} /> <RevisionDialog open={revisionsOpen} onOpenChange={setRevisionsOpen} material={material} onRestored={async (updated) => { setMaterial(updated); await refreshLibrary(); notify("Previous version restored."); }} /> <ConfirmationDialog {...dialogProps} /> {toast && ( <div className={"toast " + toast.type} role={toast.type === "error" ? "alert" : "status"} > <CheckCircle size={18} /> <span>{toast.text}</span> <IconButton label="Dismiss notification" onClick={() => setToast(null)} > <X size={16} /> </IconButton> </div> )} </div> </div> </ReaderThemeContext.Provider> );}