← back to Norma
components/ChatPanel.tsx
464 lines
'use client';
import { useState, useRef, useEffect, useCallback, type KeyboardEvent } from 'react';
import { MessageSquare, Sparkles, X, Send } from 'lucide-react';
/* ─── Types ─────────────────────────────────────────────────────────────── */
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
created_at: string;
}
/* ─── Relative time helper ──────────────────────────────────────────────── */
function relativeTime(dateStr: string): string {
const now = Date.now();
const then = new Date(dateStr).getTime();
const diffMs = now - then;
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 10) return 'just now';
if (diffSec < 60) return `${diffSec}s ago`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m ago`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h ago`;
const diffDay = Math.floor(diffHr / 24);
return `${diffDay}d ago`;
}
/* ─── ChatPanel component ───────────────────────────────────────────────── */
export default function ChatPanel() {
const [expanded, setExpanded] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [historyLoaded, setHistoryLoaded] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
/* ── Auto-scroll to bottom ────────────────────────────────────────────── */
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, loading, scrollToBottom]);
/* ── Load chat history on first expand ────────────────────────────────── */
useEffect(() => {
if (expanded && !historyLoaded) {
(async () => {
try {
const res = await fetch('/api/ai-chat');
if (res.ok) {
const data = await res.json();
setMessages(data.messages || []);
}
} catch (err) {
console.error('[ChatPanel] Failed to load history:', err);
} finally {
setHistoryLoaded(true);
}
})();
}
}, [expanded, historyLoaded]);
/* ── Focus textarea when expanded ─────────────────────────────────────── */
useEffect(() => {
if (expanded && historyLoaded) {
setTimeout(() => textareaRef.current?.focus(), 100);
}
}, [expanded, historyLoaded]);
/* ── Escape key closes panel ──────────────────────────────────────────── */
useEffect(() => {
const handleEsc = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape' && expanded) setExpanded(false);
};
window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [expanded]);
/* ── Send message ─────────────────────────────────────────────────────── */
async function sendMessage() {
const text = input.trim();
if (!text || loading) return;
const userMsg: ChatMessage = {
id: `tmp-user-${Date.now()}`,
role: 'user',
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setLoading(true);
// Reset textarea height
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
try {
const res = await fetch('/api/ai-chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text }),
});
const data = await res.json();
if (res.ok && data.reply) {
const aiMsg: ChatMessage = {
id: `tmp-ai-${Date.now()}`,
role: 'assistant',
content: data.reply,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, aiMsg]);
} else {
const errMsg: ChatMessage = {
id: `tmp-err-${Date.now()}`,
role: 'assistant',
content: data.error || 'Something went wrong. Please try again.',
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, errMsg]);
}
} catch (err) {
const errMsg: ChatMessage = {
id: `tmp-err-${Date.now()}`,
role: 'assistant',
content: 'Network error. Please check your connection.',
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, errMsg]);
} finally {
setLoading(false);
setTimeout(() => textareaRef.current?.focus(), 50);
}
}
/* ── Keyboard handling for textarea ───────────────────────────────────── */
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
/* ── Auto-resize textarea ─────────────────────────────────────────────── */
function handleTextareaChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
setInput(e.target.value);
const ta = e.target;
ta.style.height = 'auto';
ta.style.height = Math.min(ta.scrollHeight, 96) + 'px'; // max ~4 lines
}
/* ═══ Collapsed: fab button ═══════════════════════════════════════════── */
if (!expanded) {
return (
<button
onClick={() => setExpanded(true)}
aria-label="Open Norma Assistant"
style={{
position: 'fixed',
left: 24,
bottom: 24,
zIndex: 50,
width: 48,
height: 48,
borderRadius: '50%',
background: 'linear-gradient(135deg, #34d399, #10b981)',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 4px 16px rgba(16,185,129,0.4)',
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
animation: 'chat-fab-pulse 2s ease-in-out 1',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.08)';
e.currentTarget.style.boxShadow = '0 6px 24px rgba(16,185,129,0.55)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(16,185,129,0.4)';
}}
>
<MessageSquare size={22} color="#fff" />
{/* Pulse keyframe (injected once) */}
<style>{`
@keyframes chat-fab-pulse {
0%, 100% { box-shadow: 0 4px 16px rgba(16,185,129,0.4); }
50% { box-shadow: 0 4px 28px rgba(16,185,129,0.7); }
}
@keyframes chat-dots {
0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-4px); }
}
`}</style>
</button>
);
}
/* ═══ Expanded: chat panel ════════════════════════════════════════════── */
return (
<div
style={{
position: 'fixed',
left: 24,
bottom: 24,
zIndex: 50,
width: 380,
minHeight: 500,
maxHeight: '70vh',
display: 'flex',
flexDirection: 'column',
borderRadius: 16,
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
boxShadow: '0 12px 40px rgba(0,0,0,0.5)',
overflow: 'hidden',
}}
>
{/* ── Header ────────────────────────────────────────────────────────── */}
<div
style={{
background: 'linear-gradient(135deg, #34d399, #10b981)',
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<div className="flex items-center gap-2">
<Sparkles size={16} color="#fff" />
<span style={{ color: '#fff', fontWeight: 600, fontSize: '0.875rem' }}>
Norma Assistant
</span>
</div>
<button
onClick={() => setExpanded(false)}
aria-label="Close chat"
style={{
background: 'rgba(255,255,255,0.15)',
border: 'none',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 28,
height: 28,
transition: 'background 0.15s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.25)'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.15)'; }}
>
<X size={16} color="#fff" />
</button>
</div>
{/* ── Messages area ─────────────────────────────────────────────────── */}
<div
className="flex-1"
style={{
overflowY: 'auto',
padding: '12px 14px',
display: 'flex',
flexDirection: 'column',
gap: 10,
}}
>
{messages.length === 0 && !loading && (
<div
style={{
textAlign: 'center',
padding: '40px 16px',
color: 'var(--color-text-muted)',
fontSize: '0.8125rem',
lineHeight: 1.5,
}}
>
<Sparkles size={28} style={{ margin: '0 auto 12px', opacity: 0.5 }} />
<div style={{ fontWeight: 600, color: 'var(--color-text-secondary)', marginBottom: 4 }}>
Norma Assistant
</div>
Ask me about drafts, grants, partners, news, or anything civic action related.
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
maxWidth: '100%',
}}
>
<div
style={{
maxWidth: '85%',
padding: '10px 14px',
fontSize: '0.8125rem',
lineHeight: 1.55,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
...(msg.role === 'user'
? {
backgroundColor: '#10b981',
color: '#fff',
borderRadius: '16px 16px 4px 16px',
}
: {
backgroundColor: 'var(--color-surface-el)',
color: 'var(--color-text)',
borderRadius: '16px 16px 16px 4px',
}),
}}
>
{msg.content}
</div>
<span
style={{
fontSize: '0.625rem',
color: 'var(--color-text-muted)',
marginTop: 3,
paddingLeft: msg.role === 'assistant' ? 4 : 0,
paddingRight: msg.role === 'user' ? 4 : 0,
}}
>
{relativeTime(msg.created_at)}
</span>
</div>
))}
{/* Thinking dots */}
{loading && (
<div style={{ display: 'flex', alignItems: 'flex-start' }}>
<div
style={{
backgroundColor: 'var(--color-surface-el)',
borderRadius: '16px 16px 16px 4px',
padding: '12px 18px',
display: 'flex',
gap: 5,
alignItems: 'center',
}}
>
{[0, 1, 2].map((i) => (
<span
key={i}
style={{
width: 7,
height: 7,
borderRadius: '50%',
backgroundColor: 'var(--color-text-muted)',
display: 'inline-block',
animation: `chat-dots 1.4s ease-in-out ${i * 0.16}s infinite`,
}}
/>
))}
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* ── Input area ────────────────────────────────────────────────────── */}
<div
style={{
borderTop: '1px solid var(--color-border)',
padding: '10px 12px',
display: 'flex',
gap: 8,
alignItems: 'flex-end',
flexShrink: 0,
backgroundColor: 'var(--color-surface)',
}}
>
<textarea
ref={textareaRef}
value={input}
onChange={handleTextareaChange}
onKeyDown={handleKeyDown}
placeholder="Ask about civic action data..."
aria-label="Chat message"
disabled={loading}
rows={1}
style={{
flex: 1,
resize: 'none',
border: '1px solid var(--color-border)',
borderRadius: 10,
padding: '8px 12px',
fontSize: '0.8125rem',
lineHeight: 1.4,
backgroundColor: 'var(--color-surface-el)',
color: 'var(--color-text)',
outline: 'none',
overflow: 'hidden',
transition: 'border-color 0.15s',
maxHeight: 96,
}}
onFocus={(e) => { e.currentTarget.style.borderColor = '#10b981'; }}
onBlur={(e) => { e.currentTarget.style.borderColor = 'var(--color-border)'; }}
/>
<button
onClick={sendMessage}
disabled={loading || !input.trim()}
aria-label="Send message"
style={{
width: 36,
height: 36,
borderRadius: 10,
border: 'none',
cursor: loading || !input.trim() ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: loading || !input.trim() ? 'var(--color-surface-el)' : '#10b981',
transition: 'background-color 0.15s',
flexShrink: 0,
}}
onMouseEnter={(e) => {
if (!loading && input.trim()) {
e.currentTarget.style.backgroundColor = '#059669';
}
}}
onMouseLeave={(e) => {
if (!loading && input.trim()) {
e.currentTarget.style.backgroundColor = '#10b981';
}
}}
>
<Send
size={16}
color={loading || !input.trim() ? 'var(--color-text-muted)' : '#fff'}
/>
</button>
</div>
{/* Inject animation keyframes */}
<style>{`
@keyframes chat-dots {
0%, 80%, 100% { opacity: 0.3; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-4px); }
}
`}</style>
</div>
);
}