Files
agentic-coding-starter-kit/src/app/chat/page.tsx
Leon van Zyl a3a151c67a feat: comprehensive boilerplate improvements
Security & Stability:
- Add Next.js 16 proxy.ts for BetterAuth cookie-based auth protection
- Add rate limiting for API routes (src/lib/rate-limit.ts)
- Add Zod validation for chat API request bodies
- Add session auth check to chat and diagnostics endpoints
- Add security headers in next.config.ts (CSP, X-Frame-Options, etc.)
- Add file upload validation and sanitization in storage.ts

Core UX Components:
- Add error boundaries (error.tsx, not-found.tsx, chat/error.tsx)
- Add loading states (skeleton.tsx, spinner.tsx, loading.tsx files)
- Add toast notifications with Sonner
- Add form components (input.tsx, textarea.tsx, label.tsx)
- Add database indexes for performance (schema.ts)
- Enhance chat UX: timestamps, copy-to-clipboard, thinking indicator,
  error display, localStorage message persistence

Polish & Accessibility:
- Add Open Graph and Twitter card metadata
- Add JSON-LD structured data for SEO
- Add sitemap.ts, robots.ts, manifest.ts
- Add skip-to-content link and ARIA labels in site-header
- Enable profile page quick action buttons with dialogs
- Update Next.js 15 references to Next.js 16

Developer Experience:
- Add GitHub Actions CI workflow (lint, typecheck, build)
- Add Prettier configuration (.prettierrc, .prettierignore)
- Add .nvmrc pinning Node 20
- Add ESLint rules: import/order, react-hooks/exhaustive-deps
- Add stricter TypeScript settings (exactOptionalPropertyTypes,
  noImplicitOverride)
- Add interactive setup script (scripts/setup.ts)
- Add session utility functions (src/lib/session.ts)

All changes mirrored to create-agentic-app/template/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-30 14:46:15 +02:00

349 lines
10 KiB
TypeScript

