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:
@@ -10,7 +10,9 @@ import { useLocation } from "react-router";
|
||||
import { APP_TITLE } from "@/lib/app-config";
|
||||
|
||||
const pageTitleKeys: Record<string, string> = {
|
||||
"/": "navigation.chat",
|
||||
"/": "navigation.quotes",
|
||||
"/favorites": "navigation.favorites",
|
||||
"/chat": "navigation.chat",
|
||||
"/observability": "navigation.observability",
|
||||
"/agent": "settings.agentTitle",
|
||||
"/settings": "navigation.settings",
|
||||
|
||||
@@ -38,7 +38,7 @@ const SIDEBAR_COLLAPSE_KEY = "chat.sidebar.collapsed";
|
||||
*/
|
||||
function routeOwnsToolbar(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/" ||
|
||||
pathname === "/chat" ||
|
||||
pathname.startsWith("/chat/") ||
|
||||
pathname === "/database" ||
|
||||
pathname.startsWith("/extensions")
|
||||
@@ -52,7 +52,7 @@ export function Layout({ children }: LayoutProps) {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
|
||||
const isChatRoute =
|
||||
location.pathname === "/" || location.pathname.startsWith("/chat/");
|
||||
location.pathname === "/chat" || location.pathname.startsWith("/chat/");
|
||||
const chatHomeHandoffActive = useAgentChatHomeHandoff({
|
||||
storageKey: "chat",
|
||||
activePath: location.pathname,
|
||||
@@ -61,7 +61,7 @@ export function Layout({ children }: LayoutProps) {
|
||||
const chatHomeHandoffPending = isAgentChatHomeHandoffActive("chat");
|
||||
useAgentChatHomeHandoffLinks({
|
||||
storageKey: "chat",
|
||||
isChatPath: (pathname) => pathname === "/" || pathname.startsWith("/chat/"),
|
||||
isChatPath: (pathname) => pathname === "/chat" || pathname.startsWith("/chat/"),
|
||||
requireActiveHandoff: true,
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ export function Layout({ children }: LayoutProps) {
|
||||
const ownsToolbar = routeOwnsToolbar(location.pathname);
|
||||
function openAskAgentFullscreen() {
|
||||
focusAgentChat();
|
||||
navigateWithAgentChatViewTransition(navigate, "/");
|
||||
navigateWithAgentChatViewTransition(navigate, "/chat");
|
||||
}
|
||||
|
||||
const contentFrame = (
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
type ChatHistoryItem,
|
||||
} from "@agent-native/toolkit/chat-history";
|
||||
import {
|
||||
IconBooks,
|
||||
IconHeart,
|
||||
IconLayoutSidebarLeftCollapse,
|
||||
IconLayoutSidebarLeftExpand,
|
||||
IconMessageCircle,
|
||||
@@ -33,10 +35,22 @@ import { APP_TITLE } from "@/lib/app-config";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
icon: IconBooks,
|
||||
labelKey: "navigation.quotes",
|
||||
href: "/",
|
||||
view: "quotes",
|
||||
},
|
||||
{
|
||||
icon: IconHeart,
|
||||
labelKey: "navigation.favorites",
|
||||
href: "/favorites",
|
||||
view: "favorites",
|
||||
},
|
||||
{
|
||||
icon: IconMessageCircle,
|
||||
labelKey: "navigation.chat",
|
||||
href: "/",
|
||||
href: "/chat",
|
||||
view: "chat",
|
||||
},
|
||||
];
|
||||
@@ -275,7 +289,7 @@ export function Sidebar({
|
||||
const navigate = useNavigate();
|
||||
const t = useT();
|
||||
const isChatRoute =
|
||||
location.pathname === "/" || location.pathname.startsWith("/chat/");
|
||||
location.pathname === "/chat" || location.pathname.startsWith("/chat/");
|
||||
const ToggleIcon = collapsed
|
||||
? IconLayoutSidebarLeftExpand
|
||||
: IconLayoutSidebarLeftCollapse;
|
||||
@@ -397,15 +411,17 @@ export function Sidebar({
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
item.href === "/chat"
|
||||
? isChatRoute
|
||||
: location.pathname.startsWith(item.href);
|
||||
: item.href === "/"
|
||||
? location.pathname === "/"
|
||||
: location.pathname.startsWith(item.href);
|
||||
const link = (
|
||||
<Link
|
||||
to={item.href}
|
||||
onClick={(event) => {
|
||||
if (
|
||||
item.href === "/" &&
|
||||
item.href === "/chat" &&
|
||||
!isChatRoute &&
|
||||
!event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
@@ -413,7 +429,7 @@ export function Sidebar({
|
||||
!event.altKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
navigateWithAgentChatViewTransition(navigate, "/");
|
||||
navigateWithAgentChatViewTransition(navigate, "/chat");
|
||||
}
|
||||
}}
|
||||
className={navClass({ isActive })}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { IconX as X } from "@tabler/icons-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import {
|
||||
IconCheck as Check,
|
||||
IconChevronDown as ChevronDown,
|
||||
IconChevronUp as ChevronUp,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -45,10 +45,12 @@ const messages = {
|
||||
database: "Database",
|
||||
expandSidebar: "Expand Sidebar",
|
||||
extensions: "Extensions",
|
||||
favorites: "Favorites",
|
||||
navigation: "Navigation",
|
||||
navigationDescription: "Main navigation",
|
||||
observability: "Observability",
|
||||
openNavigation: "Open navigation",
|
||||
quotes: "Quotes",
|
||||
settings: "Settings",
|
||||
team: "Team",
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const rawAppName = "app";
|
||||
const rawAppTitle = "App";
|
||||
const rawAppName = "verse-quote-app";
|
||||
const rawAppTitle = "Verse";
|
||||
|
||||
const APP_NAME_PLACEHOLDER = "{" + "{APP_NAME}}";
|
||||
const APP_TITLE_PLACEHOLDER = "{" + "{APP_TITLE}}";
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "verse-quote-app.visitor-id";
|
||||
|
||||
function generateId(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `v-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stable anonymous id for this browser, used to scope favorites without
|
||||
* requiring a login. Not a real user identity — just enough to remember
|
||||
* "your" favorites on this device.
|
||||
*/
|
||||
export function getVisitorId(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
try {
|
||||
const existing = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (existing) return existing;
|
||||
const created = generateId();
|
||||
window.localStorage.setItem(STORAGE_KEY, created);
|
||||
return created;
|
||||
} catch {
|
||||
return generateId();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook wrapper around getVisitorId(). Routes that use this render only
|
||||
* an SSR loading shell (see DEVELOPING.md), so reading localStorage during
|
||||
* the initial client render here does not cause a hydration mismatch.
|
||||
*/
|
||||
export function useVisitorId(): string {
|
||||
const [visitorId] = useState(() => getVisitorId());
|
||||
return visitorId;
|
||||
}
|
||||
+131
-80
@@ -1,94 +1,145 @@
|
||||
import {
|
||||
AgentChatSurface,
|
||||
markAgentChatHomeHandoff,
|
||||
} from "@agent-native/core/client/agent-chat";
|
||||
import { useT } from "@agent-native/core/client/i18n";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { useActionQuery } from "@agent-native/core/client/hooks";
|
||||
import { IconArrowsShuffle, IconFeather, IconSearch } from "@tabler/icons-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { APP_TITLE } from "@/lib/app-config";
|
||||
import { TAB_ID } from "@/lib/tab-id";
|
||||
|
||||
const SEO_TITLE = `${APP_TITLE} - Open Source AI app starter with actions`;
|
||||
const SEO_DESCRIPTION =
|
||||
"Open Source starter for agent-native apps with durable chat, shared actions, UI state, tools, and a backend your agent can extend.";
|
||||
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: SEO_TITLE },
|
||||
{ title: "Verse — quotes drawn from poems" },
|
||||
{
|
||||
name: "description",
|
||||
content: SEO_DESCRIPTION,
|
||||
content: "Discover, search, and save quotes drawn from classic and community-submitted poems.",
|
||||
},
|
||||
{ property: "og:title", content: SEO_TITLE },
|
||||
{ property: "og:description", content: SEO_DESCRIPTION },
|
||||
{ name: "twitter:card", content: "summary" },
|
||||
{ name: "twitter:title", content: SEO_TITLE },
|
||||
{ name: "twitter:description", content: SEO_DESCRIPTION },
|
||||
];
|
||||
}
|
||||
|
||||
function chatThreadPath(threadId: string | null) {
|
||||
return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/";
|
||||
}
|
||||
|
||||
export default function ChatRoute() {
|
||||
const { threadId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const t = useT();
|
||||
const threadUrlSync = threadId
|
||||
? {
|
||||
routeThreadId: threadId,
|
||||
getPath: chatThreadPath,
|
||||
navigate,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
function handleChatRunning(event: Event) {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
if (detail?.isRunning === true) markAgentChatHomeHandoff("chat");
|
||||
}
|
||||
|
||||
window.addEventListener("agentNative.chatRunning", handleChatRunning);
|
||||
return () =>
|
||||
window.removeEventListener("agentNative.chatRunning", handleChatRunning);
|
||||
}, []);
|
||||
|
||||
export function HydrateFallback() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<AgentChatSurface
|
||||
mode="page"
|
||||
chatViewTransition
|
||||
className="h-full"
|
||||
defaultMode="chat"
|
||||
storageKey="chat"
|
||||
threadUrlSync={threadUrlSync}
|
||||
browserTabId={TAB_ID}
|
||||
showHeader={false}
|
||||
showTabBar={false}
|
||||
dynamicSuggestions={false}
|
||||
suggestions={[
|
||||
t("chat.suggestionCapabilities"),
|
||||
t("chat.suggestionCustomize"),
|
||||
t("chat.suggestionActions"),
|
||||
]}
|
||||
emptyStateText={t("chat.emptyState")}
|
||||
emptyStateDisplay="hidden"
|
||||
centerComposerWhenEmpty
|
||||
composerLayoutVariant="hero"
|
||||
composerPlaceholder={t("chat.composerPlaceholder")}
|
||||
composerSlot={
|
||||
<div className="mx-auto mb-5 max-w-xl px-4 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-foreground sm:text-3xl">
|
||||
{t("chat.heroTitle")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{t("chat.heroDescription")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { default, meta } from "./_index";
|
||||
export { default, meta } from "./chat._index";
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
AgentChatSurface,
|
||||
markAgentChatHomeHandoff,
|
||||
} from "@agent-native/core/client/agent-chat";
|
||||
import { useT } from "@agent-native/core/client/i18n";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
|
||||
import { APP_TITLE } from "@/lib/app-config";
|
||||
import { TAB_ID } from "@/lib/tab-id";
|
||||
|
||||
const SEO_TITLE = `Chat - ${APP_TITLE}`;
|
||||
const SEO_DESCRIPTION =
|
||||
"Ask the agent to look up, add, or organize poem quotes in Verse.";
|
||||
|
||||
export function meta() {
|
||||
return [
|
||||
{ title: SEO_TITLE },
|
||||
{
|
||||
name: "description",
|
||||
content: SEO_DESCRIPTION,
|
||||
},
|
||||
{ property: "og:title", content: SEO_TITLE },
|
||||
{ property: "og:description", content: SEO_DESCRIPTION },
|
||||
{ name: "twitter:card", content: "summary" },
|
||||
{ name: "twitter:title", content: SEO_TITLE },
|
||||
{ name: "twitter:description", content: SEO_DESCRIPTION },
|
||||
];
|
||||
}
|
||||
|
||||
function chatThreadPath(threadId: string | null) {
|
||||
return threadId ? `/chat/${encodeURIComponent(threadId)}` : "/chat";
|
||||
}
|
||||
|
||||
export default function ChatRoute() {
|
||||
const { threadId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const t = useT();
|
||||
const threadUrlSync = threadId
|
||||
? {
|
||||
routeThreadId: threadId,
|
||||
getPath: chatThreadPath,
|
||||
navigate,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
function handleChatRunning(event: Event) {
|
||||
const detail = (event as CustomEvent).detail;
|
||||
if (detail?.isRunning === true) markAgentChatHomeHandoff("chat");
|
||||
}
|
||||
|
||||
window.addEventListener("agentNative.chatRunning", handleChatRunning);
|
||||
return () =>
|
||||
window.removeEventListener("agentNative.chatRunning", handleChatRunning);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<AgentChatSurface
|
||||
mode="page"
|
||||
chatViewTransition
|
||||
className="h-full"
|
||||
defaultMode="chat"
|
||||
storageKey="chat"
|
||||
threadUrlSync={threadUrlSync}
|
||||
browserTabId={TAB_ID}
|
||||
showHeader={false}
|
||||
showTabBar={false}
|
||||
dynamicSuggestions={false}
|
||||
suggestions={[
|
||||
t("chat.suggestionCapabilities"),
|
||||
t("chat.suggestionCustomize"),
|
||||
t("chat.suggestionActions"),
|
||||
]}
|
||||
emptyStateText={t("chat.emptyState")}
|
||||
emptyStateDisplay="hidden"
|
||||
centerComposerWhenEmpty
|
||||
composerLayoutVariant="hero"
|
||||
composerPlaceholder={t("chat.composerPlaceholder")}
|
||||
composerSlot={
|
||||
<div className="mx-auto mb-5 max-w-xl px-4 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-normal text-foreground sm:text-3xl">
|
||||
{t("chat.heroTitle")}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">
|
||||
{t("chat.heroDescription")}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useActionQuery } from "@agent-native/core/client/hooks";
|
||||
import { IconHeart } from "@tabler/icons-react";
|
||||
|
||||
import { QuoteCard } from "@/components/quotes/QuoteCard";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useVisitorId } from "@/lib/visitor-id";
|
||||
|
||||
export function meta() {
|
||||
return [{ title: "Favorites — Verse" }];
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FavoritesRoute() {
|
||||
const visitorId = useVisitorId();
|
||||
const { data: quotes, isLoading } = useActionQuery(
|
||||
"list-favorites",
|
||||
{ visitorId },
|
||||
{ enabled: Boolean(visitorId) },
|
||||
);
|
||||
|
||||
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">Favorites</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Quotes you've saved on this device.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{Array.from({ length: 2 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-48 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : !quotes || quotes.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-16 text-center text-muted-foreground">
|
||||
<IconHeart className="size-8" />
|
||||
<p className="text-sm">
|
||||
No favorites yet — tap the heart on any quote to save it here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{quotes.map((quote) => (
|
||||
<QuoteCard key={quote.id} quote={quote} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user