fix(ai): proper chat layout, mobile sidebar, empty states, loading dots
- h-screen with overflow-hidden so messages scroll within viewport - Sidebar is slide-over on mobile (fixed + overlay) with translate animation - Sidebar defaults closed; auto-selects first session on load - Empty state when no session selected with "New Chat" CTA - Typing indicator with animated bouncing dots - Chat bubbles properly aligned (user right, AI left) with rounded corners - Delete confirm moved to its own modal (not nested in session list item) - Removed stale debug console.log - Dark mode fixes: border, hover states, disabled button Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
70aa35384b
commit
299e878e38
1 changed files with 120 additions and 56 deletions
|
|
@ -24,12 +24,11 @@ export default function AIChatPage() {
|
||||||
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
const [currentSessionId, setCurrentSessionId] = useState<string>("");
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [error, setError] = useState<string>("");
|
const [error, setError] = useState<string>("");
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("AI page: childId from useFamily:", childId);
|
|
||||||
if (childId) {
|
if (childId) {
|
||||||
fetchSessions();
|
fetchSessions();
|
||||||
}
|
}
|
||||||
|
|
@ -42,11 +41,14 @@ export default function AIChatPage() {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
setError(data.error);
|
setError(data.error);
|
||||||
console.error("API error:", data.error);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSessions(data.sessions || []);
|
const fetched = data.sessions || [];
|
||||||
|
setSessions(fetched);
|
||||||
setError("");
|
setError("");
|
||||||
|
if (fetched.length > 0 && !currentSessionId) {
|
||||||
|
setCurrentSessionId(fetched[0].id);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch:", err);
|
console.error("Failed to fetch:", err);
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +65,7 @@ export default function AIChatPage() {
|
||||||
if (data.session) {
|
if (data.session) {
|
||||||
setSessions([data.session, ...sessions]);
|
setSessions([data.session, ...sessions]);
|
||||||
setCurrentSessionId(data.session.id);
|
setCurrentSessionId(data.session.id);
|
||||||
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to create:", err);
|
console.error("Failed to create:", err);
|
||||||
|
|
@ -78,7 +81,6 @@ export default function AIChatPage() {
|
||||||
const userContent = input.trim();
|
const userContent = input.trim();
|
||||||
setInput("");
|
setInput("");
|
||||||
|
|
||||||
// Show user message immediately in UI
|
|
||||||
const tempUserMsg = { id: "temp-" + Date.now(), role: "user" as const, content: userContent, createdAt: new Date().toISOString() };
|
const tempUserMsg = { id: "temp-" + Date.now(), role: "user" as const, content: userContent, createdAt: new Date().toISOString() };
|
||||||
if (currentSessionId) {
|
if (currentSessionId) {
|
||||||
const sessionIdx = sessions.findIndex(s => s.id === currentSessionId);
|
const sessionIdx = sessions.findIndex(s => s.id === currentSessionId);
|
||||||
|
|
@ -89,7 +91,6 @@ export default function AIChatPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-create session if none selected
|
|
||||||
let sessionId = currentSessionId;
|
let sessionId = currentSessionId;
|
||||||
if (!sessionId && childId) {
|
if (!sessionId && childId) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -118,14 +119,12 @@ export default function AIChatPage() {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save user message
|
|
||||||
await fetch("/api/chat", {
|
await fetch("/api/chat", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ sessionId, role: "user", content: userContent }),
|
body: JSON.stringify({ sessionId, role: "user", content: userContent }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get AI response
|
|
||||||
const aiRes = await fetch("/api/ai", {
|
const aiRes = await fetch("/api/ai", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -133,7 +132,6 @@ export default function AIChatPage() {
|
||||||
});
|
});
|
||||||
const aiData = await aiRes.json();
|
const aiData = await aiRes.json();
|
||||||
|
|
||||||
// Save AI response
|
|
||||||
if (aiData.reply) {
|
if (aiData.reply) {
|
||||||
await fetch("/api/chat", {
|
await fetch("/api/chat", {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|
@ -142,7 +140,6 @@ export default function AIChatPage() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh sessions
|
|
||||||
fetchSessions();
|
fetchSessions();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to send:", err);
|
console.error("Failed to send:", err);
|
||||||
|
|
@ -153,59 +150,63 @@ export default function AIChatPage() {
|
||||||
const deleteSession = async (id: string) => {
|
const deleteSession = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/chat?id=${id}`, { method: "DELETE" });
|
await fetch(`/api/chat?id=${id}`, { method: "DELETE" });
|
||||||
setSessions(sessions.filter(s => s.id !== id));
|
const remaining = sessions.filter(s => s.id !== id);
|
||||||
|
setSessions(remaining);
|
||||||
setDeleteConfirm(null);
|
setDeleteConfirm(null);
|
||||||
|
if (currentSessionId === id) {
|
||||||
|
setCurrentSessionId(remaining[0]?.id || "");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete:", err);
|
console.error("Failed to delete:", err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800">
|
<div className="flex h-screen bg-gradient-to-br from-rose-50 to-amber-50 dark:from-gray-900 dark:to-gray-800 overflow-hidden">
|
||||||
{error && (
|
|
||||||
<div className="p-4 bg-red-100 dark:bg-red-900 text-red-700 dark:text-red-200">
|
{/* Sidebar overlay on mobile */}
|
||||||
Error: {error}
|
{sidebarOpen && (
|
||||||
</div>
|
<div className="fixed inset-0 bg-black/40 z-20 md:hidden" onClick={() => setSidebarOpen(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Sidebar - sessions list on left */}
|
{/* Sidebar */}
|
||||||
<div className={`${sidebarOpen ? "w-64" : "w-0"} overflow-hidden transition-all`}>
|
<div className={`
|
||||||
<div className="w-64 p-4 border-r bg-white dark:bg-gray-800 h-full overflow-y-auto">
|
fixed md:relative z-30 md:z-auto h-full
|
||||||
<div className="flex justify-between items-center mb-4">
|
${sidebarOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"}
|
||||||
|
${sidebarOpen ? "md:w-64" : "md:w-0"}
|
||||||
|
w-64 transition-all duration-200 overflow-hidden flex-shrink-0
|
||||||
|
`}>
|
||||||
|
<div className="w-64 h-full flex flex-col border-r bg-white dark:bg-gray-800">
|
||||||
|
<div className="p-4 flex justify-between items-center border-b dark:border-gray-700">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<a href="/menu" className="text-rose-500 dark:text-rose-400">←</a>
|
<a href="/menu" className="text-rose-500 dark:text-rose-400">←</a>
|
||||||
<h1 className="font-bold dark:text-white">Chats</h1>
|
<h1 className="font-bold dark:text-white">Chats</h1>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={createNewSession} className="text-rose-400">+</button>
|
<button onClick={createNewSession} className="w-8 h-8 flex items-center justify-center bg-rose-400 text-white rounded-full text-lg leading-none">+</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||||
{(sessions || []).map(session => (
|
{sessions.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500 text-center mt-8">No chats yet</p>
|
||||||
|
) : sessions.map(session => (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
key={session.id}
|
||||||
onClick={() => setCurrentSessionId(session.id)}
|
onClick={() => { setCurrentSessionId(session.id); setSidebarOpen(false); }}
|
||||||
className={`p-2 rounded-lg cursor-pointer flex justify-between items-center dark:text-gray-100 ${session.id === currentSessionId ? "bg-rose-100 dark:bg-rose-900" : ""}`}
|
className={`p-3 rounded-lg cursor-pointer flex justify-between items-center group transition-colors
|
||||||
|
${session.id === currentSessionId
|
||||||
|
? "bg-rose-100 dark:bg-rose-900/50"
|
||||||
|
: "hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium truncate">{session.title}</div>
|
<div className="text-sm font-medium truncate dark:text-gray-100">{session.title}</div>
|
||||||
<div className="text-xs text-gray-400">{new Date(session.updatedAt).toLocaleDateString()}</div>
|
<div className="text-xs text-gray-400 dark:text-gray-500">{new Date(session.updatedAt).toLocaleDateString()}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(session.id); }}
|
onClick={(e) => { e.stopPropagation(); setDeleteConfirm(session.id); }}
|
||||||
className="text-red-400 text-xs p-1"
|
className="text-gray-300 dark:text-gray-600 group-hover:text-red-400 dark:group-hover:text-red-400 text-xs p-1 ml-1 flex-shrink-0 transition-colors"
|
||||||
>
|
>
|
||||||
🗑️
|
🗑️
|
||||||
</button>
|
</button>
|
||||||
{deleteConfirm === session.id && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white dark:bg-gray-800 p-4 rounded-lg">
|
|
||||||
<p className="mb-4 dark:text-white">Delete this conversation?</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={() => deleteSession(session.id)} className="bg-red-500 text-white px-3 py-1 rounded">Delete</button>
|
|
||||||
<button onClick={() => setDeleteConfirm(null)} className="bg-gray-200 dark:bg-gray-600 px-3 py-1 rounded">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -213,39 +214,102 @@ export default function AIChatPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main chat */}
|
{/* Main chat */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
<div className="p-4 flex items-center justify-between border-b bg-white dark:bg-gray-800">
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="p-4 flex items-center justify-between border-b bg-white dark:bg-gray-800 flex-shrink-0">
|
||||||
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="text-rose-500 dark:text-rose-400">☰</button>
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-medium dark:text-white">{currentSession?.title || "AI Chat"}</span>
|
<button onClick={() => setSidebarOpen(!sidebarOpen)} className="text-rose-500 dark:text-rose-400 text-xl">☰</button>
|
||||||
|
<span className="font-medium dark:text-white truncate">{currentSession?.title || "AI Chat"}</span>
|
||||||
</div>
|
</div>
|
||||||
<a href="/menu" className="text-rose-500 dark:text-rose-400">←</a>
|
<a href="/menu" className="text-rose-500 dark:text-rose-400 text-sm flex-shrink-0">← Home</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
{error && (
|
||||||
{currentSession?.messages.map((msg, i) => (
|
<div className="px-4 py-2 bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-300 text-sm flex-shrink-0">
|
||||||
<div key={i} className={`${msg.role === "user" ? "ml-auto bg-rose-100 dark:bg-rose-900" : "mr-auto bg-gray-100 dark:bg-gray-800 dark:text-gray-100"} p-3 rounded-lg max-w-[80%] whitespace-pre-wrap`}>
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||||
|
{!currentSession ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center gap-4">
|
||||||
|
<div className="text-4xl">🤱</div>
|
||||||
|
<p className="text-gray-500 dark:text-gray-400 font-medium">Ask anything about your baby</p>
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">Tap ☰ to see past chats, or just type below to start</p>
|
||||||
|
<button onClick={createNewSession} className="mt-2 px-5 py-2 bg-rose-400 text-white rounded-full text-sm">
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : currentSession.messages.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center gap-2">
|
||||||
|
<p className="text-gray-400 dark:text-gray-500 text-sm">Type a question below to get started</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
currentSession.messages.map((msg, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||||
|
>
|
||||||
|
<div className={`p-3 rounded-2xl max-w-[80%] text-sm whitespace-pre-wrap leading-relaxed
|
||||||
|
${msg.role === "user"
|
||||||
|
? "bg-rose-400 text-white rounded-br-sm"
|
||||||
|
: "bg-white dark:bg-gray-800 dark:text-gray-100 shadow-sm rounded-bl-sm"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{msg.content}
|
{msg.content}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-3 rounded-2xl rounded-bl-sm shadow-sm">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<span className="w-2 h-2 bg-gray-300 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||||
|
<span className="w-2 h-2 bg-gray-300 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||||
|
<span className="w-2 h-2 bg-gray-300 dark:bg-gray-500 rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 border-t bg-white dark:bg-gray-800">
|
{/* Input */}
|
||||||
|
<div className="p-4 border-t bg-white dark:bg-gray-800 flex-shrink-0">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
onKeyDown={e => e.key === "Enter" && handleSend()}
|
onKeyDown={e => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||||
placeholder="Ask about your baby..."
|
placeholder="Ask about your baby..."
|
||||||
className="flex-1 p-3 border rounded-lg dark:bg-gray-700 dark:text-white"
|
className="flex-1 p-3 border dark:border-gray-600 rounded-xl dark:bg-gray-700 dark:text-white dark:placeholder-gray-400 text-sm focus:outline-none focus:ring-2 focus:ring-rose-300"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<button onClick={handleSend} disabled={loading || !input.trim()} className={`px-4 py-2 text-white rounded-lg ${loading || !input.trim() ? "bg-gray-300 cursor-not-allowed" : "bg-rose-400"}`}>
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={loading || !input.trim()}
|
||||||
|
className={`px-4 py-2 text-white rounded-xl text-sm font-medium transition-colors ${loading || !input.trim() ? "bg-gray-200 dark:bg-gray-600 text-gray-400 cursor-not-allowed" : "bg-rose-400 hover:bg-rose-500"}`}
|
||||||
|
>
|
||||||
{loading ? "..." : "Send"}
|
{loading ? "..." : "Send"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Delete confirm modal */}
|
||||||
|
{deleteConfirm && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 p-6 rounded-2xl w-72 shadow-xl">
|
||||||
|
<p className="mb-4 dark:text-white font-medium">Delete this conversation?</p>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-5">This can't be undone.</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => deleteSession(deleteConfirm)} className="flex-1 py-2 bg-red-500 text-white rounded-xl text-sm">Delete</button>
|
||||||
|
<button onClick={() => setDeleteConfirm(null)} className="flex-1 py-2 bg-gray-100 dark:bg-gray-700 dark:text-white rounded-xl text-sm">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loading…
Add table
Reference in a new issue