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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -11,6 +11,9 @@ pnpm-debug.log*
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.output
|
||||
.deploy-tmp
|
||||
build
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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}!` };
|
||||
},
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
: 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;
|
||||
}
|
||||
+127
-76
@@ -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")}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: verse-quote-app-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: versequote
|
||||
POSTGRES_USER: versequote
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- "127.0.0.1:5434:5432"
|
||||
volumes:
|
||||
- verse-quote-app-pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U versequote -d versequote"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
verse-quote-app-pgdata:
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createDrizzleConfig } from "@agent-native/core/db/drizzle-config";
|
||||
|
||||
export default createDrizzleConfig({ out: "./drizzle" });
|
||||
+18
-14
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "app",
|
||||
"displayName": "App",
|
||||
"name": "verse-quote-app",
|
||||
"displayName": "Verse",
|
||||
"private": true,
|
||||
"description": "Workspace app for App.",
|
||||
"description": "Quotes drawn from poems — browse, search, and save your favorites.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "agent-native dev --open",
|
||||
@@ -18,16 +18,27 @@
|
||||
"@agent-native/toolkit": "^0.10.6",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@libsql/client": "^0.15.8",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/api-logs": "^0.221.0",
|
||||
"@opentelemetry/exporter-logs-otlp-http": "^0.221.0",
|
||||
"@opentelemetry/exporter-metrics-otlp-http": "^0.221.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.221.0",
|
||||
"@opentelemetry/resources": "^2.10.0",
|
||||
"@opentelemetry/sdk-logs": "^0.221.0",
|
||||
"@opentelemetry/sdk-metrics": "^2.10.0",
|
||||
"@opentelemetry/sdk-trace-base": "^2.10.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.43.0",
|
||||
"@react-router/dev": "8.1.0",
|
||||
"@react-router/fs-routes": "8.1.0",
|
||||
"@tabler/icons-react": "^3.41.1",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"h3": "^2.0.1-rc.20",
|
||||
"isbot": "^5",
|
||||
"node-pty": "^1.1.0",
|
||||
"zod": "^4.4.3",
|
||||
"postgres": "^3.4.9",
|
||||
"@react-router/dev": "8.1.0",
|
||||
"@react-router/fs-routes": "8.1.0",
|
||||
"react-router": "8.1.0",
|
||||
"vite": "8.1.0"
|
||||
"vite": "8.1.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
@@ -99,12 +110,5 @@
|
||||
"coreVersion": "0.122.1",
|
||||
"shape": "standalone"
|
||||
}
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"better-sqlite3",
|
||||
"esbuild",
|
||||
"node-pty"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+12556
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -1,8 +1,8 @@
|
||||
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
esbuild: true
|
||||
better-sqlite3: true
|
||||
tesseract.js: true
|
||||
|
||||
overrides:
|
||||
nf3: "0.3.17"
|
||||
@@ -15,3 +15,4 @@ minimumReleaseAgeExclude:
|
||||
- fast-xml-parser
|
||||
- "@sentry/*"
|
||||
- "@typescript/*"
|
||||
- '@agent-native/core@0.122.1'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "App",
|
||||
"short_name": "App",
|
||||
"description": "Workspace app for App.",
|
||||
"name": "Verse",
|
||||
"short_name": "Verse",
|
||||
"description": "Quotes drawn from poems — browse, search, and save your favorites.",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#111111",
|
||||
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
# Nightly Postgres backup for verse-quote-app. Invoked by app-754b9a8a-backup.timer.
|
||||
set -euo pipefail
|
||||
|
||||
BACKUP_DIR="/home/exedev/backups"
|
||||
RETENTION_DAYS=7
|
||||
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
FILE="$BACKUP_DIR/verse-quote-app-$TIMESTAMP.sql.gz"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
docker exec verse-quote-app-postgres pg_dump -U versequote -d versequote | gzip > "$FILE"
|
||||
|
||||
find "$BACKUP_DIR" -name 'verse-quote-app-*.sql.gz' -mtime +"$RETENTION_DAYS" -delete
|
||||
|
||||
echo "Backed up verse-quote-app database to $FILE"
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createGetDb } from "@agent-native/core/db";
|
||||
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
export const getDb = createGetDb(schema);
|
||||
|
||||
// Convenience: callable as db().select()...
|
||||
export function db() {
|
||||
return getDb();
|
||||
}
|
||||
|
||||
export { schema };
|
||||
@@ -0,0 +1,35 @@
|
||||
import { table, text, integer } from "@agent-native/core/db/schema";
|
||||
|
||||
// A poem a quote is drawn from. Kept minimal — this app curates excerpts,
|
||||
// not full poem texts.
|
||||
export const poems = table("poems", {
|
||||
id: integer("id").primaryKey(),
|
||||
author: text("author").notNull(),
|
||||
title: text("title").notNull(),
|
||||
source: text("source"),
|
||||
year: integer("year"),
|
||||
created_at: text("created_at").$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
// tags_json is a JSON-encoded string array (e.g. '["hope","nature"]").
|
||||
// Dialect-agnostic schema helpers have no array type, so tags are
|
||||
// JSON-encoded text — the same convention other first-party templates use.
|
||||
export const quotes = table("quotes", {
|
||||
id: integer("id").primaryKey(),
|
||||
poem_id: integer("poem_id").notNull(),
|
||||
text: text("text").notNull(),
|
||||
tags_json: text("tags_json").notNull().default("[]"),
|
||||
is_user_submitted: integer("is_user_submitted", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
created_at: text("created_at").$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
// visitor_id is a client-generated anonymous id (localStorage), not a real
|
||||
// user account — this app has no login requirement.
|
||||
export const favorites = table("favorites", {
|
||||
id: integer("id").primaryKey(),
|
||||
quote_id: integer("quote_id").notNull(),
|
||||
visitor_id: text("visitor_id").notNull(),
|
||||
created_at: text("created_at").$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
// Public-domain poem excerpts used to seed a fresh database. Shared by the
|
||||
// startup migration plugin so seeding works the same way regardless of which
|
||||
// SQL provider (Docker Postgres, Neon, Supabase, local SQLite, ...) is
|
||||
// behind DATABASE_URL.
|
||||
|
||||
export type SeedQuote = {
|
||||
text: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type SeedPoem = {
|
||||
author: string;
|
||||
title: string;
|
||||
source: string | null;
|
||||
year: number | null;
|
||||
quotes: SeedQuote[];
|
||||
};
|
||||
|
||||
export const SEED_POEMS: SeedPoem[] = [
|
||||
{
|
||||
author: "Emily Dickinson",
|
||||
title: "“Hope” is the thing with feathers",
|
||||
source: "Poems (1891)",
|
||||
year: 1861,
|
||||
quotes: [
|
||||
{
|
||||
text: "“Hope” is the thing with feathers — that perches in the soul — and sings the tune without the words — and never stops — at all —",
|
||||
tags: ["hope", "resilience"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Robert Frost",
|
||||
title: "The Road Not Taken",
|
||||
source: "Mountain Interval",
|
||||
year: 1916,
|
||||
quotes: [
|
||||
{
|
||||
text: "Two roads diverged in a wood, and I— I took the one less traveled by, and that has made all the difference.",
|
||||
tags: ["choice", "life", "journey"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Walt Whitman",
|
||||
title: "Song of Myself",
|
||||
source: "Leaves of Grass",
|
||||
year: 1855,
|
||||
quotes: [
|
||||
{ text: "I contain multitudes.", tags: ["identity", "self"] },
|
||||
{
|
||||
text: "Do I contradict myself? Very well then I contradict myself, (I am large, I contain multitudes.)",
|
||||
tags: ["identity", "self"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "William Wordsworth",
|
||||
title: "I Wandered Lonely as a Cloud",
|
||||
source: "Poems in Two Volumes",
|
||||
year: 1807,
|
||||
quotes: [
|
||||
{
|
||||
text: "For oft, when on my couch I lie in vacant or in pensive mood, they flash upon that inward eye which is the bliss of solitude.",
|
||||
tags: ["memory", "solitude", "nature"],
|
||||
},
|
||||
{
|
||||
text: "I wandered lonely as a cloud that floats on high o'er vales and hills, when all at once I saw a crowd, a host, of golden daffodils.",
|
||||
tags: ["nature", "solitude"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "William Shakespeare",
|
||||
title: "Sonnet 18",
|
||||
source: "Shakespeare's Sonnets",
|
||||
year: 1609,
|
||||
quotes: [
|
||||
{
|
||||
text: "Shall I compare thee to a summer's day? Thou art more lovely and more temperate.",
|
||||
tags: ["love", "beauty"],
|
||||
},
|
||||
{
|
||||
text: "So long as men can breathe or eyes can see, so long lives this, and this gives life to thee.",
|
||||
tags: ["love", "immortality"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "John Keats",
|
||||
title: "Endymion",
|
||||
source: null,
|
||||
year: 1818,
|
||||
quotes: [
|
||||
{
|
||||
text: "A thing of beauty is a joy for ever: its loveliness increases; it will never pass into nothingness.",
|
||||
tags: ["beauty", "joy"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "William Butler Yeats",
|
||||
title: "The Second Coming",
|
||||
source: "Michael Robartes and the Dancer",
|
||||
year: 1920,
|
||||
quotes: [
|
||||
{
|
||||
text: "Turning and turning in the widening gyre the falcon cannot hear the falconer; things fall apart; the centre cannot hold.",
|
||||
tags: ["chaos", "change"],
|
||||
},
|
||||
{
|
||||
text: "The best lack all conviction, while the worst are full of passionate intensity.",
|
||||
tags: ["conviction", "society"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Percy Bysshe Shelley",
|
||||
title: "Ozymandias",
|
||||
source: null,
|
||||
year: 1818,
|
||||
quotes: [
|
||||
{
|
||||
text: "My name is Ozymandias, king of kings: look on my works, ye Mighty, and despair! Nothing beside remains.",
|
||||
tags: ["time", "pride", "impermanence"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Alfred, Lord Tennyson",
|
||||
title: "Ulysses",
|
||||
source: null,
|
||||
year: 1842,
|
||||
quotes: [
|
||||
{
|
||||
text: "Though much is taken, much abides; and though we are not now that strength which in old days moved earth and heaven... one equal temper of heroic hearts... strong in will to strive, to seek, to find, and not to yield.",
|
||||
tags: ["perseverance", "age", "courage"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Edgar Allan Poe",
|
||||
title: "A Dream Within a Dream",
|
||||
source: null,
|
||||
year: 1849,
|
||||
quotes: [
|
||||
{
|
||||
text: "Is all that we see or seem but a dream within a dream?",
|
||||
tags: ["dreams", "existence"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "William Blake",
|
||||
title: "Auguries of Innocence",
|
||||
source: null,
|
||||
year: 1863,
|
||||
quotes: [
|
||||
{
|
||||
text: "To see a World in a Grain of Sand, and a Heaven in a Wild Flower, hold Infinity in the palm of your hand, and Eternity in an hour.",
|
||||
tags: ["wonder", "nature", "infinity"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Christina Rossetti",
|
||||
title: "Remember",
|
||||
source: "Goblin Market and Other Poems",
|
||||
year: 1862,
|
||||
quotes: [
|
||||
{
|
||||
text: "Better by far you should forget and smile than that you should remember and be sad.",
|
||||
tags: ["memory", "love", "loss"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Robert Frost",
|
||||
title: "Stopping by Woods on a Snowy Evening",
|
||||
source: "New Hampshire",
|
||||
year: 1923,
|
||||
quotes: [
|
||||
{
|
||||
text: "The woods are lovely, dark and deep, but I have promises to keep, and miles to go before I sleep.",
|
||||
tags: ["duty", "rest", "nature"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Emily Dickinson",
|
||||
title: "Because I could not stop for Death",
|
||||
source: "Poems (1890)",
|
||||
year: 1863,
|
||||
quotes: [
|
||||
{
|
||||
text: "Because I could not stop for Death, he kindly stopped for me; the carriage held but just ourselves and Immortality.",
|
||||
tags: ["death", "time"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Walt Whitman",
|
||||
title: "O Captain! My Captain!",
|
||||
source: "Sequel to Drums-Taps",
|
||||
year: 1865,
|
||||
quotes: [
|
||||
{
|
||||
text: "O Captain! my Captain! our fearful trip is done, the ship has weather'd every rack, the prize we sought is won.",
|
||||
tags: ["triumph", "loss", "leadership"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Rumi",
|
||||
title: "The Guest House",
|
||||
source: "The Essential Rumi (translation)",
|
||||
year: 1207,
|
||||
quotes: [
|
||||
{
|
||||
text: "This being human is a guest house. Every morning a new arrival... Welcome and entertain them all!",
|
||||
tags: ["acceptance", "emotion", "mindfulness"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Sara Teasdale",
|
||||
title: "Barter",
|
||||
source: "Rivers to the Sea",
|
||||
year: 1915,
|
||||
quotes: [
|
||||
{
|
||||
text: "Life has loveliness to sell, all beautiful and splendid things, blue waves whitened on a cliff, soaring fire that sways and sings.",
|
||||
tags: ["beauty", "life", "joy"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Elizabeth Barrett Browning",
|
||||
title: "Sonnet 43 (How Do I Love Thee?)",
|
||||
source: "Sonnets from the Portuguese",
|
||||
year: 1850,
|
||||
quotes: [
|
||||
{
|
||||
text: "How do I love thee? Let me count the ways. I love thee to the depth and breadth and height my soul can reach.",
|
||||
tags: ["love"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Langston Hughes",
|
||||
title: "Dreams",
|
||||
source: "The Weary Blues",
|
||||
year: 1926,
|
||||
quotes: [
|
||||
{
|
||||
text: "Hold fast to dreams, for if dreams die, life is a broken-winged bird that cannot fly.",
|
||||
tags: ["dreams", "hope"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
author: "Henry Wadsworth Longfellow",
|
||||
title: "A Psalm of Life",
|
||||
source: "Voices of the Night",
|
||||
year: 1838,
|
||||
quotes: [
|
||||
{
|
||||
text: "Life is real! Life is earnest! And the grave is not its goal... Let us, then, be up and doing, with a heart for any fate.",
|
||||
tags: ["life", "purpose", "courage"],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,85 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db, schema } from "../db/index.js";
|
||||
|
||||
export type PoemInfo = {
|
||||
id: number;
|
||||
title: string;
|
||||
author: string;
|
||||
source: string | null;
|
||||
year: number | null;
|
||||
};
|
||||
|
||||
export type QuoteWithPoem = {
|
||||
id: number;
|
||||
text: string;
|
||||
tags: string[];
|
||||
isUserSubmitted: boolean;
|
||||
createdAt: string | null;
|
||||
poem: PoemInfo;
|
||||
favoriteCount: number;
|
||||
isFavorited: boolean;
|
||||
};
|
||||
|
||||
export function parseTags(tagsJson: string): string[] {
|
||||
try {
|
||||
const value = JSON.parse(tagsJson);
|
||||
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type RawRow = {
|
||||
quote: typeof schema.quotes.$inferSelect;
|
||||
poem: typeof schema.poems.$inferSelect;
|
||||
};
|
||||
|
||||
/**
|
||||
* Joins quote rows with their poem, favorite counts, and (when visitorId is
|
||||
* given) whether that visitor has favorited each quote. Shared by every
|
||||
* action that returns quotes so the shape stays identical across list,
|
||||
* random, favorites, and create.
|
||||
*/
|
||||
export async function hydrateQuotes(
|
||||
rows: RawRow[],
|
||||
visitorId?: string,
|
||||
): Promise<QuoteWithPoem[]> {
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const quoteIds = rows.map((r) => r.quote.id);
|
||||
const allFavorites = await db().select().from(schema.favorites);
|
||||
const countByQuoteId = new Map<number, number>();
|
||||
const favoritedByVisitor = new Set<number>();
|
||||
for (const fav of allFavorites) {
|
||||
if (!quoteIds.includes(fav.quote_id)) continue;
|
||||
countByQuoteId.set(fav.quote_id, (countByQuoteId.get(fav.quote_id) ?? 0) + 1);
|
||||
if (visitorId && fav.visitor_id === visitorId) {
|
||||
favoritedByVisitor.add(fav.quote_id);
|
||||
}
|
||||
}
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.quote.id,
|
||||
text: row.quote.text,
|
||||
tags: parseTags(row.quote.tags_json),
|
||||
isUserSubmitted: row.quote.is_user_submitted,
|
||||
createdAt: row.quote.created_at ?? null,
|
||||
poem: {
|
||||
id: row.poem.id,
|
||||
title: row.poem.title,
|
||||
author: row.poem.author,
|
||||
source: row.poem.source ?? null,
|
||||
year: row.poem.year ?? null,
|
||||
},
|
||||
favoriteCount: countByQuoteId.get(row.quote.id) ?? 0,
|
||||
isFavorited: favoritedByVisitor.has(row.quote.id),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchAllQuoteRows(): Promise<RawRow[]> {
|
||||
return db()
|
||||
.select({ quote: schema.quotes, poem: schema.poems })
|
||||
.from(schema.quotes)
|
||||
.innerJoin(schema.poems, eq(schema.quotes.poem_id, schema.poems.id));
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* OpenTelemetry setup for verse-quote-app: exports traces, metrics, and logs
|
||||
* over OTLP/HTTP to the shared Grafana LGTM collector on this VM
|
||||
* (http://localhost:4318). Manual instrumentation rather than
|
||||
* auto-instrumentation-node — this app's ESM/Nitro entrypoint doesn't give
|
||||
* us a require-before-everything-else hook to patch http/pg at, so we
|
||||
* instrument the one seam that matters for this app: action calls.
|
||||
*/
|
||||
import { trace, metrics, diag, DiagConsoleLogger, DiagLogLevel, type Span } from "@opentelemetry/api";
|
||||
import { logs, SeverityNumber } from "@opentelemetry/api-logs";
|
||||
import { resourceFromAttributes } from "@opentelemetry/resources";
|
||||
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
||||
import {
|
||||
BasicTracerProvider,
|
||||
BatchSpanProcessor,
|
||||
} from "@opentelemetry/sdk-trace-base";
|
||||
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
||||
import {
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http";
|
||||
import {
|
||||
LoggerProvider,
|
||||
BatchLogRecordProcessor,
|
||||
} from "@opentelemetry/sdk-logs";
|
||||
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http";
|
||||
|
||||
const SERVICE_NAME = process.env.OTEL_SERVICE_NAME || "verse-quote-app";
|
||||
const OTLP_ENDPOINT =
|
||||
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318";
|
||||
|
||||
let started = false;
|
||||
|
||||
export function startOtel(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
if (process.env.OTEL_DEBUG) {
|
||||
diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
|
||||
}
|
||||
|
||||
const resource = resourceFromAttributes({
|
||||
[ATTR_SERVICE_NAME]: SERVICE_NAME,
|
||||
});
|
||||
|
||||
const tracerProvider = new BasicTracerProvider({
|
||||
resource,
|
||||
spanProcessors: [
|
||||
new BatchSpanProcessor(
|
||||
new OTLPTraceExporter({ url: `${OTLP_ENDPOINT}/v1/traces` }),
|
||||
),
|
||||
],
|
||||
});
|
||||
trace.setGlobalTracerProvider(tracerProvider);
|
||||
|
||||
const meterProvider = new MeterProvider({
|
||||
resource,
|
||||
readers: [
|
||||
new PeriodicExportingMetricReader({
|
||||
exporter: new OTLPMetricExporter({ url: `${OTLP_ENDPOINT}/v1/metrics` }),
|
||||
exportIntervalMillis: 15000,
|
||||
}),
|
||||
],
|
||||
});
|
||||
metrics.setGlobalMeterProvider(meterProvider);
|
||||
|
||||
const loggerProvider = new LoggerProvider({
|
||||
resource,
|
||||
processors: [
|
||||
new BatchLogRecordProcessor({
|
||||
exporter: new OTLPLogExporter({ url: `${OTLP_ENDPOINT}/v1/logs` }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
logs.setGlobalLoggerProvider(loggerProvider);
|
||||
|
||||
const shutdown = () => {
|
||||
Promise.allSettled([
|
||||
tracerProvider.shutdown(),
|
||||
meterProvider.shutdown(),
|
||||
loggerProvider.shutdown(),
|
||||
]).finally(() => process.exit(0));
|
||||
};
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
|
||||
console.log(`[otel] started, exporting to ${OTLP_ENDPOINT} as service "${SERVICE_NAME}"`);
|
||||
}
|
||||
|
||||
// Must run before getMeter()/createCounter() below: unlike the tracer and
|
||||
// logger proxies (which re-resolve their delegate on every call), the metrics
|
||||
// API's Counter is bound to whatever provider is active at createCounter()
|
||||
// time — calling startOtel() after creating counters would permanently bind
|
||||
// them to the no-op backend and silently drop every metric.
|
||||
startOtel();
|
||||
|
||||
const tracer = trace.getTracer(SERVICE_NAME);
|
||||
const meter = metrics.getMeter(SERVICE_NAME);
|
||||
const otelLogger = logs.getLogger(SERVICE_NAME);
|
||||
|
||||
export const actionCallCounter = meter.createCounter("verse_quote_action_calls_total", {
|
||||
description: "Number of verse-quote-app action invocations, by action name and outcome",
|
||||
});
|
||||
|
||||
export const quoteFavoriteCounter = meter.createCounter("verse_quote_favorites_total", {
|
||||
description: "Number of favorite/unfavorite toggles on quotes",
|
||||
});
|
||||
|
||||
/**
|
||||
* Wrap an action's run() body in a span + call counter + structured log.
|
||||
* Records success/error outcome and re-throws so defineAction's own error
|
||||
* handling is unaffected.
|
||||
*/
|
||||
export async function withActionSpan<T>(
|
||||
actionName: string,
|
||||
attributes: Record<string, string | number | boolean | undefined>,
|
||||
fn: (span: Span) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return tracer.startActiveSpan(`action.${actionName}`, async (span) => {
|
||||
for (const [key, value] of Object.entries(attributes)) {
|
||||
if (value !== undefined) span.setAttribute(key, value);
|
||||
}
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await fn(span);
|
||||
actionCallCounter.add(1, { action: actionName, outcome: "success" });
|
||||
otelLogger.emit({
|
||||
severityNumber: SeverityNumber.INFO,
|
||||
severityText: "INFO",
|
||||
body: `action ${actionName} succeeded in ${Date.now() - start}ms`,
|
||||
attributes: { action: actionName, ...attributes },
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
actionCallCounter.add(1, { action: actionName, outcome: "error" });
|
||||
span.recordException(err instanceof Error ? err : new Error(String(err)));
|
||||
otelLogger.emit({
|
||||
severityNumber: SeverityNumber.ERROR,
|
||||
severityText: "ERROR",
|
||||
body: `action ${actionName} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
attributes: { action: actionName, ...attributes },
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { createAuthPlugin } from "@agent-native/core/server";
|
||||
|
||||
const rawAppTitle = "App";
|
||||
const rawAppTitle = "Verse";
|
||||
const appTitle = rawAppTitle === "{" + "{APP_TITLE}}" ? "Chat" : rawAppTitle;
|
||||
|
||||
export default createAuthPlugin({
|
||||
marketing: {
|
||||
appName: appTitle,
|
||||
tagline:
|
||||
"Start from a chat-first agent-native app and add actions, screens, and workflows as you grow.",
|
||||
tagline: "Quotes drawn from poems — browse, search, and save your favorites.",
|
||||
features: [
|
||||
"Full-page chat with durable threads and tool call history",
|
||||
"Add actions once and use them from chat, UI, HTTP, MCP, A2A, and CLI",
|
||||
"Plug in your own agent runtime or build on the included app-agent loop",
|
||||
"Curated and community-submitted quotes from classic poems",
|
||||
"Search and filter by poet or theme, favorite the ones you love",
|
||||
"An agent chat that can look up, add, and organize quotes for you",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
ensureAdditiveColumns,
|
||||
getDbExec,
|
||||
runMigrations,
|
||||
isPostgres,
|
||||
} from "@agent-native/core/db";
|
||||
|
||||
import { db } from "../db/index.js";
|
||||
import * as schema from "../db/schema.js";
|
||||
import { SEED_POEMS } from "../db/seed-data.js";
|
||||
|
||||
function pk(): string {
|
||||
return isPostgres() ? "SERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY";
|
||||
}
|
||||
|
||||
function boolType(): string {
|
||||
return isPostgres() ? "BOOLEAN" : "INTEGER";
|
||||
}
|
||||
|
||||
function boolDefault(): string {
|
||||
return isPostgres() ? "false" : "0";
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Drizzle table exported from schema.ts. Filters out type-only and
|
||||
* helper exports the same way the framework's own templates do: a real
|
||||
* table carries a Symbol-keyed drizzle metadata bag, plain exports don't.
|
||||
*/
|
||||
function isDrizzleTable(value: unknown): value is object {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
Object.getOwnPropertySymbols(value).some((s) =>
|
||||
s.toString().includes("drizzle"),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const schemaTables = Object.values(schema).filter(isDrizzleTable);
|
||||
|
||||
const runVerseQuoteMigrations = runMigrations(
|
||||
[
|
||||
{
|
||||
version: 1,
|
||||
name: "verse-quote-app-poems-quotes-favorites",
|
||||
get sql() {
|
||||
return `
|
||||
CREATE TABLE IF NOT EXISTS poems (
|
||||
id ${pk()},
|
||||
author TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
source TEXT,
|
||||
year INTEGER,
|
||||
created_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS quotes (
|
||||
id ${pk()},
|
||||
poem_id INTEGER NOT NULL REFERENCES poems(id) ON DELETE CASCADE,
|
||||
text TEXT NOT NULL,
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
is_user_submitted ${boolType()} NOT NULL DEFAULT ${boolDefault()},
|
||||
created_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS favorites (
|
||||
id ${pk()},
|
||||
quote_id INTEGER NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||
visitor_id TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
UNIQUE (quote_id, visitor_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS verse_quote_quotes_poem_id_idx ON quotes(poem_id);
|
||||
CREATE INDEX IF NOT EXISTS verse_quote_favorites_quote_id_idx ON favorites(quote_id);
|
||||
CREATE INDEX IF NOT EXISTS verse_quote_favorites_visitor_id_idx ON favorites(visitor_id);
|
||||
`;
|
||||
},
|
||||
},
|
||||
],
|
||||
{ table: "verse_quote_migrations" },
|
||||
);
|
||||
|
||||
/**
|
||||
* Seeds the curated poem/quote set on first boot only. Runs after the table
|
||||
* migration so it works the same against any DATABASE_URL (Docker Postgres
|
||||
* here, but also Neon/Supabase/local SQLite) — the seed isn't tied to the
|
||||
* Docker container's initdb hook.
|
||||
*/
|
||||
async function seedIfEmpty(): Promise<void> {
|
||||
const existing = await db().select({ id: schema.poems.id }).from(schema.poems).limit(1);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
for (const poem of SEED_POEMS) {
|
||||
const inserted = await db()
|
||||
.insert(schema.poems)
|
||||
.values({
|
||||
author: poem.author,
|
||||
title: poem.title,
|
||||
source: poem.source,
|
||||
year: poem.year,
|
||||
})
|
||||
.returning({ id: schema.poems.id });
|
||||
|
||||
const poemId = inserted[0].id;
|
||||
for (const quote of poem.quotes) {
|
||||
await db().insert(schema.quotes).values({
|
||||
poem_id: poemId,
|
||||
text: quote.text,
|
||||
tags_json: JSON.stringify(quote.tags),
|
||||
is_user_submitted: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
console.log(`[db] Seeded ${SEED_POEMS.length} poems.`);
|
||||
}
|
||||
|
||||
export default async (nitroApp: any): Promise<void> => {
|
||||
await runVerseQuoteMigrations(nitroApp);
|
||||
try {
|
||||
const summary = await ensureAdditiveColumns({
|
||||
db: getDbExec(),
|
||||
tables: schemaTables,
|
||||
});
|
||||
if (summary.errors.length > 0) {
|
||||
console.warn(
|
||||
"[db] ensureAdditiveColumns completed with errors:",
|
||||
summary.errors,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[db] ensureAdditiveColumns failed (non-fatal):",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await seedIfEmpty();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[db] seedIfEmpty failed (non-fatal):",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user