Something went wrong. Try again.
Original-language reader for Kant, Aristotle & more: select any passage to gloss, translate, or hear it read aloud, with exact citations.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728import { useQuery } from "convex/react"import { ArrowLeft, BookOpenText, ChevronLeft, Copy, ExternalLink, FileText, Image as ImageIcon, Languages, LogIn, LogOut, Minus, MoreHorizontal, PanelLeftClose, PanelLeftOpen, PanelsTopLeft, Plus, Volume2,} from "lucide-react"import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type JSX,} from "react"import { Link, useParams } from "react-router-dom"
import { api } from "../../convex/_generated/api"import { applyCorrections } from "@/lib/model/applyCorrections"import { ArtifactPane } from "@/read/ArtifactPane"import { useArtifactLink } from "@/read/useArtifactLink"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { SettingsMenu } from "@/components/settings-menu"import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger,} from "@/components/ui/sheet"import { Skeleton } from "@/components/ui/skeleton"import { copyPageMarkdown, pageMarkdownUrl } from "@/lib/pageMarkdown"import { useLightAuth } from "@/auth/context"import { useCatalog, useDocumentModel, workSiblings } from "@/lib/corpus"import { anchorIndexAt, headingIdAt, type GoToTarget } from "@/lib/model/locus"import type { DocumentModel, HeadingNode } from "@/lib/schemas/model"import { cn } from "@/lib/utils"import { CitationReadout } from "@/read/CitationReadout"import { FacsimilePane } from "@/read/FacsimilePane"import { GoTo } from "@/read/GoTo"import { LANG_LABEL, SCHEME_LABEL } from "@/read/labels"import { PageExport } from "@/read/PageExport"import { BrowseBar } from "@/read/BrowseBar"import { ReadingBody } from "@/read/ReadingBody"import { SelectionLayer } from "@/read/SelectionLayer"import { SidePanel, type PanelMode } from "@/read/SidePanel"import { Toc } from "@/read/Toc"import { useReadingNav } from "@/read/useReadingNav"import { WorkNav } from "@/read/WorkNav"import { LoginButton } from "@/auth/LoginButton"
/** * Reading view (§1 render + §2 navigation). Loads + validates the Tier-1 model, * then composes the faithful renderer with the TOC, page rail, citation readout, * Go To, and URL state. Document scroll (no nested scroll container) so native * selection + Find-in-page work over the whole text. */
const PANEL_MIN = 280const PANEL_WIDTH_KEY = "lekterion:panelWidth"const TOC_COLLAPSED_KEY = "lekterion:tocCollapsed"const ZOOM_STEP = 0.25const ZOOM_MIN = 1 // 100% already fits the pane to width — no reason to go smallerconst ZOOM_MAX = 4
function readPanelWidth(): number | null { try { const v = window.localStorage.getItem(PANEL_WIDTH_KEY) return v ? Number(v) : null } catch { return null }}
function readTocCollapsed(): boolean { try { return window.localStorage.getItem(TOC_COLLAPSED_KEY) === "1" } catch { return false }}
function BackLink(): JSX.Element { return ( <Link to="/" className="reader-head-back"> <ArrowLeft className="size-4" aria-hidden="true" /> <span>Library</span> </Link> )}
function ReadingSkeleton(): JSX.Element { return ( <div className="reader-shell"> <header className="reader-head"> <div className="reader-head-inner"> <BackLink /> </div> </header> <main className="reader-main"> <div className="reader-column" aria-busy="true"> <Skeleton className="mb-8 h-8 w-2/3" /> <div className="space-y-3"> {Array.from({ length: 9 }).map((_, i) => ( <Skeleton key={i} className="h-4" style={{ width: `${String(72 + ((i * 13) % 28))}%` }} /> ))} </div> </div> </main> </div> )}
function ReadingError({ message }: { message: string }): JSX.Element { return ( <div className="reader-shell"> <header className="reader-head"> <div className="reader-head-inner"> <BackLink /> </div> </header> <main className="reader-main"> <div className="reader-column"> <p className="reader-notice-kicker">Unavailable</p> <h1 className="reader-notice-title">This text could not be opened</h1> <p className="reader-notice-body">{message}</p> <Link to="/" className="reader-notice-link"> <ArrowLeft className="size-4" aria-hidden="true" /> Return to the library </Link> </div> </main> </div> )}
function Reader({ model: source }: { model: DocumentModel }): JSX.Element { const rootRef = useRef<HTMLElement>(null)
// Effective model = canonical source + correction overlay (§7). Everything // below reads `model`, so the whole reader renders/navigates/cites/normalizes // over the corrected text without further plumbing; the `.md` is untouched. const corrections = useQuery(api.corrections.listForText, { textId: source.id, }) const model = useMemo( () => applyCorrections(source, corrections ?? []), [source, corrections] )
const nav = useReadingNav(model, rootRef) const [tocOpen, setTocOpen] = useState(false) // Contents sidebar: a persisted user preference plus transient, pane-aware // overrides (see the effect below for the three-state behaviour). const [tocCollapsed, setTocCollapsed] = useState(readTocCollapsed) const tocCollapsedRef = useRef(tocCollapsed) const tocStashRef = useRef(tocCollapsed) const tocMutatedRef = useRef(false) useEffect(() => { tocCollapsedRef.current = tocCollapsed // latest value for the pane-transition effect }, [tocCollapsed])
const catalog = useCatalog() const siblings = catalog.status === "ready" ? workSiblings(catalog.data, model.id) : null const manifest = catalog.status === "ready" ? (catalog.data.texts.find((t) => t.id === model.id) ?? null) : null const facsimile = manifest?.facsimile ?? null const hasFacsimile = manifest?.hasFacsimile ?? false
const [panelMode, setPanelMode] = useState<PanelMode | null>(null) const [artifactKey, setArtifactKey] = useState<string | null>(null) const [artifactView, setArtifactView] = useState<string | null>(null) const [panelWidth, setPanelWidth] = useState<number | null>(() => readPanelWidth() ) const [zoom, setZoom] = useState(1) const panelOpen = panelMode !== null const facsAnchor = model.anchors.find((a) => a.id === nav.currentAnchorId) const facsLabel = facsAnchor ? `p. ${facsAnchor.locus}` : null
// Contents sidebar ↔ right-pane interaction (three states): // • user-selected (persisted) — toggled while no pane is open; // • auto-collapsed — forced closed while a pane is open (pref untouched); // • overridden-open — the reader expanded it without dismissing the pane. // On pane close, restore the stashed user-selected state unless they changed // it during the pane (a mutation sticks; an untouched auto-collapse reverts). const collapseToc = useCallback( (next: boolean) => { setTocCollapsed(next) if (panelOpen) { tocMutatedRef.current = true // transient override — don't touch the saved pref } else { try { window.localStorage.setItem(TOC_COLLAPSED_KEY, next ? "1" : "0") } catch { /* storage unavailable — collapse stays in-session */ } } }, [panelOpen] )
const prevPanelOpenRef = useRef(panelOpen) useEffect(() => { const prev = prevPanelOpenRef.current if (panelOpen === prev) return // only act on an actual open/close transition prevPanelOpenRef.current = panelOpen if (panelOpen) { tocStashRef.current = tocCollapsedRef.current // stash the user-selected state tocMutatedRef.current = false if (!tocCollapsedRef.current) setTocCollapsed(true) // auto-collapse } else if (!tocMutatedRef.current) { setTocCollapsed(tocStashRef.current) // unmutated → return to the stashed state } }, [panelOpen])
const onResizePanel = useCallback((width: number) => { const clamped = Math.round( Math.max(PANEL_MIN, Math.min(width, window.innerWidth * 0.8)) ) setPanelWidth(clamped) try { window.localStorage.setItem(PANEL_WIDTH_KEY, String(clamped)) } catch { /* storage unavailable — width stays in-session */ } }, [])
const zoomBy = useCallback((delta: number) => { setZoom((z) => Math.min( ZOOM_MAX, Math.max(ZOOM_MIN, Math.round((z + delta) * 100) / 100) ) ) }, [])
const shellStyle: CSSProperties | undefined = panelOpen && panelWidth !== null ? ({ "--panel-w": `${String(panelWidth)}px` } as CSSProperties) : undefined
const openArtifact = useCallback((key: string) => { setArtifactKey(key) setArtifactView(null) setPanelMode("artifact") }, [])
// Resolve an incoming `?art=…` share link → open the artifact pane at its locus (§12). const { goToAnchor } = nav useArtifactLink( useCallback( (row) => { if (!row) return setArtifactKey(row.key) setArtifactView(row.view) setPanelMode("artifact") if (row.locusAnchor) goToAnchor(row.locusAnchor) }, [goToAnchor] ) )
const activeHeadingId = headingIdAt(model, nav.currentOffset) const hasToc = model.headings.length > 0
const anchorIdForOffset = (offset: number): string | null => model.anchors[anchorIndexAt(model, offset)]?.id ?? null
const onHeadingSelect = (node: HeadingNode): void => { nav.goToHeading(node.headingId, anchorIdForOffset(node.start)) setTocOpen(false) }
const onGoTo = (target: GoToTarget): void => { if (target.kind === "anchor") nav.goToAnchor(target.anchorId) else nav.goToHeading(target.headingId, anchorIdForOffset(target.start)) }
return ( <div className={cn( "reader-shell", panelOpen && "has-panel", tocCollapsed && "toc-collapsed" )} style={shellStyle} > <header className="reader-head"> <div className="reader-head-inner"> <div className="reader-head-left"> <BackLink /> {hasToc && ( <Sheet open={tocOpen} onOpenChange={setTocOpen}> <SheetTrigger asChild> <button type="button" className="reader-head-action reader-toc-trigger" aria-label="Table of contents" > <PanelsTopLeft className="size-4" /> <span>Contents</span> </button> </SheetTrigger> <SheetContent side="left" className="w-80 max-w-[85vw]"> <SheetHeader> <SheetTitle>Contents</SheetTitle> <SheetDescription className="sr-only"> Table of contents for {model.id} </SheetDescription> </SheetHeader> <div className="overflow-y-auto px-2 pb-6"> <Toc model={model} activeId={activeHeadingId} onSelect={onHeadingSelect} /> </div> </SheetContent> </Sheet> )} </div>
<CitationReadout model={model} offset={nav.currentOffset} />
<div className="reader-head-right"> <span className="reader-head-meta"> {LANG_LABEL[model.lang]} <span className="reader-head-dot" aria-hidden="true" /> {manifest?.author ?? SCHEME_LABEL[model.locusScheme]} </span> {/* Full action row on desktop; folded into the overflow menu below the mobile breakpoint (see .reader-head-inline / -overflow CSS). */} <div className="reader-head-inline"> {facsimile && ( <button type="button" className="reader-head-action" aria-pressed={panelMode === "facsimile"} onClick={() => { setPanelMode((m) => (m === "facsimile" ? null : "facsimile")) }} > <ImageIcon className="size-4" aria-hidden="true" /> <span>Facsimile</span> </button> )} <button type="button" className="reader-head-action" aria-pressed={panelMode === "artifact"} onClick={() => { setPanelMode((m) => (m === "artifact" ? null : "artifact")) }} > <FileText className="size-4" aria-hidden="true" /> <span>Artifact</span> </button> <PageExport textId={model.id} enabled={manifest?.mdUrl !== undefined} /> </div> <GoTo model={model} onNavigate={onGoTo} /> <div className="reader-head-inline"> <LoginButton /> </div> <SettingsMenu /> <ReaderOverflow textId={model.id} hasFacsimile={facsimile !== null} canExportMd={manifest?.mdUrl !== undefined} onToggleArtifact={() => { setPanelMode((m) => (m === "artifact" ? null : "artifact")) }} onToggleFacsimile={() => { setPanelMode((m) => (m === "facsimile" ? null : "facsimile")) }} /> </div> </div> </header>
<div className="reader-body"> <BrowseBar model={model} siblings={siblings} currentOffset={nav.currentOffset} onSelectAnchor={nav.goToAnchor} onSelectHeading={onHeadingSelect} /> <div className="reader-layout"> {hasToc && ( <aside className="reader-aside reader-aside-toc"> <div className="reader-aside-head"> <span className="reader-aside-title">Contents</span> <button type="button" className="reader-aside-collapse" aria-label="Collapse contents" onClick={() => { collapseToc(true) }} > <PanelLeftClose className="size-4" aria-hidden="true" /> </button> </div> <Toc model={model} activeId={activeHeadingId} onSelect={onHeadingSelect} /> </aside> )} <main className="reader-main"> <div className="reader-column"> <ReadingBody ref={rootRef} model={model} /> {siblings && ( <WorkNav siblings={siblings} work={siblings.work} currentId={model.id} /> )} </div> </main> </div> </div> {hasToc && ( <button type="button" className="reader-toc-expand" aria-label="Show contents" onClick={() => { collapseToc(false) }} > <PanelLeftOpen className="size-4" aria-hidden="true" /> <span>Contents</span> </button> )}
<SidePanel open={panelOpen} mode={panelMode ?? "facsimile"} hasFacsimile={facsimile !== null} // The artifact view is always available (it shows the saved-results list // when nothing specific is open), so the Facsimile|Artifact toggle should // appear whenever the text has a facsimile — letting the reader switch to // the facsimile from the saved-results view, not only from one artifact. hasArtifact headerExtra={ panelMode === "facsimile" && facsimile ? ( <div className="facsimile-zoom" role="group" aria-label="Zoom"> <button type="button" className="facsimile-zoom-btn" aria-label="Zoom out" disabled={zoom <= ZOOM_MIN} onClick={() => { zoomBy(-ZOOM_STEP) }} > <Minus className="size-3.5" aria-hidden="true" /> </button> <button type="button" className="facsimile-zoom-level" title="Reset to 100%" onClick={() => { setZoom(1) }} > {Math.round(zoom * 100)}% </button> <button type="button" className="facsimile-zoom-btn" aria-label="Zoom in" disabled={zoom >= ZOOM_MAX} onClick={() => { zoomBy(ZOOM_STEP) }} > <Plus className="size-3.5" aria-hidden="true" /> </button> </div> ) : null } onModeChange={setPanelMode} onResize={onResizePanel} onClose={() => { setPanelMode(null) }} > {panelMode === "artifact" ? ( artifactKey ? ( <div className="side-pane-scroll"> <button type="button" className="artifact-back" onClick={() => { setArtifactKey(null) setArtifactView(null) }} > <ChevronLeft className="size-4" aria-hidden="true" /> <span>Saved results</span> </button> <ArtifactPane artifactKey={artifactKey} lang={model.lang} autoplayTts={artifactView === "tts"} /> </div> ) : ( <div className="side-pane-scroll"> <SavedResults textId={model.id} onPick={openArtifact} /> </div> ) ) : facsimile ? ( <FacsimilePane config={facsimile} anchorId={nav.currentAnchorId} locusLabel={facsLabel} zoom={zoom} /> ) : ( <p className="facsimile-status">No source page for this text.</p> )} </SidePanel>
<SelectionLayer model={model} rootRef={rootRef} hasFacsimile={hasFacsimile} currentAnchorId={nav.currentAnchorId} onRequestFacsimile={() => { setPanelMode("facsimile") }} onOpenArtifact={openArtifact} onSelectionLocus={nav.setSelectionLocus} onClearSelection={nav.clearSelection} /> </div> )}
/** * Mobile running-head overflow (§2). Below the toolbar breakpoint the secondary * head actions collapse into a single "⋯" menu — Artifact, Facsimile, the page * Markdown export, and the account controls — keeping the bar legible on a * phone. Back, Contents, Go to, and the citation stay as their own affordances; * the desktop action row hides this trigger. */function ReaderOverflow({ textId, hasFacsimile, canExportMd, onToggleArtifact, onToggleFacsimile,}: { textId: string hasFacsimile: boolean canExportMd: boolean onToggleArtifact: () => void onToggleFacsimile: () => void}): JSX.Element { const { isAuthenticated, isAdmin, nickname, login, logout } = useLightAuth() return ( <DropdownMenu> <DropdownMenuTrigger asChild> <button type="button" className="reader-head-action reader-head-overflow" aria-label="More actions" > <MoreHorizontal className="size-4" aria-hidden="true" /> </button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-56"> <DropdownMenuItem onSelect={onToggleArtifact}> <FileText className="size-4" /> Artifact </DropdownMenuItem> {hasFacsimile && ( <DropdownMenuItem onSelect={onToggleFacsimile}> <ImageIcon className="size-4" /> Facsimile </DropdownMenuItem> )} {canExportMd && ( <> <DropdownMenuSeparator /> <DropdownMenuItem onSelect={() => { void copyPageMarkdown(textId) }} > <Copy className="size-4" /> Copy page as Markdown </DropdownMenuItem> <DropdownMenuItem asChild> <a href={pageMarkdownUrl(textId)} target="_blank" rel="noreferrer"> <ExternalLink className="size-4" /> View as Markdown </a> </DropdownMenuItem> </> )} <DropdownMenuSeparator /> {isAuthenticated ? ( <> <DropdownMenuLabel className="reader-overflow-account"> Signed in as {nickname ?? (isAdmin ? "Admin" : "Reader")} </DropdownMenuLabel> {isAdmin && ( <DropdownMenuItem asChild> <Link to="/admin">Admin</Link> </DropdownMenuItem> )} <DropdownMenuItem onSelect={logout}> <LogOut className="size-4" /> Sign out </DropdownMenuItem> </> ) : ( <DropdownMenuItem onSelect={login}> <LogIn className="size-4" /> Sign in </DropdownMenuItem> )} </DropdownMenuContent> </DropdownMenu> )}
/** Browsable list of a text's saved AI results — fills the artifact pane when no * specific artifact is open, so they're discoverable without a `?art` link (§12). */function SavedResults({ textId, onPick,}: { textId: string onPick: (key: string) => void}): JSX.Element { const rows = useQuery(api.cache.listByText, { textId }) if (rows === undefined) { return <p className="facsimile-status">Loading…</p> } if (rows.length === 0) { return ( <p className="facsimile-status"> No saved results for this text yet. Gloss or translate a word to create one. </p> ) } return ( <div className="locus-menu" role="menu" aria-label="Saved results"> {rows.map((row) => ( <button key={row.key} type="button" className="locus-item" role="menuitem" onClick={() => { onPick(row.key) }} > <span className="locus-quote">{row.quote}</span> <span className="locus-badges"> {row.gloss !== "" && ( <BookOpenText className="size-3.5" aria-label="gloss" /> )} {row.translation !== "" && ( <Languages className="size-3.5" aria-label="translation" /> )} {row.ttsR2Key && ( <Volume2 className="size-3.5" aria-label="audio" /> )} </span> </button> ))} </div> )}
export function ReadingView(): JSX.Element { const { textId } = useParams<{ textId: string }>() const resource = useDocumentModel(textId)
if (resource.status === "loading") return <ReadingSkeleton /> if (resource.status === "error") return <ReadingError message={resource.error.message} /> return <Reader model={resource.data} />}