diff --git a/.env.example b/.env.example index 33d7eaa..d3c0b56 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,14 @@ PING_MESSAGE=pong # Skip login/signup (local dev / preview only) # AUTH_DISABLED=true + +# Persistent secret for auth sessions (generate with: openssl rand -hex 32) +# BETTER_AUTH_SECRET= + +# Postgres connection (see docker-compose.yml for local Docker Postgres) +# POSTGRES_PASSWORD= +# DATABASE_URL=postgres://versequote:password@127.0.0.1:5434/versequote + +# Observability — OTLP http endpoint (Grafana LGTM / otel-collector) +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +# OTEL_SERVICE_NAME=verse-quote-app diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..887d08b --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: '10' + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Typecheck + run: pnpm typecheck + + - name: Test + run: pnpm test + + - name: Build + run: pnpm build diff --git a/.gitignore b/.gitignore index 3160707..a2d8d59 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ pnpm-debug.log* node_modules dist dist-ssr +.output +.deploy-tmp +build *.local # Editor directories and files diff --git a/AGENTS.md b/AGENTS.md index f0815c2..cd7d50c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,9 @@ -# Chat — Agent Guide +# Verse — Agent Guide -Chat is the minimal chat-first agent-native app template. Keep chat as the -primary surface, add actions for real capabilities, and add screens only when a -workflow needs durable UI around the conversation. +Verse is a quote app built on poems: browse, search, and favorite quotes drawn +from poems, or submit new ones. It started from the agent-native chat template, +but the primary surface is the quotes UI at `/` — chat lives at `/chat` and is +a secondary way to reach the same actions (see "Domain & Actions" below). ## Core Rules @@ -35,10 +36,40 @@ workflow needs durable UI around the conversation. - For new features, update UI, actions, skills/instructions, and application state when applicable. +## Domain & Actions + +Data model: `poems` (author, title, source, year) → `quotes` (text, tags, +is_user_submitted) → `favorites` (quote_id, visitor_id). `visitor_id` is an +anonymous per-browser id (localStorage), not a login — there is no user +account system in this app; `AUTH_DISABLED=true` by default since no Zitadel +client is configured for this instance. + +- **list-quotes** (GET) — browse/search quotes. Optional `tag`, `author`, + `search` (free text over quote + poem title), `visitorId` (marks + `isFavorited` on each result), `favoritesOnly`, `limit`. Use this for any + "show me quotes about X" / "quotes by Y" request. +- **get-random-quote** (GET) — one random quote, optionally filtered by `tag`. + Use for "quote of the day" / "surprise me" requests. +- **create-quote** (POST) — submit a new quote. Requires `text`, `author`, + `poemTitle`; optional `source`, `year`, `tags`. Creates the poem record if + author+title doesn't already exist (case-sensitive exact match). Marks the + quote `is_user_submitted: true`. +- **toggle-favorite** (POST) — favorite/unfavorite a quote for a `visitorId` + (toggles based on current state, no separate favorite/unfavorite actions). + Returns `{ favorited, favoriteCount }`. +- **list-favorites** (GET) — all quotes a `visitorId` has favorited, most + recent first. Requires `visitorId`. + +All five are callable from chat and from the React UI via +`useActionQuery`/`useActionMutation` — there is no separate `/api/*` REST +layer. Every action call emits an OpenTelemetry span, a structured log line, +and increments a call counter, exported over OTLP to the shared Grafana LGTM +stack (see `server/otel.ts`). + ## Application State - `navigation` should describe the current view and selected entity ids. The - default chat view is `chat` at `/`. + quotes home view is `quotes` at `/`; chat is a secondary view at `/chat`. - `navigate` may be used to move the UI when the app supports it. - `view-screen` is the first tool to call when the user's visible context matters. diff --git a/README.md b/README.md index 2ed48d2..f0b24aa 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,43 @@ -# Chat +# Verse -The minimal agent-native starter app — a clean, ChatGPT-style shell with chat at -the center, durable threads, standard app navigation, auth, live sync, and -actions. Start here when you want a real browser app to build on without -committing to a domain template. - -**Live app: [chat.agent-native.com](https://chat.agent-native.com)** - -Chat is the basic agent-native app starting point. It gives you the app-agent -loop wired end to end and one example action, so you can add your own UI, data, -and actions on top. +A quote app built on poems: browse, search, and favorite quotes drawn from +classic poems, or submit your own. Built with +[Agent Native](https://agent-native.com) — every app operation is a single +`defineAction` callable from the React UI *and* from agent chat. ## Features -- ChatGPT-style shell with a threads list and durable chat history. -- Auth, live sync, and application state wired out of the box. -- The action surface the agent and UI share, plus one example action to copy. -- A minimal, brandable base for any domain app. +- Browse and search quotes by poet, poem, or theme tag. +- Favorite quotes anonymously (per-browser, no login required). +- Submit new quotes, which create the poem record if it doesn't exist yet. +- The same actions are callable from the in-app agent chat at `/chat`. +- Postgres-backed shared state, seeded with ~20 public-domain poems on first boot. +- OpenTelemetry traces/metrics/logs for every action call, exported over OTLP. + +## Action surface + +See `AGENTS.md` for the full list. In short: `list-quotes`, `get-random-quote`, +`create-quote`, `toggle-favorite`, `list-favorites`. ## Develop locally -Scaffold your own copy and run it: - ```bash -npx @agent-native/core@latest create my-app --standalone --template chat -cd my-app pnpm install +docker compose up -d # starts local Postgres pnpm dev ``` -Full docs: [agent-native.com/docs/template-chat](https://agent-native.com/docs/template-chat). +Set `DATABASE_URL` in `.env` (see `.env.example`) to point at the Postgres +container, or any Postgres/Neon/Supabase/libSQL connection string — schema and +seed data are created automatically on first boot via the migration plugin in +`server/plugins/db.ts`. + +## Production + +```bash +pnpm build +node .output/server/index.mjs +``` + +Built on the Agent Native `chat` template: +[agent-native.com/docs/template-chat](https://agent-native.com/docs/template-chat). diff --git a/actions/create-quote.ts b/actions/create-quote.ts new file mode 100644 index 0000000..4022f8d --- /dev/null +++ b/actions/create-quote.ts @@ -0,0 +1,68 @@ +import { defineAction } from "@agent-native/core/action"; +import { eq, and } from "drizzle-orm"; +import { z } from "zod"; + +import { db, schema } from "../server/db/index.js"; +import { hydrateQuotes } from "../server/lib/quotes.js"; +import { withActionSpan } from "../server/otel.js"; + +export default defineAction({ + description: + "Submit a new poem quote. Finds the poem by author + title (case-insensitive) or creates it if it doesn't exist yet, then adds the quote under it, marked as user-submitted.", + schema: z.object({ + text: z.string().min(1).describe("The quote text (an excerpt from the poem)"), + author: z.string().min(1).describe("The poet's name"), + poemTitle: z.string().min(1).describe("Title of the poem this quote is from"), + source: z.string().optional().describe("Collection/book the poem was published in"), + year: z.coerce.number().int().optional().describe("Year the poem was written or published"), + tags: z + .array(z.string()) + .optional() + .describe("Theme tags for the quote, e.g. [\"hope\", \"nature\"]"), + }), + run: async (args) => { + return withActionSpan( + "create-quote", + { author: args.author, poemTitle: args.poemTitle }, + async () => { + const existing = await db() + .select() + .from(schema.poems) + .where( + and( + eq(schema.poems.author, args.author), + eq(schema.poems.title, args.poemTitle), + ), + ) + .limit(1); + + let poem = existing[0]; + if (!poem) { + const inserted = await db() + .insert(schema.poems) + .values({ + author: args.author, + title: args.poemTitle, + source: args.source ?? null, + year: args.year ?? null, + }) + .returning(); + poem = inserted[0]; + } + + const insertedQuote = await db() + .insert(schema.quotes) + .values({ + poem_id: poem.id, + text: args.text, + tags_json: JSON.stringify(args.tags ?? []), + is_user_submitted: true, + }) + .returning(); + + const [hydrated] = await hydrateQuotes([{ quote: insertedQuote[0], poem }]); + return hydrated; + }, + ); + }, +}); diff --git a/actions/get-random-quote.ts b/actions/get-random-quote.ts new file mode 100644 index 0000000..17c2164 --- /dev/null +++ b/actions/get-random-quote.ts @@ -0,0 +1,32 @@ +import { defineAction } from "@agent-native/core/action"; +import { z } from "zod"; + +import { withActionSpan } from "../server/otel.js"; +import { fetchAllQuoteRows, hydrateQuotes, parseTags } from "../server/lib/quotes.js"; + +export default defineAction({ + description: + "Get one random poem quote, optionally restricted to a tag. Use for a \"quote of the day\" / discover surface.", + schema: z.object({ + tag: z.string().optional().describe("Only pick from quotes with this tag"), + visitorId: z + .string() + .optional() + .describe("Anonymous visitor id, used to mark whether this visitor already favorited the pick"), + }), + http: { method: "GET" }, + run: async (args) => { + return withActionSpan("get-random-quote", { tag: args.tag }, async () => { + const rows = await fetchAllQuoteRows(); + const candidates = args.tag + ? rows.filter((row) => parseTags(row.quote.tags_json).includes(args.tag!)) + : rows; + + if (candidates.length === 0) return null; + + const pick = candidates[Math.floor(Math.random() * candidates.length)]; + const [hydrated] = await hydrateQuotes([pick], args.visitorId); + return hydrated; + }); + }, +}); diff --git a/actions/hello.ts b/actions/hello.ts deleted file mode 100644 index b07d960..0000000 --- a/actions/hello.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineAction } from "@agent-native/core/action"; -import { z } from "zod"; - -export default defineAction({ - description: "Return a friendly greeting.", - schema: z.object({ - name: z.string().default("world").describe("Name to greet"), - }), - http: { method: "GET" }, - run: async ({ name }) => { - return { message: `Hello, ${name}!` }; - }, -}); diff --git a/actions/list-favorites.ts b/actions/list-favorites.ts new file mode 100644 index 0000000..224bac5 --- /dev/null +++ b/actions/list-favorites.ts @@ -0,0 +1,32 @@ +import { defineAction } from "@agent-native/core/action"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; + +import { db, schema } from "../server/db/index.js"; +import { fetchAllQuoteRows, hydrateQuotes } from "../server/lib/quotes.js"; +import { withActionSpan } from "../server/otel.js"; + +export default defineAction({ + description: "List all quotes a given visitor has favorited, most recently favorited first.", + schema: z.object({ + visitorId: z.string().min(1).describe("Anonymous visitor id (from browser storage)"), + }), + http: { method: "GET" }, + run: async (args) => { + return withActionSpan("list-favorites", { visitorId: args.visitorId }, async () => { + const favorites = await db() + .select() + .from(schema.favorites) + .where(eq(schema.favorites.visitor_id, args.visitorId)); + + if (favorites.length === 0) return []; + + const favoritedAt = new Map(favorites.map((f) => [f.quote_id, f.created_at ?? ""])); + const allRows = await fetchAllQuoteRows(); + const rows = allRows.filter((row) => favoritedAt.has(row.quote.id)); + rows.sort((a, b) => (favoritedAt.get(b.quote.id) ?? "").localeCompare(favoritedAt.get(a.quote.id) ?? "")); + + return hydrateQuotes(rows, args.visitorId); + }); + }, +}); diff --git a/actions/list-quotes.ts b/actions/list-quotes.ts new file mode 100644 index 0000000..f774d33 --- /dev/null +++ b/actions/list-quotes.ts @@ -0,0 +1,65 @@ +import { defineAction } from "@agent-native/core/action"; +import { z } from "zod"; + +import { withActionSpan } from "../server/otel.js"; +import { fetchAllQuoteRows, hydrateQuotes, parseTags } from "../server/lib/quotes.js"; + +export default defineAction({ + description: + "List/browse poem quotes, optionally filtered by tag, author, or a free-text search over the quote and poem title. Set favoritesOnly=\"true\" with visitorId to list only that visitor's favorites (prefer list-favorites for that case though).", + schema: z.object({ + tag: z.string().optional().describe("Only quotes with this tag (e.g. \"hope\", \"nature\")"), + author: z.string().optional().describe("Only quotes by this poet (exact match)"), + search: z + .string() + .optional() + .describe("Free-text search across quote text and poem title"), + visitorId: z + .string() + .optional() + .describe("Anonymous visitor id, used to mark which quotes this visitor has favorited"), + favoritesOnly: z + .string() + .optional() + .describe("Pass \"true\" (with visitorId) to only return favorited quotes"), + limit: z.coerce + .number() + .int() + .min(1) + .max(200) + .optional() + .describe("Max quotes to return (default 100)"), + }), + http: { method: "GET" }, + run: async (args) => { + return withActionSpan( + "list-quotes", + { tag: args.tag, author: args.author, search: args.search }, + async () => { + const rows = await fetchAllQuoteRows(); + const search = args.search?.trim().toLowerCase(); + + const filtered = rows.filter((row) => { + if (args.author && row.poem.author !== args.author) return false; + if (args.tag && !parseTags(row.quote.tags_json).includes(args.tag)) { + return false; + } + if (search) { + const haystack = `${row.quote.text} ${row.poem.title} ${row.poem.author}`.toLowerCase(); + if (!haystack.includes(search)) return false; + } + return true; + }); + + filtered.sort((a, b) => (b.quote.created_at ?? "").localeCompare(a.quote.created_at ?? "")); + const limited = filtered.slice(0, args.limit ?? 100); + + let hydrated = await hydrateQuotes(limited, args.visitorId); + if (args.favoritesOnly === "true") { + hydrated = hydrated.filter((q) => q.isFavorited); + } + return hydrated; + }, + ); + }, +}); diff --git a/actions/toggle-favorite.ts b/actions/toggle-favorite.ts new file mode 100644 index 0000000..67dac59 --- /dev/null +++ b/actions/toggle-favorite.ts @@ -0,0 +1,54 @@ +import { defineAction } from "@agent-native/core/action"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { db, schema } from "../server/db/index.js"; +import { quoteFavoriteCounter, withActionSpan } from "../server/otel.js"; + +export default defineAction({ + description: + "Favorite or unfavorite a quote for a given visitor (toggles based on current state). Returns the new favorited state and total favorite count for the quote.", + schema: z.object({ + quoteId: z.coerce.number().int().describe("The quote's id"), + visitorId: z.string().min(1).describe("Anonymous visitor id (from browser storage)"), + }), + run: async (args) => { + return withActionSpan( + "toggle-favorite", + { quoteId: args.quoteId }, + async () => { + const existing = await db() + .select() + .from(schema.favorites) + .where( + and( + eq(schema.favorites.quote_id, args.quoteId), + eq(schema.favorites.visitor_id, args.visitorId), + ), + ) + .limit(1); + + let favorited: boolean; + if (existing.length > 0) { + await db().delete(schema.favorites).where(eq(schema.favorites.id, existing[0].id)); + favorited = false; + quoteFavoriteCounter.add(1, { action: "remove" }); + } else { + await db().insert(schema.favorites).values({ + quote_id: args.quoteId, + visitor_id: args.visitorId, + }); + favorited = true; + quoteFavoriteCounter.add(1, { action: "add" }); + } + + const allFavorites = await db() + .select() + .from(schema.favorites) + .where(eq(schema.favorites.quote_id, args.quoteId)); + + return { quoteId: args.quoteId, favorited, favoriteCount: allFavorites.length }; + }, + ); + }, +}); diff --git a/app/components/layout/Header.tsx b/app/components/layout/Header.tsx index e7ab7b6..8a0c85c 100644 --- a/app/components/layout/Header.tsx +++ b/app/components/layout/Header.tsx @@ -10,7 +10,9 @@ import { useLocation } from "react-router"; import { APP_TITLE } from "@/lib/app-config"; const pageTitleKeys: Record = { - "/": "navigation.chat", + "/": "navigation.quotes", + "/favorites": "navigation.favorites", + "/chat": "navigation.chat", "/observability": "navigation.observability", "/agent": "settings.agentTitle", "/settings": "navigation.settings", diff --git a/app/components/layout/Layout.tsx b/app/components/layout/Layout.tsx index e11657c..48c86f3 100644 --- a/app/components/layout/Layout.tsx +++ b/app/components/layout/Layout.tsx @@ -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 = ( diff --git a/app/components/layout/Sidebar.tsx b/app/components/layout/Sidebar.tsx index e9bdc2b..d320152 100644 --- a/app/components/layout/Sidebar.tsx +++ b/app/components/layout/Sidebar.tsx @@ -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 = ( { 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 })} diff --git a/app/components/quotes/QuoteCard.tsx b/app/components/quotes/QuoteCard.tsx new file mode 100644 index 0000000..c08bb74 --- /dev/null +++ b/app/components/quotes/QuoteCard.tsx @@ -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 ( + + +
+ “{quote.text}” +
+

+ — {quote.poem.author}, {quote.poem.title} + {quote.poem.year ? ` (${quote.poem.year})` : ""} +

+ {quote.tags.length > 0 && ( +
+ {quote.tags.map((tag) => ( + onTagClick?.(tag)} + > + {tag} + + ))} +
+ )} +
+ + + {count} {count === 1 ? "favorite" : "favorites"} + + + +
+ ); +} diff --git a/app/components/quotes/SubmitQuoteDialog.tsx b/app/components/quotes/SubmitQuoteDialog.tsx new file mode 100644 index 0000000..11c1770 --- /dev/null +++ b/app/components/quotes/SubmitQuoteDialog.tsx @@ -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 ( + + + + + + + Submit a quote + + Share a line or passage from a poem you love. + + +
+
+ +