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.
146 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|