Files
verse-quote-app/app/routes/_index.tsx
T
exe.dev user 0a18c774a5
CI / build (push) Successful in 4m26s
Build Verse: a poem-quote app on the agent-native chat template
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.
2026-07-25 17:42:04 +00:00

146 lines
4.8 KiB
TypeScript

import { useActionQuery } from "@agent-native/core/client/hooks";
import { IconArrowsShuffle, IconFeather, IconSearch } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { QuoteCard } from "@/components/quotes/QuoteCard";
import { SubmitQuoteDialog } from "@/components/quotes/SubmitQuoteDialog";
import { TagFilterBar } from "@/components/quotes/TagFilterBar";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { useVisitorId } from "@/lib/visitor-id";
export function meta() {
return [
{ title: "Verse — quotes drawn from poems" },
{
name: "description",
content: "Discover, search, and save quotes drawn from classic and community-submitted poems.",
},
];
}
export function HydrateFallback() {
return (
<div className="flex items-center justify-center h-screen">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-foreground" />
</div>
);
}
function DailyQuoteHero() {
const visitorId = useVisitorId();
const { data: quote, isLoading, refetch, isFetching } = useActionQuery("get-random-quote", {
visitorId,
});
return (
<div className="rounded-xl border bg-card p-6 sm:p-8">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
<IconFeather className="size-4" />
Quote of the moment
</div>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
disabled={isFetching}
onClick={() => refetch()}
>
<IconArrowsShuffle className="size-4" />
Shuffle
</Button>
</div>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-4 w-1/3" />
</div>
) : quote ? (
<QuoteCard quote={quote} className="border-0 p-0 shadow-none" />
) : (
<p className="text-sm text-muted-foreground">
No quotes yet be the first to submit one below.
</p>
)}
</div>
);
}
export default function QuotesHomeRoute() {
const visitorId = useVisitorId();
const [search, setSearch] = useState("");
const [activeTag, setActiveTag] = useState<string | null>(null);
const { data: quotes, isLoading } = useActionQuery("list-quotes", { visitorId });
const allTags = useMemo(() => {
const set = new Set<string>();
for (const q of quotes ?? []) for (const tag of q.tags) set.add(tag);
return Array.from(set).sort();
}, [quotes]);
const filtered = useMemo(() => {
const term = search.trim().toLowerCase();
return (quotes ?? []).filter((q) => {
if (activeTag && !q.tags.includes(activeTag)) return false;
if (!term) return true;
const haystack = `${q.text} ${q.poem.title} ${q.poem.author}`.toLowerCase();
return haystack.includes(term);
});
}, [quotes, search, activeTag]);
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6 p-4 sm:p-6">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Verse</h1>
<p className="text-sm text-muted-foreground">
Quotes drawn from poems browse, search, and save your favorites.
</p>
</div>
<DailyQuoteHero />
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1 sm:max-w-xs">
<IconSearch className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search quotes, poets, poems..."
className="pl-8"
/>
</div>
<SubmitQuoteDialog />
</div>
<TagFilterBar tags={allTags} activeTag={activeTag} onSelect={setActiveTag} />
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-48 w-full" />
))}
</div>
) : filtered.length === 0 ? (
<p className="py-12 text-center text-sm text-muted-foreground">
No quotes match. Try a different search or tag.
</p>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{filtered.map((quote) => (
<QuoteCard
key={quote.id}
quote={quote}
onTagClick={(tag) => setActiveTag(tag)}
/>
))}
</div>
)}
</div>
);
}