Build Verse: a poem-quote app on the agent-native chat template
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:
exe.dev user
2026-07-25 17:42:04 +00:00
parent ffea2aeff7
commit 0a18c774a5
44 changed files with 14702 additions and 156 deletions
+95
View File
@@ -0,0 +1,95 @@
import { useActionMutation } from "@agent-native/core/client/hooks";
import { IconHeart, IconHeartFilled } from "@tabler/icons-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { useVisitorId } from "@/lib/visitor-id";
export type QuoteCardData = {
id: number;
text: string;
tags: string[];
poem: {
title: string;
author: string;
source: string | null;
year: number | null;
};
favoriteCount: number;
isFavorited: boolean;
};
interface QuoteCardProps {
quote: QuoteCardData;
onTagClick?: (tag: string) => void;
className?: string;
}
export function QuoteCard({ quote, onTagClick, className }: QuoteCardProps) {
const visitorId = useVisitorId();
const [optimistic, setOptimistic] = useState<{ favorited: boolean; count: number } | null>(
null,
);
const { mutate, isPending } = useActionMutation("toggle-favorite", {
onSuccess: (result) => {
setOptimistic({ favorited: result.favorited, count: result.favoriteCount });
},
onError: () => setOptimistic(null),
});
const favorited = optimistic?.favorited ?? quote.isFavorited;
const count = optimistic?.count ?? quote.favoriteCount;
return (
<Card className={cn("flex h-full flex-col justify-between", className)}>
<CardContent className="pt-6">
<blockquote className="text-balance text-lg leading-relaxed font-medium text-foreground">
{quote.text}
</blockquote>
<p className="mt-4 text-sm text-muted-foreground">
{quote.poem.author}, <span className="italic">{quote.poem.title}</span>
{quote.poem.year ? ` (${quote.poem.year})` : ""}
</p>
{quote.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1.5">
{quote.tags.map((tag) => (
<Badge
key={tag}
variant="secondary"
className={cn(onTagClick && "cursor-pointer hover:bg-secondary/70")}
onClick={() => onTagClick?.(tag)}
>
{tag}
</Badge>
))}
</div>
)}
</CardContent>
<CardFooter className="flex items-center justify-between border-t pt-4">
<span className="text-xs text-muted-foreground">
{count} {count === 1 ? "favorite" : "favorites"}
</span>
<Button
type="button"
variant="ghost"
size="sm"
disabled={isPending || !visitorId}
onClick={() => mutate({ quoteId: quote.id, visitorId })}
aria-pressed={favorited}
aria-label={favorited ? "Remove from favorites" : "Add to favorites"}
className={cn("gap-1.5", favorited && "text-rose-500 hover:text-rose-500")}
>
{favorited ? (
<IconHeartFilled className="size-4" />
) : (
<IconHeart className="size-4" />
)}
{favorited ? "Favorited" : "Favorite"}
</Button>
</CardFooter>
</Card>
);
}
+143
View File
@@ -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>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface TagFilterBarProps {
tags: string[];
activeTag: string | null;
onSelect: (tag: string | null) => void;
}
export function TagFilterBar({ tags, activeTag, onSelect }: TagFilterBarProps) {
if (tags.length === 0) return null;
return (
<div className="flex gap-1.5 overflow-x-auto pb-1 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Badge
variant={activeTag === null ? "default" : "outline"}
className="shrink-0 cursor-pointer"
onClick={() => onSelect(null)}
>
All
</Badge>
{tags.map((tag) => (
<Badge
key={tag}
variant={activeTag === tag ? "default" : "outline"}
className={cn("shrink-0 cursor-pointer")}
onClick={() => onSelect(activeTag === tag ? null : tag)}
>
{tag}
</Badge>
))}
</div>
);
}