259 lines
8.1 KiB
TypeScript
259 lines
8.1 KiB
TypeScript
import { usePrompt } from "./drafts.js";
|
|||
|
|
import { useEffect, useState } from "react";
|
||
|
|
import { useCrdtText, useInterface, referenceOf, type ExactReference } from "@quixos/camino-react";
|
||
|
|
import { Icon, LocalChoice, TextArea, SegmentedControl } from "@quixos/camino-react/ui";
|
||
|
|
import { interfaces } from "./gen/client.js";
|
||
|
|
import { accepted, asInterface, errorMessage, type Root } from "./model.js";
|
||
|
|
|
||
|
|
type TextNode = Root["documents"]["entries"][number]["node"];
|
||
|
|
function TextRow({
|
||
|
|
node,
|
||
|
|
open,
|
||
|
|
selected,
|
||
|
|
changed,
|
||
|
|
}: {
|
||
|
|
node: TextNode;
|
||
|
|
open: (ref: ExactReference) => void;
|
||
|
|
selected?: string | undefined;
|
||
|
|
changed: (id: string, archived: boolean) => void;
|
||
|
|
}) {
|
||
|
|
const api = useInterface(
|
||
|
|
{ archived: { set: interfaces.ReadingDocument.archived.set } },
|
||
|
|
asInterface(referenceOf(node), "document"),
|
||
|
|
);
|
||
|
|
const [busy, setBusy] = useState(false),
|
||
|
|
[error, setError] = useState("");
|
||
|
|
const archive = async () => {
|
||
|
|
if (api.status !== "ready") return;
|
||
|
|
setBusy(true);
|
||
|
|
setError("");
|
||
|
|
try {
|
||
|
|
await accepted(api.value.archived.set(!node.archived));
|
||
|
|
changed(referenceOf(node).object, !node.archived);
|
||
|
|
} catch (e) {
|
||
|
|
setError(errorMessage(e));
|
||
|
|
} finally {
|
||
|
|
setBusy(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
return (
|
||
|
|
<div className="lr-text-row">
|
||
|
|
<button
|
||
|
|
className="lr-text-open"
|
||
|
|
aria-current={selected === referenceOf(node).object ? "page" : undefined}
|
||
|
|
onClick={() => open(referenceOf(node))}
|
||
|
|
>
|
||
|
|
<span className="lr-text-icon">
|
||
|
|
<Icon name="pages" size={21} />
|
||
|
|
</span>
|
||
|
|
<span className="lr-text-title">
|
||
|
|
<strong>{node.data.title}</strong>
|
||
|
|
<small>
|
||
|
|
{node.data.source === "sample"
|
||
|
|
? "Sample text"
|
||
|
|
: node.data.source === "generated"
|
||
|
|
? "Generated text"
|
||
|
|
: node.data.source}
|
||
|
|
</small>
|
||
|
|
</span>
|
||
|
|
<Icon name="arrow-right" />
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
className="lr-text-archive"
|
||
|
|
disabled={busy || api.status !== "ready"}
|
||
|
|
aria-label={`${node.archived ? "Restore" : "Archive"} ${node.data.title}`}
|
||
|
|
onClick={() => void archive()}
|
||
|
|
>
|
||
|
|
{node.archived ? "Restore" : <Icon name="archive" />}
|
||
|
|
</button>
|
||
|
|
{error && (
|
||
|
|
<p className="lr-error" role="alert">
|
||
|
|
{error}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
export function Texts({
|
||
|
|
root,
|
||
|
|
selected,
|
||
|
|
open,
|
||
|
|
add,
|
||
|
|
speech,
|
||
|
|
}: {
|
||
|
|
root: Root;
|
||
|
|
selected?: string | undefined;
|
||
|
|
open: (ref: ExactReference) => void;
|
||
|
|
add: () => void;
|
||
|
|
speech: () => void;
|
||
|
|
}) {
|
||
|
|
const [view, setView] = useState("active"),
|
||
|
|
[overrides, setOverrides] = useState<Record<string, boolean>>({});
|
||
|
|
useEffect(() => {
|
||
|
|
setOverrides((old) => {
|
||
|
|
const next = { ...old };
|
||
|
|
for (const { node } of root.documents.entries) {
|
||
|
|
const id = referenceOf(node).object;
|
||
|
|
if (next[id] === node.archived) delete next[id];
|
||
|
|
}
|
||
|
|
return Object.keys(next).length === Object.keys(old).length ? old : next;
|
||
|
|
});
|
||
|
|
}, [root.documents]);
|
||
|
|
const nodes = root.documents.entries
|
||
|
|
.map((e) => e.node)
|
||
|
|
.filter((n) => (overrides[referenceOf(n).object] ?? n.archived) === (view === "archived"));
|
||
|
|
return (
|
||
|
|
<div className="lr-library">
|
||
|
|
<header className="lr-library-head">
|
||
|
|
<div>
|
||
|
|
<h1>Texts</h1>
|
||
|
|
<p>
|
||
|
|
{nodes.length} {view === "archived" ? "archived " : ""}
|
||
|
|
{nodes.length === 1 ? "text" : "texts"}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div className="lr-toolbar">
|
||
|
|
{selected && root.documents.entries.some((e) => referenceOf(e.node).object === selected) && (
|
||
|
|
<button
|
||
|
|
onClick={() =>
|
||
|
|
open(referenceOf(root.documents.entries.find((e) => referenceOf(e.node).object === selected)!.node))
|
||
|
|
}
|
||
|
|
>
|
||
|
|
Back to text
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
<button onClick={speech}>Read-aloud settings</button>
|
||
|
|
<button className="lr-primary" onClick={add}>
|
||
|
|
<Icon name="plus" />
|
||
|
|
Add text
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</header>
|
||
|
|
<div className="lr-library-filter">
|
||
|
|
<SegmentedControl
|
||
|
|
label="Text collection"
|
||
|
|
value={view}
|
||
|
|
options={[
|
||
|
|
{ value: "active", label: "Texts" },
|
||
|
|
{ value: "archived", label: "Archived" },
|
||
|
|
]}
|
||
|
|
onChange={setView}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<nav className="lr-text-list" aria-label="Texts">
|
||
|
|
{nodes.map((node) => (
|
||
|
|
<TextRow
|
||
|
|
key={referenceOf(node).object}
|
||
|
|
node={node}
|
||
|
|
selected={selected}
|
||
|
|
open={open}
|
||
|
|
changed={(id, archived) => setOverrides((old) => ({ ...old, [id]: archived }))}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</nav>
|
||
|
|
{nodes.length === 0 && (
|
||
|
|
<div className="lr-empty">
|
||
|
|
<h2>{view === "archived" ? "No archived texts" : "No texts yet"}</h2>
|
||
|
|
{view === "active" && <button onClick={add}>Add text</button>}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
export function GenerateText({
|
||
|
|
library,
|
||
|
|
root,
|
||
|
|
initialReader,
|
||
|
|
navigation,
|
||
|
|
added,
|
||
|
|
}: {
|
||
|
|
library: ExactReference;
|
||
|
|
root: Root;
|
||
|
|
initialReader?: string | undefined;
|
||
|
|
navigation: ExactReference;
|
||
|
|
added: (ref: ExactReference) => void;
|
||
|
|
}) {
|
||
|
|
const draft = usePrompt(navigation, "generate");
|
||
|
|
const prompt = draft.text;
|
||
|
|
const api = useInterface(interfaces.LanguageReader, library),
|
||
|
|
[reader, setReader] = useState(initialReader ?? root.defaultReader?._camino.ref.object ?? ""),
|
||
|
|
[busy, setBusy] = useState(false),
|
||
|
|
[job, setJob] = useState(""),
|
||
|
|
[error, setError] = useState("");
|
||
|
|
const task = root.tasks.entries.map((e) => e.node).find((n) => referenceOf(n).object === job);
|
||
|
|
useEffect(() => {
|
||
|
|
if (task?.data.status === "ready" && task.document) added(referenceOf(task.document));
|
||
|
|
}, [task?.data.status, task?.document?._camino.ref.object]);
|
||
|
|
const failed = !!task && ["failed", "cancelled", "uncertain"].includes(task.data.status);
|
||
|
|
const generate = async () => {
|
||
|
|
if (api.status !== "ready" || !reader) return;
|
||
|
|
setBusy(true);
|
||
|
|
setError("");
|
||
|
|
try {
|
||
|
|
await draft.flush();
|
||
|
|
const ref = await accepted(
|
||
|
|
api.value.generateDocument.call({
|
||
|
|
prompt,
|
||
|
|
reader: {
|
||
|
|
...root.readers.entries.find((e) => referenceOf(e.node).object === reader)!.node._camino.ref,
|
||
|
|
expectation: { kind: "atom", id: "atom:reading:reader" },
|
||
|
|
},
|
||
|
|
createdAt: new Date().toISOString(),
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
setJob(ref.object);
|
||
|
|
draft.clearSubmitted(prompt);
|
||
|
|
} catch (e) {
|
||
|
|
setError(errorMessage(e));
|
||
|
|
} finally {
|
||
|
|
setBusy(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
return (
|
||
|
|
<div className="lr-generate-text">
|
||
|
|
<label className="lr-field">
|
||
|
|
Read with
|
||
|
|
<LocalChoice
|
||
|
|
aria-label="Reader for generated text"
|
||
|
|
value={reader}
|
||
|
|
onValueChange={setReader}
|
||
|
|
options={root.readers.entries.map(({ node }) => ({ value: referenceOf(node).object, label: node.data.name }))}
|
||
|
|
disabled={busy || (!!job && !failed)}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<label className="lr-field">
|
||
|
|
What would you like to read?
|
||
|
|
<TextArea
|
||
|
|
binding={draft}
|
||
|
|
aria-label="Text request"
|
||
|
|
rows={6}
|
||
|
|
maxLength={4000}
|
||
|
|
placeholder="A short French story about missing a train, at A2 level…"
|
||
|
|
disabled={busy || (!!job && !failed)}
|
||
|
|
/>
|
||
|
|
</label>
|
||
|
|
<footer>
|
||
|
|
<small>Include a language, topic, and difficulty.</small>
|
||
|
|
<button
|
||
|
|
className="lr-primary"
|
||
|
|
disabled={!prompt.trim() || !reader || busy || (!!job && !failed)}
|
||
|
|
onClick={() => void generate()}
|
||
|
|
>
|
||
|
|
<Icon name="spark" />
|
||
|
|
{busy ? "Starting…" : job && !failed ? "Writing…" : failed ? "Try again" : "Generate text"}
|
||
|
|
</button>
|
||
|
|
</footer>
|
||
|
|
{job && !failed && (
|
||
|
|
<p role="status" className="lr-generation-status">
|
||
|
|
Writing your text. You can close this window; it will appear in Texts when ready.
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
{(error || failed) && (
|
||
|
|
<p className="lr-error" role="alert">
|
||
|
|
{error || task?.data.error || "The text could not be generated."}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|