← 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(',');
}