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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user