← back to Norma
components/email-analyzer/SavedAnalyses.tsx
261 lines
'use client';
import { useEffect, useState } from 'react';
import { FolderClock, Sparkles, FileText, Trash2 } from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
interface SavedSession {
id: number;
session_id: string;
version_number: number;
label: string | null;
tone: string | null;
word_count: number | null;
paragraph_count: number | null;
created_by: string | null;
created_at: string;
original_preview: string | null;
}
interface SavedAnalysesProps {
onOpen: (sessionId: string) => void;
}
function formatRelative(iso: string): string {
const d = new Date(iso);
const diffMs = Date.now() - d.getTime();
const mins = Math.floor(diffMs / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function deriveTopic(s: SavedSession): string {
if (s.label && s.label.trim()) return s.label.trim();
if (s.original_preview) {
const clean = s.original_preview.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
return clean.slice(0, 80) || 'Untitled analysis';
}
return 'Untitled analysis';
}
export default function SavedAnalyses({ onOpen }: SavedAnalysesProps) {
const { addToast } = useToast();
const [sessions, setSessions] = useState<SavedSession[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState('');
useEffect(() => {
void fetchSessions();
}, []);
async function fetchSessions() {
setLoading(true);
try {
const res = await fetch('/api/email-analyzer/drafts?limit=100', { credentials: 'include' });
if (!res.ok) throw new Error('Failed to load saved analyses');
const data = await res.json();
setSessions(Array.isArray(data.sessions) ? data.sessions : []);
} catch (err) {
addToast((err as Error).message || 'Failed to load', 'error');
} finally {
setLoading(false);
}
}
async function handleDelete(sessionId: string, evt: React.MouseEvent): Promise<void> {
evt.stopPropagation();
if (!confirm('Delete all versions of this analysis? This cannot be undone.')) return;
try {
const res = await fetch(
`/api/email-analyzer/drafts?session_id=${encodeURIComponent(sessionId)}`,
{ method: 'DELETE', credentials: 'include' },
);
if (!res.ok) throw new Error('Delete failed');
setSessions((prev) => prev.filter((s) => s.session_id !== sessionId));
addToast('Analysis deleted', 'success');
} catch (err) {
addToast((err as Error).message || 'Delete failed', 'error');
}
}
const filtered = query.trim()
? sessions.filter((s) => {
const hay = `${deriveTopic(s)} ${s.tone || ''} ${s.created_by || ''}`.toLowerCase();
return hay.includes(query.trim().toLowerCase());
})
: sessions;
return (
<div className="p-4 sm:p-6 max-w-5xl mx-auto">
<div className="flex items-center gap-2.5 mb-5">
<div
style={{
width: 36,
height: 36,
borderRadius: 'var(--radius-md)',
background: 'linear-gradient(135deg, rgba(79,70,229,0.15), rgba(99,102,241,0.15))',
border: '1px solid rgba(79,70,229,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
aria-hidden="true"
>
<FolderClock size={18} style={{ color: '#6366f1' }} />
</div>
<div style={{ flex: 1 }}>
<h1 className="text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
Saved Analyses
</h1>
<p className="text-sm" style={{ color: 'var(--color-text-muted)' }}>
Pick a saved Email Analyzer session to reopen
</p>
</div>
<input
type="search"
placeholder="Search topic, tone…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="input"
style={{ maxWidth: 240 }}
/>
</div>
{loading && (
<div className="card" style={{ padding: 24, textAlign: 'center' }}>
<span className="spinner" aria-hidden="true" />
<span style={{ marginLeft: 10, color: 'var(--color-text-muted)' }}>Loading…</span>
</div>
)}
{!loading && filtered.length === 0 && (
<div
className="card"
style={{
padding: 40,
textAlign: 'center',
color: 'var(--color-text-muted)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 12,
}}
>
<FileText size={28} style={{ color: 'var(--color-text-muted)' }} />
<div>
<div style={{ fontWeight: 600, color: 'var(--color-text)' }}>
{query ? 'No matches' : 'No saved analyses yet'}
</div>
<div style={{ fontSize: 13, marginTop: 4 }}>
{query
? 'Try a different search term.'
: 'Open Email Analyzer, click Save on a version, and it will appear here.'}
</div>
</div>
</div>
)}
{!loading && filtered.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{filtered.map((s) => (
<button
key={s.session_id}
type="button"
onClick={() => onOpen(s.session_id)}
className="card"
style={{
display: 'flex',
alignItems: 'center',
gap: 14,
padding: '14px 16px',
cursor: 'pointer',
textAlign: 'left',
transition: 'border-color var(--transition), background-color var(--transition)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#6366f1';
e.currentTarget.style.backgroundColor = 'var(--color-surface-el)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = 'var(--color-border)';
e.currentTarget.style.backgroundColor = 'var(--color-surface)';
}}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 'var(--radius-md)',
background: 'rgba(99,102,241,0.12)',
border: '1px solid rgba(99,102,241,0.25)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<Sparkles size={16} style={{ color: '#6366f1' }} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 14,
fontWeight: 600,
color: 'var(--color-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{deriveTopic(s)}
</div>
<div
style={{
fontSize: 12,
color: 'var(--color-text-muted)',
marginTop: 3,
display: 'flex',
gap: 10,
flexWrap: 'wrap',
}}
>
<span>v{s.version_number}</span>
<span>·</span>
<span>{formatRelative(s.created_at)}</span>
{s.tone && (
<>
<span>·</span>
<span style={{ textTransform: 'capitalize' }}>{s.tone}</span>
</>
)}
{s.word_count != null && (
<>
<span>·</span>
<span>{s.word_count} words</span>
</>
)}
</div>
</div>
<button
type="button"
onClick={(e) => handleDelete(s.session_id, e)}
className="btn btn-ghost btn-sm"
aria-label="Delete analysis"
style={{ padding: '6px 8px', flexShrink: 0 }}
>
<Trash2 size={14} />
</button>
</button>
))}
</div>
)}
</div>
);
}