← back to Norma
components/email-analyzer/EmailSearchPanel.tsx
217 lines
'use client';
/**
* Search across saved Email Analyzer drafts + versions.
* Favorites float to the top, then trigram similarity, then newest.
* Each result exposes two actions: load (via parent callback) and
* "Save to Universal References" (works without loading).
*/
import { useEffect, useState, useCallback } from 'react';
import { Search, Star, BookmarkPlus, ExternalLink, Filter } from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
interface SearchResult {
id: string;
session_id: string;
version_number: number;
label: string | null;
tone: string | null;
word_count: number;
paragraph_count: number;
content: string;
is_favorite: boolean;
created_at: string;
sim: number;
}
interface EmailSearchPanelProps {
onOpenSession?: (sessionId: string) => void;
}
export default function EmailSearchPanel({ onOpenSession }: EmailSearchPanelProps) {
const { addToast } = useToast();
const [q, setQ] = useState('');
const [favoritesOnly, setFavoritesOnly] = useState(false);
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const runSearch = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (q.trim()) params.set('q', q.trim());
if (favoritesOnly) params.set('favorites_only', 'true');
const res = await fetch(`/api/email-analyzer/search?${params.toString()}`);
if (!res.ok) throw new Error('search failed');
const data = await res.json();
setResults(data.results || []);
} catch {
addToast('Search failed', 'error');
} finally {
setLoading(false);
}
}, [q, favoritesOnly, addToast]);
// Debounced search on query/filter change
useEffect(() => {
const t = setTimeout(() => { void runSearch(); }, 220);
return () => clearTimeout(t);
}, [runSearch]);
async function saveToUniversal(r: SearchResult) {
const plain = r.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
try {
const res = await fetch('/api/email-analyzer/universal-references', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `${r.label || 'Saved email'} (v${r.version_number})`,
content: plain,
tone: r.tone,
source_session_id: r.session_id,
}),
});
if (!res.ok) throw new Error('save failed');
addToast('Added to Universal References', 'success');
} catch {
addToast('Failed to save', 'error');
}
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{/* Controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ position: 'relative', flex: 1 }}>
<Search
size={13}
style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', color: 'var(--color-text-muted)' }}
/>
<input
type="text"
className="input"
style={{ width: '100%', paddingLeft: 30, fontSize: 13 }}
placeholder="Search saved emails... (e.g., 'thank you donor')"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<button
type="button"
onClick={() => setFavoritesOnly((p) => !p)}
className="btn btn-ghost btn-sm"
style={{
gap: 4,
fontSize: 11,
padding: '4px 10px',
minHeight: 30,
color: favoritesOnly ? '#f59e0b' : 'var(--color-text-muted)',
borderColor: favoritesOnly ? '#f59e0b' : 'var(--color-border)',
background: favoritesOnly ? 'rgba(245,158,11,0.08)' : 'transparent',
}}
title={favoritesOnly ? 'Show all' : 'Favorites only'}
>
<Filter size={11} />
{favoritesOnly ? 'Favs only' : 'All'}
</button>
</div>
{/* Results */}
{loading ? (
<div style={{ padding: '18px 10px', textAlign: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
Searching...
</div>
) : results.length === 0 ? (
<div style={{ padding: '18px 10px', textAlign: 'center', fontSize: 12, color: 'var(--color-text-muted)' }}>
{q ? 'No results.' : 'Type to search your saved emails.'}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 520, overflowY: 'auto' }}>
{results.map((r) => (
<div
key={r.id}
style={{
padding: 8,
border: '1px solid var(--color-border)',
borderRadius: 8,
backgroundColor: 'var(--color-surface)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
{r.is_favorite && (
<Star size={12} fill="#f59e0b" strokeWidth={2} style={{ color: '#f59e0b', flexShrink: 0 }} />
)}
<span
style={{
fontSize: 13,
fontWeight: 600,
color: 'var(--color-text)',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={r.label || `v${r.version_number}`}
>
{r.label || `Version ${r.version_number}`}
</span>
<span style={{ fontSize: 10, color: 'var(--color-text-muted)', flexShrink: 0 }}>
v{r.version_number} · {r.word_count}w
</span>
</div>
<div
style={{
fontSize: 11,
color: 'var(--color-text-secondary)',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
lineHeight: 1.35,
marginBottom: 6,
}}
>
{r.content.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 240)}
</div>
<div style={{ display: 'flex', gap: 4 }}>
{onOpenSession && (
<button
type="button"
className="btn btn-ghost btn-sm"
style={{ gap: 3, fontSize: 11, padding: '2px 8px', minHeight: 24 }}
onClick={() => onOpenSession(r.session_id)}
title="Open this analysis session"
>
<ExternalLink size={10} /> Open
</button>
)}
<button
type="button"
className="btn btn-ghost btn-sm"
style={{
gap: 3,
fontSize: 11,
padding: '2px 8px',
minHeight: 24,
color: 'var(--color-lane-b)',
}}
onClick={() => void saveToUniversal(r)}
title="Save to Universal References"
>
<BookmarkPlus size={10} /> Universal
</button>
{r.sim > 0 && (
<span style={{ marginLeft: 'auto', fontSize: 10, color: 'var(--color-text-muted)' }}>
match {(r.sim * 100).toFixed(0)}%
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
);
}