import { useCallback, useEffect, useMemo, useState } from "react"; import { referenceOf, useCaminoQuery, useInterface, useCrdtText, useLiveField, objectPresentationURL, type ExactReference, } from "@quixos/camino-react"; import { Button, Icon, ObjectLink, ObjectPicker, LocalInput, TextInput, useCommandControl, type ObjectPickerRequest, } from "@quixos/camino-react/ui"; import { interfaces, queries, type Modules } from "./gen/client.js"; import { accepted, asInterface, errorMessage, type ReaderNode } from "./model.js"; import { readingStyle } from "./style.js"; type Word = { _camino: { ref: ExactReference }; text: string }; export function useVocabulary(reference?: ExactReference | null) { const [pages, setPages] = useState>({}); const result = useCaminoQuery(queries.VocabularyView, { enabled: !!reference, root: reference ? asInterface(reference, "vocabulary") : undefined, input: { search: "", after: null }, admission: { query: queries.VocabularyInputs, input: { search: "", after: null } }, }); useEffect(() => { setPages({}); }, [reference?.object]); const report = useCallback((cursor: string, words: readonly Word[] | null) => { setPages((old) => { const next = { ...old }; if (words) next[cursor] = words; else delete next[cursor]; return next; }); }, []); const node = result.status === "ready" && referenceOf(result.data.root).object === reference?.object ? result.data.root : null; const words = useMemo( () => [ ...new Map( [...(node?.words.entries.map((e) => e.node) ?? []), ...Object.values(pages).flat()].map((word) => [ referenceOf(word).object, word, ]), ).values(), ], [node, pages], ); const known = useMemo(() => new Set(words.map((word) => word.text)), [words]); return { node, words, known, ready: !!node, continuation: node?.words.pageInfo.hasNextPage && node.words.pageInfo.endCursor ? ( ) : null, }; } function VocabularyTail({ reference, cursor, report, }: { reference: ExactReference; cursor: string; report(cursor: string, words: readonly Word[] | null): void; }) { const result = useCaminoQuery(queries.VocabularyView, { root: asInterface(reference, "vocabulary"), input: { search: "", after: cursor }, admission: { query: queries.VocabularyInputs, input: { search: "", after: cursor } }, }); const words = result.status === "ready" ? result.data.root.words : null; useEffect(() => { if (words) report( cursor, words.entries.map((e) => e.node), ); return () => report(cursor, null); }, [words, cursor, report]); return words?.pageInfo.hasNextPage && words.pageInfo.endCursor ? ( ) : null; } export const Vocabulary: Modules["Vocabulary"] = ({ subject }) => (
); export function VocabularyEditor({ reference }: { reference: ExactReference }) { const vocabulary = useVocabulary(reference); return vocabulary.ready ? ( ) : (

Opening vocabulary…

); } function ReadyVocabularyEditor({ reference, vocabulary, }: { reference: ExactReference; vocabulary: ReturnType; }) { const api = useInterface( { remember: interfaces.ReadingVocabulary.remember, rename: interfaces.ReadingVocabulary.name.set }, asInterface(reference, "vocabulary"), ); const name = useCommandControl({ key: reference.object + "/name", value: vocabulary.node?.name ?? "", submit: api.status === "ready" ? (value: string) => api.value.rename(value) : null, }); const draftControl = useCrdtText({ key: reference.object + "/word-draft", operation: interfaces.ReadingVocabulary.draft.edit, reference: asInterface(reference, "vocabulary"), path: ["word"], }); const draftField = useLiveField({ key: reference.object + "/draft-clear", operation: interfaces.ReadingVocabulary.draft.edit, reference: asInterface(reference, "vocabulary"), }); const draft = draftControl.text; const [nameDraft, setNameDraft] = useState(null); useEffect(() => { if (nameDraft === vocabulary.node?.name) setNameDraft(null); }, [vocabulary.node?.name, nameDraft]); const [search, setSearch] = useState(""), [pending, setPending] = useState(false), [error, setError] = useState(""); const words = vocabulary.words.filter((word) => word.text.toLocaleLowerCase().includes(search.toLocaleLowerCase())); return (
{vocabulary.continuation}
Vocabulary list

{vocabulary.node?.name ?? "Vocabulary"}

{vocabulary.words.length} {vocabulary.words.length === 1 ? "word" : "words"}
{ event.preventDefault(); if (api.status !== "ready" || !draft.trim() || pending) return; setPending(true); void accepted(api.value.remember.call(draft)) .then( () => { const current = draftField.current(); if (current.status === "ready") { const snapshot = current.binding.getSnapshot(); if (snapshot.value.word === draft) current.binding.edit((editor) => editor.spliceText(["word"], 0, draft.length, ""), snapshot); } }, (e) => setError(errorMessage(e)), ) .finally(() => setPending(false)); }} >
{!!draftControl.error &&

{errorMessage(draftControl.error)}

} {!vocabulary.ready ? (

Opening vocabulary…

) : words.length ? (
    {words.map((word) => ( ))}
) : (

{search ? "No matching words" : "No words saved yet"}

{search ? "Try another search." : "Save a word from the text or add one above."}

)} {(error || name.error) &&

{error || name.error}

}
); } function VocabularyWord({ word, report }: { word: Word; report(message: string): void }) { const api = useInterface({ forget: interfaces.ReadingWord.forget }, asInterface(referenceOf(word), "word")); const [pending, setPending] = useState(false); return (
  • {word.text}
  • ); } export function VocabularySettings({ library, reader, defaultVocabulary, }: { library: ExactReference; reader: ReaderNode; defaultVocabulary?: ExactReference | null; }) { const attached = reader.vocabulary ? referenceOf(reader.vocabulary) : defaultVocabulary; const [picking, setPicking] = useState(false), [editing, setEditing] = useState(false), [error, setError] = useState(""); const api = useInterface( { attach: interfaces.ReadingReader.vocabulary.connect }, asInterface(referenceOf(reader), "reader"), ); return (

    Vocabulary

    This reader uses a shared list. Saving a word updates that list for every reader attached to it.

    {attached ? : No list selected}
    {attached && ( )}
    {editing && attached && } {picking && ( setPicking(false)} selected={(ref) => { if (api.status !== "ready") return; void accepted(api.value.attach(asInterface(ref, "vocabulary"))).then( () => setPicking(false), (e) => setError(errorMessage(e)), ); }} /> )} {error &&

    {error}

    }
    ); } export function VocabularyLabel({ reference }: { reference: ExactReference }) { const result = useCaminoQuery(queries.VocabularyView, { root: asInterface(reference, "vocabulary"), input: { search: "", after: null }, admission: { query: queries.VocabularyInputs, input: { search: "", after: null } }, }); const node = result.status === "ready" ? result.data.root : null; return ( {node?.name ?? "Vocabulary"} ); } function VocabularyPicker({ library, value, selected, close, }: { library: ExactReference; value?: ExactReference | null; selected(ref: ExactReference): void; close(): void; }) { const [request, setRequest] = useState({ location: "lists", search: "", cursor: null }), [creating, setCreating] = useState(false), [error, setError] = useState(""); const input = { search: request.search, after: request.cursor }; const result = useCaminoQuery(queries.VocabularyListsView, { root: library, input, admission: { query: queries.VocabularyListsInputs, input }, }); const api = useInterface({ create: interfaces.LanguageReader.addVocabulary }, library); const rows = result.status === "ready" ? result.data.root.vocabularies : null; return ( }]} request={request} onBrowse={setRequest} page={ error ? { status: "error", error } : rows ? { status: "ready", items: rows.entries.map(({ node }) => ({ reference: referenceOf(node), title: node.name, kind: "Vocabulary list", description: "Share this list between readers", open: { href: objectPresentationURL(referenceOf(node)) }, })), nextCursor: rows.pageInfo.hasNextPage ? rows.pageInfo.endCursor : null, } : result.status === "error" ? { status: "error", error: errorMessage(result.error) } : { status: "loading" } } onSelect={selected} onCancel={close} renderPreview={(item) => (

    {item.title}

    Attach this list to the reader. Other readers can continue to use it.

    )} create={{ label: creating ? "Creating…" : "New vocabulary list", disabled: creating || api.status !== "ready", action: () => { if (api.status !== "ready") return; setCreating(true); void accepted(api.value.create.call("New vocabulary list")) .then(selected, (e) => setError(errorMessage(e))) .finally(() => setCreating(false)); }, }} /> ); } function VocabularyPreview({ reference }: { reference: ExactReference }) { const result = useCaminoQuery(queries.VocabularyView, { root: asInterface(reference, "vocabulary"), input: { search: "", after: null }, admission: { query: queries.VocabularyInputs, input: { search: "", after: null } }, }); if (result.status !== "ready") return

    Loading preview…

    ; const words = result.data.root.words; return (

    {words.entries.length} {words.pageInfo.hasNextPage ? "+" : ""} saved words

    {words.entries.slice(0, 24).map(({ node }) => ( {node.text} ))}
    ); }