← back to Norma
components/email-analyzer/SuggestionsPanel.tsx
310 lines
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Inbox, Newspaper, Twitter, HardDrive, Plus, Loader2, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
import { dispatchRefsChanged } from './useReferences';
interface Suggestion {
id: string;
source: 'gmail' | 'news' | 'leader' | 'drive';
title: string;
snippet: string;
url?: string;
meta?: string;
published_at?: string;
}
interface SuggestionsPanelProps {
sessionId: string;
/** Keywords derived from the current email — comma-separated. */
keywords: string;
/** Called after a suggestion is added as a reference, so parent can refresh. */
onReferenceAdded?: () => void;
}
type Tab = 'gmail' | 'news' | 'leader' | 'drive';
const TABS: Array<{ key: Tab; label: string; Icon: typeof Inbox }> = [
{ key: 'news', label: 'News', Icon: Newspaper },
{ key: 'gmail', label: 'Gmail', Icon: Inbox },
{ key: 'leader', label: 'Leaders', Icon: Twitter },
{ key: 'drive', label: 'Drive', Icon: HardDrive },
];
export default function SuggestionsPanel({ sessionId, keywords, onReferenceAdded }: SuggestionsPanelProps) {
const { addToast } = useToast();
const [tab, setTab] = useState<Tab>('news');
const [buckets, setBuckets] = useState<Record<Tab, Suggestion[]>>({
gmail: [], news: [], leader: [], drive: [],
});
const [driveStatus, setDriveStatus] = useState<string>('not_connected');
const [loading, setLoading] = useState(false);
const [adding, setAdding] = useState<string | null>(null);
const [collapsed, setCollapsed] = useState(true);
const load = useCallback(async () => {
if (!keywords || keywords.trim().length < 3) return;
setLoading(true);
try {
const res = await fetch(
`/api/email-analyzer/suggestions?keywords=${encodeURIComponent(keywords)}&limit=5`,
{ credentials: 'include' },
);
if (!res.ok) throw new Error('Suggestions failed');
const data = await res.json();
setBuckets({
gmail: data.gmail || [],
news: data.news || [],
leader: data.leaders || [],
drive: data.drive || [],
});
setDriveStatus(data.driveStatus || 'not_connected');
} catch (err) {
console.error('[SuggestionsPanel] load error:', (err as Error).message);
} finally {
setLoading(false);
}
}, [keywords]);
useEffect(() => {
void load();
}, [load]);
async function addAsReference(sug: Suggestion) {
setAdding(sug.id);
try {
const refType = sug.source === 'gmail' || sug.source === 'leader' ? 'notes'
: sug.source === 'news' ? 'article'
: 'link';
const content = `${sug.title}\n\n${sug.snippet}${sug.meta ? `\n\n— ${sug.meta}` : ''}${sug.url ? `\n\n${sug.url}` : ''}`;
const res = await fetch('/api/email-analyzer/references', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
session_id: sessionId,
ref_type: refType,
title: sug.title,
source_url: sug.url || null,
content,
include_in_rewrite: true,
}),
});
if (!res.ok) throw new Error((await res.json()).error || 'Add failed');
addToast('Added to references', 'success');
dispatchRefsChanged(sessionId);
onReferenceAdded?.();
} catch (err) {
addToast((err as Error).message, 'error');
} finally {
setAdding(null);
}
}
const items = buckets[tab];
const totalCount = buckets.gmail.length + buckets.news.length + buckets.leader.length + buckets.drive.length;
return (
<div className="card" style={{ padding: 12 }}>
<div
role="button"
tabIndex={0}
onClick={() => setCollapsed((p) => !p)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setCollapsed((p) => !p); } }}
style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: collapsed ? 0 : 10, cursor: 'pointer' }}
>
{collapsed ? <ChevronRight size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} />}
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
Auto-Suggested References
</span>
{totalCount > 0 && (
<span
className="badge"
style={{
fontSize: 10,
padding: '1px 6px',
background: 'var(--color-surface-el)',
color: 'var(--color-text-secondary)',
}}
>
{totalCount}
</span>
)}
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={(e) => { e.stopPropagation(); setCollapsed(false); load(); }}
disabled={loading || !keywords}
style={{ marginLeft: 'auto', minHeight: 24, padding: '2px 8px', fontSize: 11, gap: 4 }}
title="Re-fetch suggestions"
>
{loading ? <Loader2 size={11} className="spinner" /> : <RefreshCw size={11} />}
Refresh
</button>
</div>
{collapsed ? null : (<>
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8, flexWrap: 'wrap' }}>
{TABS.map((t) => {
const count = buckets[t.key].length;
const active = tab === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '4px 8px',
borderRadius: 'var(--radius-sm)',
border: `1px solid ${active ? 'var(--color-primary)' : 'var(--color-border)'}`,
background: active ? 'rgba(16,185,129,0.10)' : 'var(--color-surface-el)',
color: active ? 'var(--color-text)' : 'var(--color-text-secondary)',
fontSize: 11,
cursor: 'pointer',
transition: 'var(--transition)',
}}
>
<t.Icon size={11} />
{t.label}
<span style={{
fontSize: 9,
padding: '0 5px',
borderRadius: 999,
background: active ? 'var(--color-primary)' : 'var(--color-border)',
color: active ? '#fff' : 'var(--color-text-secondary)',
marginLeft: 2,
}}>
{count}
</span>
</button>
);
})}
</div>
{/* Content */}
{!keywords && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
Paste an email first — we'll auto-search Gmail, News, and leader tweets.
</div>
)}
{tab === 'drive' && driveStatus !== 'connected' && (
<div
style={{
padding: 10,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
background: 'var(--color-surface-el)',
fontSize: 11,
color: 'var(--color-text-secondary)',
}}
>
{driveStatus === 'scope_missing'
? 'Drive access is missing from the current OAuth grant. '
: 'Google Drive is not connected. '}
<a
href="/api/gmail/oauth/start"
style={{ color: 'var(--color-primary)', textDecoration: 'underline' }}
>
Click here to (re)authorize Gmail + Drive
</a>
. You only need to do this once.
</div>
)}
{keywords && items.length === 0 && !loading && tab !== 'drive' && (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
No matches for <em>{keywords.split(',').slice(0, 3).join(', ')}</em>.
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, maxHeight: 280, overflowY: 'auto' }}>
{items.map((sug) => (
<div
key={sug.id}
style={{
padding: 8,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
background: 'var(--color-surface-el)',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: 12,
fontWeight: 600,
color: 'var(--color-text)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{sug.title}
</div>
{sug.meta && (
<div style={{ fontSize: 10, color: 'var(--color-text-muted)', marginTop: 2 }}>
{sug.meta}
{sug.published_at && ` · ${new Date(sug.published_at).toLocaleDateString()}`}
</div>
)}
{sug.snippet && (
<div style={{
fontSize: 11,
color: 'var(--color-text-secondary)',
marginTop: 4,
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}>
{sug.snippet}
</div>
)}
</div>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => addAsReference(sug)}
disabled={adding === sug.id}
style={{ minHeight: 24, padding: '2px 6px', gap: 3, fontSize: 10, flexShrink: 0 }}
title="Add to references"
>
{adding === sug.id ? <Loader2 size={10} className="spinner" /> : <Plus size={10} />}
Use
</button>
</div>
</div>
))}
</div>
</>)}
</div>
);
}
/** Extract 3-8 keywords from a plain email body to feed the suggestions API. */
export function extractKeywords(emailHtml: string, max = 6): string {
const plain = emailHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const stop = new Set([
'the','a','an','and','or','but','of','to','for','with','in','on','at','by','from','is','are','was','were','be','been','being','have','has','had','do','does','did','this','that','these','those','it','its','we','you','your','our','us','they','them','their','i','me','my','if','so','as','not','no','yes','will','can','would','could','should','may','might','about','into','than','then','more','most','some','any','all','there','here','what','when','where','who','how','because','due','amount','please','thank','thanks','best','regards','sincerely',
]);
const freq = new Map<string, number>();
for (const tok of plain.match(/[a-z][a-z-]{3,}/g) || []) {
if (stop.has(tok)) continue;
freq.set(tok, (freq.get(tok) || 0) + 1);
}
const sorted = [...freq.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, max)
.map(([w]) => w);
return sorted.join(',');
}