"use client";
import { useState, useEffect, type ReactNode } from "react";
import { useChat } from "@ai-sdk/react";
import { Copy, Check, Loader2 } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import { UserProfile } from "@/components/auth/user-profile";
import { Button } from "@/components/ui/button";
import { useSession } from "@/lib/auth-client";
import type { Components } from "react-markdown";
const H1: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h1 className="mt-2 mb-3 text-2xl font-bold" {...props} />
);
const H2: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h2 className="mt-2 mb-2 text-xl font-semibold" {...props} />
);
const H3: React.FC<React.HTMLAttributes<HTMLHeadingElement>> = (props) => (
<h3 className="mt-2 mb-2 text-lg font-semibold" {...props} />
);
const Paragraph: React.FC<React.HTMLAttributes<HTMLParagraphElement>> = (
props
) => <p className="mb-3 leading-7 text-sm" {...props} />;
const UL: React.FC<React.HTMLAttributes<HTMLUListElement>> = (props) => (
<ul className="mb-3 ml-5 list-disc space-y-1 text-sm" {...props} />
);
const OL: React.FC<React.OlHTMLAttributes<HTMLOListElement>> = (props) => (
<ol className="mb-3 ml-5 list-decimal space-y-1 text-sm" {...props} />
);
const LI: React.FC<React.LiHTMLAttributes<HTMLLIElement>> = (props) => (
<li className="leading-6" {...props} />
);
const Anchor: React.FC<React.AnchorHTMLAttributes<HTMLAnchorElement>> = (
props
) => (
<a
className="underline underline-offset-2 text-primary hover:opacity-90"
target="_blank"
rel="noreferrer noopener"
{...props}
/>
);
const Blockquote: React.FC<React.BlockquoteHTMLAttributes<HTMLElement>> = (
props
) => (
<blockquote
className="mb-3 border-l-2 border-border pl-3 text-muted-foreground"
{...props}
/>
);
const Code: Components["code"] = ({ children, className, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
if (isInline) {
return (
<code className="rounded bg-muted px-1 py-0.5 text-xs" {...props}>
{children}
</code>
);
}
return (
<pre className="mb-3 w-full overflow-x-auto rounded-md bg-muted p-3">
<code className="text-xs leading-5" {...props}>
{children}
</code>
</pre>
);
};
const HR: React.FC<React.HTMLAttributes<HTMLHRElement>> = (props) => (
<hr className="my-4 border-border" {...props} />
);
const Table: React.FC<React.TableHTMLAttributes<HTMLTableElement>> = (
props
) => (
<div className="mb-3 overflow-x-auto">
<table className="w-full border-collapse text-sm" {...props} />
</div>
);
const TH: React.FC<React.ThHTMLAttributes<HTMLTableCellElement>> = (props) => (
<th
className="border border-border bg-muted px-2 py-1 text-left"
{...props}
/>
);
const TD: React.FC<React.TdHTMLAttributes<HTMLTableCellElement>> = (props) => (
<td className="border border-border px-2 py-1" {...props} />
);
const markdownComponents: Components = {
h1: H1,
h2: H2,
h3: H3,
p: Paragraph,
ul: UL,
ol: OL,
li: LI,
a: Anchor,
blockquote: Blockquote,
code: Code,
hr: HR,
table: Table,
th: TH,
td: TD,
};
type TextPart = { type?: string; text?: string };
type MaybePartsMessage = {
display?: ReactNode;
parts?: TextPart[];
content?: TextPart[];
};
function getMessageText(message: MaybePartsMessage): string {
const parts = Array.isArray(message.parts)
? message.parts
: Array.isArray(message.content)
? message.content
: [];
return parts
.filter((p) => p?.type === "text" && p.text)
.map((p) => p.text)
.join("\n");
}
function renderMessageContent(message: MaybePartsMessage): ReactNode {
if (message.display) return message.display;
const parts = Array.isArray(message.parts)
? message.parts
: Array.isArray(message.content)
? message.content
: [];
return parts.map((p, idx) =>
p?.type === "text" && p.text ? (
<ReactMarkdown key={idx} components={markdownComponents}>
{p.text}
</ReactMarkdown>
) : null
);
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(date);
}
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy");
}
};
return (
<button
onClick={handleCopy}
className="p-1 hover:bg-muted rounded transition-colors"
title="Copy to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-500" />
) : (
<Copy className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
);
}
function ThinkingIndicator() {
return (
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted max-w-[80%]">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm text-muted-foreground">AI is thinking...</span>
</div>
);
}
const STORAGE_KEY = "chat-messages";
export default function ChatPage() {
const { data: session, isPending } = useSession();
const { messages, sendMessage, status, error, setMessages } = useChat({
onError: (err) => {
toast.error(err.message || "Failed to send message");
},
});
const [input, setInput] = useState("");
// Load messages from localStorage on mount
useEffect(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed) && parsed.length > 0) {
setMessages(parsed);
}
} catch {
// Invalid JSON, ignore
}
}
}
}, [setMessages]);
// Save messages to localStorage when they change
useEffect(() => {
if (typeof window !== "undefined" && messages.length > 0) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
}
}, [messages]);
const clearMessages = () => {
setMessages([]);
localStorage.removeItem(STORAGE_KEY);
toast.success("Chat cleared");
};
if (isPending) {
return <div className="container mx-auto px-4 py-12">Loading...</div>;
}
if (!session) {
return (
<div className="container mx-auto px-4 py-12">
<div className="max-w-3xl mx-auto">
<UserProfile />
</div>
</div>
);
}
const isStreaming = status === "streaming";
return (
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6 pb-4 border-b">
<h1 className="text-2xl font-bold">AI Chat</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground">
Welcome, {session.user.name}!
</span>
{messages.length > 0 && (
<Button variant="ghost" size="sm" onClick={clearMessages}>
Clear chat
</Button>
)}
</div>
</div>
{error && (
<div className="mb-4 p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">
Error: {error.message || "Something went wrong"}
</p>
</div>
)}
<div className="min-h-[50vh] overflow-y-auto space-y-4 mb-4">
{messages.length === 0 && (
<div className="text-center text-muted-foreground py-12">
Start a conversation with AI
</div>
)}
{messages.map((message) => {
const messageText = getMessageText(message as MaybePartsMessage);
const createdAt = (message as { createdAt?: Date }).createdAt;
const timestamp = createdAt
? formatTimestamp(new Date(createdAt))
: null;
return (
<div
key={message.id}
className={`group p-3 rounded-lg ${
message.role === "user"
? "bg-primary text-primary-foreground ml-auto max-w-[80%]"
: "bg-muted max-w-[80%]"
}`}
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{message.role === "user" ? "You" : "AI"}
</span>
{timestamp && (
<span className="text-xs opacity-60">{timestamp}</span>
)}
</div>
{message.role === "assistant" && messageText && (
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
<CopyButton text={messageText} />
</div>
)}
</div>
<div>{renderMessageContent(message as MaybePartsMessage)}</div>
</div>
);
})}
{isStreaming && messages[messages.length - 1]?.role === "user" && (
<ThinkingIndicator />
)}
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const text = input.trim();
if (!text) return;
sendMessage({ role: "user", parts: [{ type: "text", text }] });
setInput("");
}}
className="flex gap-2"
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type your message..."
className="flex-1 p-2 border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-ring"
disabled={isStreaming}
/>
<Button type="submit" disabled={!input.trim() || isStreaming}>
{isStreaming ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sending
</>
) : (
"Send"
)}
</Button>
</form>
</div>
</div>
);
}