Next.js app — chat interface, auth, settings, PWA manifest
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Message } from '@/lib/api';
|
||||
|
||||
export default function MessageInput() {
|
||||
const { currentId, setCurrentId, addMessage, setIsLoading, isLoading, setConversations } = useStore();
|
||||
const [text, setText] = useState('');
|
||||
const [recording, setRecording] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, [currentId]);
|
||||
|
||||
function autoResize() {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const message = text.trim();
|
||||
if (!message || isLoading) return;
|
||||
setText('');
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto';
|
||||
|
||||
let convId = currentId;
|
||||
if (!convId) {
|
||||
const conv = await api.newConversation();
|
||||
convId = conv.id;
|
||||
setCurrentId(convId);
|
||||
}
|
||||
|
||||
const userMsg: Message = {
|
||||
role: 'user',
|
||||
content: message,
|
||||
sources: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
addMessage(userMsg);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const data = await api.sendMessage(message, convId);
|
||||
setCurrentId(data.conversation_id);
|
||||
const assistantMsg: Message = {
|
||||
role: 'assistant',
|
||||
content: data.response,
|
||||
sources: data.sources || [],
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
addMessage(assistantMsg);
|
||||
const updated = await api.getConversations();
|
||||
setConversations(updated);
|
||||
} catch (e) {
|
||||
addMessage({
|
||||
role: 'assistant',
|
||||
content: 'Error — please try again.',
|
||||
sources: [],
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mr = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
mr.ondataavailable = e => chunksRef.current.push(e.data);
|
||||
mr.onstop = async () => {
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
try {
|
||||
const { text: transcript } = await api.transcribe(blob);
|
||||
setText(prev => prev ? prev + ' ' + transcript : transcript);
|
||||
textareaRef.current?.focus();
|
||||
} catch {
|
||||
console.error('Transcription failed');
|
||||
}
|
||||
};
|
||||
mr.start();
|
||||
mediaRecorderRef.current = mr;
|
||||
setRecording(true);
|
||||
} catch {
|
||||
alert('Microphone access denied');
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
mediaRecorderRef.current?.stop();
|
||||
setRecording(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex gap-2 items-end px-4 py-3 flex-shrink-0"
|
||||
style={{ borderTop: '1px solid var(--border)' }}
|
||||
>
|
||||
{/* Voice button */}
|
||||
<button
|
||||
onMouseDown={startRecording}
|
||||
onMouseUp={stopRecording}
|
||||
onTouchStart={startRecording}
|
||||
onTouchEnd={stopRecording}
|
||||
className="flex-shrink-0 rounded-lg flex items-center justify-center transition-all"
|
||||
style={{
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
background: recording ? 'var(--accent)' : 'var(--bg3)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: recording ? '#e8f5ed' : 'var(--text3)',
|
||||
}}
|
||||
aria-label={recording ? 'Recording...' : 'Hold to record'}
|
||||
title="Hold to record voice"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 1a4 4 0 0 1 4 4v6a4 4 0 0 1-8 0V5a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v6a2 2 0 0 0 4 0V5a2 2 0 0 0-2-2zm-7 8a7 7 0 0 0 14 0h2a9 9 0 0 1-8 8.94V22h-2v-2.06A9 9 0 0 1 3 11h2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Text input */}
|
||||
<div
|
||||
className="flex-1 rounded-xl"
|
||||
style={{ background: 'var(--bg2)', border: '1px solid var(--border2)' }}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={e => { setText(e.target.value); autoResize(); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask anything... (Shift+Enter for new line)"
|
||||
rows={1}
|
||||
className="w-full block resize-none outline-none bg-transparent px-3 py-3 leading-relaxed"
|
||||
style={{
|
||||
fontSize: 'var(--font-size)',
|
||||
color: 'var(--text)',
|
||||
minHeight: '44px',
|
||||
maxHeight: '160px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Send button */}
|
||||
<button
|
||||
onClick={send}
|
||||
disabled={isLoading || !text.trim()}
|
||||
className="flex-shrink-0 rounded-lg px-4 py-3 text-sm font-medium transition-opacity"
|
||||
style={{
|
||||
background: 'var(--accent)',
|
||||
color: '#e8f5ed',
|
||||
border: 'none',
|
||||
cursor: isLoading || !text.trim() ? 'not-allowed' : 'pointer',
|
||||
opacity: isLoading || !text.trim() ? 0.4 : 1,
|
||||
minHeight: '44px',
|
||||
fontFamily: 'var(--font-sans)',
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { renderMarkdown } from '@/lib/markdown';
|
||||
|
||||
export default function MessageList() {
|
||||
const { messages, isLoading } = useStore();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isLoading]);
|
||||
|
||||
if (!messages.length && !isLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center px-8">
|
||||
<h2 className="text-lg font-medium mb-2" style={{ color: 'var(--text2)' }}>
|
||||
What are you working on?
|
||||
</h2>
|
||||
<p className="text-sm leading-relaxed max-w-sm" style={{ color: 'var(--text3)' }}>
|
||||
Ask about your documents, projects, research, or anything else.
|
||||
Your entire corpus is available.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 flex flex-col gap-5">
|
||||
{messages.map((m, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex flex-col max-w-3xl w-full ${
|
||||
m.role === 'user' ? 'self-end items-end max-w-xl' : 'self-start items-start'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="text-xs mb-1"
|
||||
style={{ color: 'var(--text3)', letterSpacing: '0.03em' }}
|
||||
>
|
||||
{m.role === 'user' ? 'you' : 'aaron ai'}
|
||||
</div>
|
||||
<div
|
||||
className="px-4 py-3 leading-relaxed"
|
||||
style={{
|
||||
background: m.role === 'user' ? 'var(--user-bg)' : 'var(--accent-light)',
|
||||
color: m.role === 'user' ? 'var(--text)' : 'var(--accent-text)',
|
||||
border: m.role === 'assistant' ? '1px solid var(--accent-border)' : 'none',
|
||||
borderRadius: m.role === 'user' ? '12px 12px 3px 12px' : '12px 12px 12px 3px',
|
||||
fontSize: 'var(--font-size)',
|
||||
}}
|
||||
>
|
||||
{m.role === 'assistant' ? (
|
||||
<div
|
||||
className="prose"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(m.content) }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ whiteSpace: 'pre-wrap' }}>{m.content}</span>
|
||||
)}
|
||||
</div>
|
||||
{m.sources && m.sources.length > 0 && (
|
||||
<div className="text-xs mt-1.5 italic" style={{ color: 'var(--text3)' }}>
|
||||
Sources: {[...new Set(m.sources)].join(', ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="self-start flex flex-col items-start max-w-3xl">
|
||||
<div className="text-xs mb-1" style={{ color: 'var(--text3)' }}>aaron ai</div>
|
||||
<div
|
||||
className="px-4 py-3 rounded-xl text-sm italic"
|
||||
style={{
|
||||
background: 'var(--accent-light)',
|
||||
color: 'var(--text3)',
|
||||
border: '1px solid var(--accent-border)',
|
||||
animation: 'pulse 1.5s infinite',
|
||||
}}
|
||||
>
|
||||
Thinking...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
|
||||
<style jsx>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useStore } from '@/lib/store';
|
||||
import { api } from '@/lib/api';
|
||||
import type { Status } from '@/lib/api';
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const { settingsOpen, setSettingsOpen, settings, setSettings } = useStore();
|
||||
const [status, setStatus] = useState<Status | null>(null);
|
||||
const [memory, setMemory] = useState('');
|
||||
const [editingMemory, setEditingMemory] = useState(false);
|
||||
const [reindexing, setReindexing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) return;
|
||||
api.getStatus().then(setStatus).catch(console.error);
|
||||
api.getMemory().then(d => setMemory(d.content)).catch(console.error);
|
||||
}, [settingsOpen]);
|
||||
|
||||
async function updateSetting(key: string, value: unknown) {
|
||||
const updated = { ...settings, [key]: value };
|
||||
setSettings(updated);
|
||||
await api.updateSettings(updated);
|
||||
}
|
||||
|
||||
async function saveMemory() {
|
||||
await api.updateMemory(memory);
|
||||
setEditingMemory(false);
|
||||
}
|
||||
|
||||
async function triggerReindex() {
|
||||
setReindexing(true);
|
||||
await api.reindex();
|
||||
setTimeout(() => setReindexing(false), 3000);
|
||||
}
|
||||
|
||||
async function exportConversation() {
|
||||
const messages = useStore.getState().messages;
|
||||
if (!messages.length) { alert('No messages to export.'); return; }
|
||||
let md = `# Conversation Export\n\nExported: ${new Date().toLocaleString()}\n\n---\n\n`;
|
||||
messages.forEach(m => {
|
||||
md += `**${m.role === 'user' ? 'You' : 'Aaron AI'}**\n\n${m.content}\n\n`;
|
||||
if (m.sources?.length) md += `*Sources: ${m.sources.join(', ')}*\n\n`;
|
||||
md += '---\n\n';
|
||||
});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([md], { type: 'text/markdown' }));
|
||||
a.download = `conversation-${Date.now()}.md`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
if (!confirm('Delete all conversations permanently?')) return;
|
||||
await api.clearAllConversations();
|
||||
useStore.getState().setConversations([]);
|
||||
useStore.getState().setCurrentId(null);
|
||||
useStore.getState().setMessages([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop on mobile */}
|
||||
{settingsOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/40 md:hidden"
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`fixed top-0 right-0 bottom-0 z-50 flex flex-col transition-transform duration-200 ${
|
||||
settingsOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
style={{
|
||||
width: 'min(340px, 100vw)',
|
||||
background: 'var(--bg)',
|
||||
borderLeft: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-4 flex-shrink-0"
|
||||
style={{ borderBottom: '1px solid var(--border)' }}
|
||||
>
|
||||
<h2 className="text-sm font-medium" style={{ color: 'var(--text)' }}>Settings</h2>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
style={{ background: 'none', border: 'none', color: 'var(--text3)', fontSize: '20px', cursor: 'pointer', padding: '2px 6px', lineHeight: 1 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-6">
|
||||
|
||||
{/* Appearance */}
|
||||
<Section title="Appearance">
|
||||
<Row label="Dark mode" desc="Switch between light and dark theme">
|
||||
<Toggle
|
||||
on={settings.theme === 'dark'}
|
||||
onChange={v => updateSetting('theme', v ? 'dark' : 'light')}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Font size">
|
||||
<select
|
||||
value={settings.font_size}
|
||||
onChange={e => updateSetting('font_size', e.target.value)}
|
||||
className="rounded-md px-2 py-1 text-sm"
|
||||
style={{ background: 'var(--bg3)', border: '1px solid var(--border2)', color: 'var(--text)', fontFamily: 'var(--font-sans)' }}
|
||||
>
|
||||
<option value="small">Small (13px)</option>
|
||||
<option value="medium">Medium (15px)</option>
|
||||
<option value="large">Large (17px)</option>
|
||||
</select>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Corpus */}
|
||||
<Section title="Corpus">
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<StatCard number={(status?.chunk_count || 0).toLocaleString()} label="total chunks" />
|
||||
<StatCard number={(status?.file_count || 0).toLocaleString()} label="files indexed" />
|
||||
</div>
|
||||
<Row label="Last indexed" desc={status?.last_indexed || 'Unknown'}>
|
||||
<SBtn primary onClick={triggerReindex} disabled={reindexing}>
|
||||
{reindexing ? 'Starting...' : 'Re-index'}
|
||||
</SBtn>
|
||||
</Row>
|
||||
<Row label="Web search" desc="Search the web for current information">
|
||||
<Toggle on={settings.web_search} onChange={v => updateSetting('web_search', v)} />
|
||||
</Row>
|
||||
<Row label="Show sources" desc="Display document sources under responses">
|
||||
<Toggle on={settings.show_sources} onChange={v => updateSetting('show_sources', v)} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Memory */}
|
||||
<Section title="Memory">
|
||||
{editingMemory ? (
|
||||
<>
|
||||
<textarea
|
||||
value={memory}
|
||||
onChange={e => setMemory(e.target.value)}
|
||||
className="w-full rounded-lg px-3 py-2 text-xs leading-relaxed resize-y mb-2"
|
||||
style={{
|
||||
background: 'var(--bg3)',
|
||||
border: '1px solid var(--border2)',
|
||||
color: 'var(--text)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
minHeight: '140px',
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<SBtn primary onClick={saveMemory}>Save</SBtn>
|
||||
<SBtn onClick={() => setEditingMemory(false)}>Cancel</SBtn>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="rounded-lg px-3 py-2 text-xs leading-relaxed mb-2 overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--bg3)',
|
||||
color: 'var(--text2)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
maxHeight: '90px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{memory.split('\n').slice(0, 6).join('\n') || 'No memory yet'}
|
||||
</div>
|
||||
<SBtn primary onClick={() => setEditingMemory(true)}>Edit memory</SBtn>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Conversations */}
|
||||
<Section title="Conversations">
|
||||
<Row label="Stored" desc="">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text)' }}>
|
||||
{status?.conversation_count || 0}
|
||||
</span>
|
||||
</Row>
|
||||
<Row label="Export current" desc="Download as markdown">
|
||||
<SBtn onClick={exportConversation}>Export</SBtn>
|
||||
</Row>
|
||||
<Row label="Clear all" desc="Permanently delete history">
|
||||
<SBtn danger onClick={clearAll}>Clear</SBtn>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* System */}
|
||||
<Section title="System">
|
||||
<StatusRow label="Aaron AI service" value={status?.aaron_ai || 'unknown'} ok={status?.aaron_ai === 'running'} />
|
||||
<StatusRow label="File watcher" value={status?.watcher || 'unknown'} ok={status?.watcher === 'running'} />
|
||||
<StatusRow label="Nextcloud files" value={`${(status?.file_count || 0).toLocaleString()} files`} ok={true} />
|
||||
<StatusRow label="Model" value={status?.model || 'unknown'} ok={true} yellow />
|
||||
</Section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Sub-components
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wider mb-2" style={{ color: 'var(--text3)', letterSpacing: '0.06em' }}>
|
||||
{title}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, desc, children }: { label: string; desc?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-2 gap-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm" style={{ color: 'var(--text)' }}>{label}</div>
|
||||
{desc && <div className="text-xs mt-0.5" style={{ color: 'var(--text3)' }}>{desc}</div>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ on, onChange }: { on: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!on)}
|
||||
className="flex-shrink-0 rounded-full relative transition-colors"
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '20px',
|
||||
background: on ? 'var(--accent)' : 'var(--border2)',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute rounded-full bg-white transition-all"
|
||||
style={{
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
top: '2px',
|
||||
left: on ? '18px' : '2px',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SBtn({ children, onClick, primary, danger, disabled }: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
primary?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className="rounded-md px-3 py-1 text-xs whitespace-nowrap transition-opacity"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: `1px solid ${danger ? '#f7c1c1' : primary ? 'var(--accent-border)' : 'var(--border2)'}`,
|
||||
color: danger ? '#a32d2d' : primary ? 'var(--accent)' : 'var(--text2)',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
fontFamily: 'var(--font-sans)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ number, label }: { number: string; label: string }) {
|
||||
return (
|
||||
<div className="rounded-lg px-3 py-2" style={{ background: 'var(--accent-light)', border: '1px solid var(--accent-border)' }}>
|
||||
<div className="text-xl font-medium" style={{ color: 'var(--accent)' }}>{number}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text3)' }}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({ label, value, ok, yellow }: { label: string; value: string; ok: boolean; yellow?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<span
|
||||
className="rounded-full flex-shrink-0"
|
||||
style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
background: yellow ? '#c8821a' : ok ? '#2d7a3d' : '#a32d2d',
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm flex-1" style={{ color: 'var(--text)' }}>{label}</span>
|
||||
<span className="text-xs" style={{ color: 'var(--text3)' }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useStore } from '@/lib/store';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = (now.getTime() - d.getTime()) / 1000;
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
if (diff < 172800) return 'yesterday';
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function groupConversations(convs: { id: string; title: string; updated_at: string; created_at: string; message_count: number }[]) {
|
||||
const now = new Date();
|
||||
const groups: Record<string, typeof convs> = { today: [], yesterday: [], week: [], older: [] };
|
||||
convs.forEach(c => {
|
||||
const diff = (now.getTime() - new Date(c.updated_at).getTime()) / 86400000;
|
||||
if (diff < 1) groups.today.push(c);
|
||||
else if (diff < 2) groups.yesterday.push(c);
|
||||
else if (diff < 7) groups.week.push(c);
|
||||
else groups.older.push(c);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const { conversations, currentId, setCurrentId, setMessages, setConversations, setSidebarOpen } = useStore();
|
||||
|
||||
async function newConversation() {
|
||||
const conv = await api.newConversation();
|
||||
const updated = await api.getConversations();
|
||||
setConversations(updated);
|
||||
setCurrentId(conv.id);
|
||||
setMessages([]);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
||||
async function loadConversation(id: string) {
|
||||
setCurrentId(id);
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
|
||||
async function deleteConv(e: React.MouseEvent, id: string) {
|
||||
e.stopPropagation();
|
||||
if (!confirm('Delete this conversation?')) return;
|
||||
await api.deleteConversation(id);
|
||||
const updated = await api.getConversations();
|
||||
setConversations(updated);
|
||||
if (currentId === id) { setCurrentId(null); setMessages([]); }
|
||||
}
|
||||
|
||||
const groups = groupConversations(conversations);
|
||||
const labels: Record<string, string> = { today: 'Today', yesterday: 'Yesterday', week: 'This week', older: 'Older' };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="p-3" style={{ borderBottom: '1px solid var(--border)' }}>
|
||||
<button
|
||||
onClick={newConversation}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-85"
|
||||
style={{ background: 'var(--accent)', color: '#e8f5ed', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
<span style={{ fontSize: '16px', lineHeight: 1 }}>+</span>
|
||||
New conversation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Conversation list */}
|
||||
<div className="flex-1 overflow-y-auto py-1 px-2">
|
||||
{Object.entries(labels).map(([key, label]) => {
|
||||
const group = groups[key];
|
||||
if (!group.length) return null;
|
||||
return (
|
||||
<div key={key}>
|
||||
<div
|
||||
className="px-2 py-2 text-xs uppercase tracking-wider"
|
||||
style={{ color: 'var(--text3)', letterSpacing: '0.06em' }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
{group.map(c => (
|
||||
<div
|
||||
key={c.id}
|
||||
onClick={() => loadConversation(c.id)}
|
||||
className="flex items-center gap-2 px-2 py-2 rounded-lg cursor-pointer mb-0.5 group"
|
||||
style={{
|
||||
background: c.id === currentId ? 'var(--bg3)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (c.id !== currentId) (e.currentTarget as HTMLElement).style.background = 'var(--bg3)'; }}
|
||||
onMouseLeave={e => { if (c.id !== currentId) (e.currentTarget as HTMLElement).style.background = 'transparent'; }}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate" style={{ color: 'var(--text)' }} title={c.title}>
|
||||
{c.title}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--text3)' }}>
|
||||
{formatDate(c.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={e => deleteConv(e, c.id)}
|
||||
className="opacity-0 group-hover:opacity-100 px-1 py-0.5 rounded text-sm transition-opacity"
|
||||
style={{ background: 'none', border: 'none', color: 'var(--text3)', cursor: 'pointer', minWidth: '24px', minHeight: '24px' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{conversations.length === 0 && (
|
||||
<div className="px-3 py-4 text-sm" style={{ color: 'var(--text3)' }}>
|
||||
No conversations yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user