Build Verse: a poem-quote app on the agent-native chat template
CI / build (push) Successful in 4m26s
CI / build (push) Successful in 4m26s
Adds a Postgres-backed quotes domain (poems/quotes/favorites) exposed as five agent-native actions (list-quotes, get-random-quote, create-quote, toggle-favorite, list-favorites) callable from both chat and the React UI via useActionQuery/useActionMutation. Moves the primary UI from chat to a mobile-first Quotes/Favorites experience (chat moves to /chat), adds manual OpenTelemetry instrumentation exporting traces/metrics/logs over OTLP, and wires a local Docker Postgres for shared state.
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { useActionMutation } from "@agent-native/core/client/hooks";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
const emptyForm = { text: "", author: "", poemTitle: "", source: "", year: "", tags: "" };
|
||||
|
||||
export function SubmitQuoteDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const formId = useId();
|
||||
|
||||
const { mutate, isPending } = useActionMutation("create-quote", {
|
||||
onSuccess: () => {
|
||||
toast.success("Quote added");
|
||||
setForm(emptyForm);
|
||||
setOpen(false);
|
||||
},
|
||||
onError: () => toast.error("Couldn't save that quote — try again"),
|
||||
});
|
||||
|
||||
function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!form.text.trim() || !form.author.trim() || !form.poemTitle.trim()) return;
|
||||
mutate({
|
||||
text: form.text.trim(),
|
||||
author: form.author.trim(),
|
||||
poemTitle: form.poemTitle.trim(),
|
||||
source: form.source.trim() || undefined,
|
||||
year: form.year ? Number(form.year) : undefined,
|
||||
tags: form.tags
|
||||
.split(",")
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" className="gap-1.5">
|
||||
<IconPlus className="size-4" />
|
||||
Submit a quote
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Submit a quote</DialogTitle>
|
||||
<DialogDescription>
|
||||
Share a line or passage from a poem you love.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form id={formId} onSubmit={handleSubmit} className="grid gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${formId}-text`}>Quote</Label>
|
||||
<Textarea
|
||||
id={`${formId}-text`}
|
||||
required
|
||||
rows={4}
|
||||
value={form.text}
|
||||
onChange={(e) => setForm((f) => ({ ...f, text: e.target.value }))}
|
||||
placeholder="Two roads diverged in a wood..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${formId}-author`}>Poet</Label>
|
||||
<Input
|
||||
id={`${formId}-author`}
|
||||
required
|
||||
value={form.author}
|
||||
onChange={(e) => setForm((f) => ({ ...f, author: e.target.value }))}
|
||||
placeholder="Robert Frost"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${formId}-title`}>Poem title</Label>
|
||||
<Input
|
||||
id={`${formId}-title`}
|
||||
required
|
||||
value={form.poemTitle}
|
||||
onChange={(e) => setForm((f) => ({ ...f, poemTitle: e.target.value }))}
|
||||
placeholder="The Road Not Taken"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${formId}-source`}>Source (optional)</Label>
|
||||
<Input
|
||||
id={`${formId}-source`}
|
||||
value={form.source}
|
||||
onChange={(e) => setForm((f) => ({ ...f, source: e.target.value }))}
|
||||
placeholder="Collection name"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${formId}-year`}>Year (optional)</Label>
|
||||
<Input
|
||||
id={`${formId}-year`}
|
||||
inputMode="numeric"
|
||||
value={form.year}
|
||||
onChange={(e) => setForm((f) => ({ ...f, year: e.target.value.replace(/\D/g, "") }))}
|
||||
placeholder="1916"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor={`${formId}-tags`}>Tags (optional, comma separated)</Label>
|
||||
<Input
|
||||
id={`${formId}-tags`}
|
||||
value={form.tags}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tags: e.target.value }))}
|
||||
placeholder="hope, nature, journey"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form={formId} disabled={isPending}>
|
||||
{isPending ? "Saving..." : "Save quote"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user