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>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { UserProfile } from "@/components/auth/user-profile";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { useState, type ReactNode } from "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) => (
|
||||
@@ -110,6 +112,18 @@ type MaybePartsMessage = {
|
||||
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)
|
||||
@@ -126,11 +140,93 @@ function renderMessageContent(message: MaybePartsMessage): ReactNode {
|
||||
);
|
||||
}
|
||||
|
||||
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 } = useChat();
|
||||
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>;
|
||||
}
|
||||
@@ -145,37 +241,77 @@ export default function ChatPage() {
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Welcome, {session.user.name}!
|
||||
</span>
|
||||
<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">
|
||||
<div className="text-center text-muted-foreground py-12">
|
||||
Start a conversation with AI
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`p-3 rounded-lg ${
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground ml-auto max-w-[80%]"
|
||||
: "bg-muted max-w-[80%]"
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm font-medium mb-1">
|
||||
{message.role === "user" ? "You" : "AI"}
|
||||
{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>
|
||||
<div>{renderMessageContent(message as MaybePartsMessage)}</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{isStreaming && messages[messages.length - 1]?.role === "user" && (
|
||||
<ThinkingIndicator />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
@@ -193,12 +329,17 @@ export default function ChatPage() {
|
||||
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() || status === "streaming"}
|
||||
>
|
||||
Send
|
||||
<Button type="submit" disabled={!input.trim() || isStreaming}>
|
||||
{isStreaming ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending
|
||||
</>
|
||||
) : (
|
||||
"Send"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user