Export template source from be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9:examples/local-first/packages/language-reader-presentation
This commit is contained in:
+258
@@ -0,0 +1,258 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user