← back to Norma

components/email-analyzer/RewriteControls.tsx

584 lines

'use client';

import { useCallback, useEffect, useMemo, useState } from 'react';
import {
  SlidersHorizontal,
  Gauge,
  Users,
  Send,
  BookmarkPlus,
  BookOpen,
  Loader2,
  X,
  Trash2,
  ChevronDown,
  ChevronRight,
} from 'lucide-react';
import { useToast } from '@/components/ToastProvider';
import { ANGLE_OPTIONS } from './AngleCheckboxes';

/* ─── Option sets ────────────────────────────────────────────────────────── */

const TONE_OPTIONS = [
  { key: 'professional', label: 'Professional' },
  { key: 'warm',         label: 'Warm' },
  { key: 'urgent',       label: 'Urgent' },
  { key: 'formal',       label: 'Formal' },
  { key: 'casual',       label: 'Casual' },
  { key: 'assertive',    label: 'Assertive' },
];

const LENGTH_OPTIONS = [
  { key: 'shorter', label: 'Shorter',     prompt: 'Make it ~30% shorter.' },
  { key: 'same',    label: 'Same length', prompt: 'Keep approximately the same length.' },
  { key: 'longer',  label: 'Longer',      prompt: 'Expand with more detail (~30% longer).' },
  { key: 'bullets', label: 'Bullets',     prompt: 'Restructure with a short intro and bulleted key points.' },
  { key: 'onepara', label: 'One paragraph', prompt: 'Condense to a single focused paragraph.' },
];

const AUDIENCE_OPTIONS = [
  { key: 'general',     label: 'General' },
  { key: 'donors',      label: 'Donors' },
  { key: 'volunteers',  label: 'Volunteers' },
  { key: 'electeds',    label: 'Elected officials' },
  { key: 'journalists', label: 'Journalists' },
  { key: 'borrowers',   label: 'Borrowers' },
];

/* ─── Types ──────────────────────────────────────────────────────────────── */

export interface RewriteSelection {
  tone: string | null;
  length: string | null;
  audience: string | null;
  angles: string[];
  customInstruction: string;
}

interface SavedSkill {
  id: string;
  name: string;
  description: string | null;
  tone: string | null;
  length: string | null;
  audience: string | null;
  angles: string[];
  custom_instruction: string | null;
  use_count: number;
}

interface RewriteControlsProps {
  onGo: (instruction: string, selection: RewriteSelection) => void;
  isProcessing: boolean;
  hasEmail: boolean;
}

const CACHE_KEY = 'norma-analyzer-rewrite-controls';

/* ─── Helpers ────────────────────────────────────────────────────────────── */

function buildInstructionFromSelection(s: RewriteSelection): string {
  const lines: string[] = [];
  if (s.tone)     lines.push(`Apply a ${s.tone} tone.`);
  if (s.length) {
    const lo = LENGTH_OPTIONS.find((l) => l.key === s.length);
    if (lo) lines.push(lo.prompt);
  }
  if (s.audience && s.audience !== 'general') {
    const ao = AUDIENCE_OPTIONS.find((a) => a.key === s.audience);
    if (ao) lines.push(`Write for an audience of ${ao.label.toLowerCase()}.`);
  }
  if (s.angles.length > 0) {
    const prompts = ANGLE_OPTIONS
      .filter((o) => s.angles.includes(o.key))
      .map((o) => `• ${o.prompt}`)
      .join('\n');
    lines.push(`Apply these angles:\n${prompts}`);
  }
  if (s.customInstruction.trim()) {
    lines.push(s.customInstruction.trim());
  }
  return lines.join('\n\n');
}

/* ─── Component ──────────────────────────────────────────────────────────── */

export default function RewriteControls({ onGo, isProcessing, hasEmail }: RewriteControlsProps) {
  const { addToast } = useToast();

  const [tone, setTone] = useState<string | null>(null);
  const [length, setLength] = useState<string | null>(null);
  const [audience, setAudience] = useState<string | null>(null);
  const [angles, setAngles] = useState<string[]>([]);
  const [customInstruction, setCustomInstruction] = useState('');
  const [promptField, setPromptField] = useState('');
  const [promptEdited, setPromptEdited] = useState(false);

  const [skills, setSkills] = useState<SavedSkill[]>([]);
  const [showSkillsList, setShowSkillsList] = useState(false);
  const [showSaveDialog, setShowSaveDialog] = useState(false);
  const [saveName, setSaveName] = useState('');
  const [saveDesc, setSaveDesc] = useState('');
  const [savingSkill, setSavingSkill] = useState(false);
  const [collapsed, setCollapsed] = useState(true);

  /* ── Restore from localStorage ─────────────────────────────────────────── */
  useEffect(() => {
    try {
      const raw = localStorage.getItem(CACHE_KEY);
      if (!raw) return;
      const d = JSON.parse(raw);
      if (d.tone !== undefined) setTone(d.tone);
      if (d.length !== undefined) setLength(d.length);
      if (d.audience !== undefined) setAudience(d.audience);
      if (Array.isArray(d.angles)) setAngles(d.angles);
      if (typeof d.customInstruction === 'string') setCustomInstruction(d.customInstruction);
    } catch { /* noop */ }
  }, []);

  /* ── Persist on change ─────────────────────────────────────────────────── */
  useEffect(() => {
    try {
      localStorage.setItem(
        CACHE_KEY,
        JSON.stringify({ tone, length, audience, angles, customInstruction }),
      );
    } catch { /* noop */ }
  }, [tone, length, audience, angles, customInstruction]);

  /* ── Auto-populate the prompt field from selections ────────────────────── */
  const computedPrompt = useMemo(
    () => buildInstructionFromSelection({ tone, length, audience, angles, customInstruction }),
    [tone, length, audience, angles, customInstruction],
  );

  useEffect(() => {
    if (!promptEdited) {
      setPromptField(computedPrompt);
    }
  }, [computedPrompt, promptEdited]);

  /* ── Skills list ───────────────────────────────────────────────────────── */
  const loadSkills = useCallback(async () => {
    try {
      const res = await fetch('/api/email-analyzer/skills', { credentials: 'include' });
      if (!res.ok) return;
      const data = await res.json();
      setSkills(data.skills || []);
    } catch { /* noop */ }
  }, []);

  useEffect(() => {
    void loadSkills();
  }, [loadSkills]);

  function applySkill(skill: SavedSkill) {
    setTone(skill.tone);
    setLength(skill.length);
    setAudience(skill.audience);
    setAngles(Array.isArray(skill.angles) ? skill.angles : []);
    setCustomInstruction(skill.custom_instruction || '');
    setPromptEdited(false);
    setShowSkillsList(false);
    addToast(`Applied skill: ${skill.name}`, 'success');
  }

  async function saveSkill() {
    if (!saveName.trim()) {
      addToast('Name required', 'error');
      return;
    }
    setSavingSkill(true);
    try {
      const res = await fetch('/api/email-analyzer/skills', {
        method: 'POST',
        credentials: 'include',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: saveName,
          description: saveDesc,
          tone, length, audience,
          angles,
          custom_instruction: customInstruction,
        }),
      });
      if (!res.ok) throw new Error((await res.json()).error || 'Save failed');
      addToast(`Skill saved: ${saveName}`, 'success');
      setShowSaveDialog(false);
      setSaveName('');
      setSaveDesc('');
      void loadSkills();
    } catch (err) {
      addToast((err as Error).message, 'error');
    } finally {
      setSavingSkill(false);
    }
  }

  async function deleteSkill(id: string, name: string) {
    if (!confirm(`Delete skill "${name}"?`)) return;
    try {
      const res = await fetch(`/api/email-analyzer/skills?id=${encodeURIComponent(id)}`, {
        method: 'DELETE',
        credentials: 'include',
      });
      if (!res.ok) throw new Error('Delete failed');
      addToast('Skill deleted', 'success');
      void loadSkills();
    } catch (err) {
      addToast((err as Error).message, 'error');
    }
  }

  function toggleAngle(key: string) {
    setAngles((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
  }

  function handleGo() {
    const instruction = promptField.trim();
    if (!instruction) {
      addToast('Add something to the prompt first', 'error');
      return;
    }
    onGo(instruction, { tone, length, audience, angles, customInstruction });
  }

  function resetAll() {
    setTone(null);
    setLength(null);
    setAudience(null);
    setAngles([]);
    setCustomInstruction('');
    setPromptEdited(false);
    setPromptField('');
  }

  const hasSelection = tone || length || audience || angles.length > 0 || customInstruction.trim();

  /* ── Render ────────────────────────────────────────────────────────────── */
  return (
    <div className="card" style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: collapsed ? 0 : 10 }}>
      {/* Header */}
      <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, cursor: 'pointer' }}
      >
        {collapsed ? <ChevronRight size={14} style={{ color: 'var(--color-text-muted)' }} /> : <ChevronDown size={14} style={{ color: 'var(--color-text-muted)' }} />}
        <SlidersHorizontal size={14} style={{ color: 'var(--color-lane-b)' }} />
        <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text)' }}>
          Rewrite Controls
        </span>
        {hasSelection && (
          <span
            className="badge badge-success"
            style={{ fontSize: 10, padding: '1px 6px' }}
          >
            {(tone ? 1 : 0) + (length ? 1 : 0) + (audience ? 1 : 0) + angles.length + (customInstruction.trim() ? 1 : 0)}
          </span>
        )}
        <button
          type="button"
          className="btn btn-ghost btn-sm"
          onClick={(e) => { e.stopPropagation(); setCollapsed(false); setShowSkillsList((v) => !v); }}
          style={{ marginLeft: 'auto', minHeight: 24, padding: '2px 8px', fontSize: 11, gap: 4 }}
          title="Load a saved skill"
        >
          <BookOpen size={11} />
          Skills ({skills.length})
        </button>
        {hasSelection && (
          <button
            type="button"
            className="btn btn-ghost btn-sm"
            onClick={(e) => { e.stopPropagation(); resetAll(); }}
            style={{ minHeight: 24, padding: '2px 8px', fontSize: 11, gap: 4 }}
            title="Clear all selections"
          >
            <X size={11} />
            Reset
          </button>
        )}
      </div>

      {collapsed ? null : (
      <>
      {/* Skills list */}
      {showSkillsList && (
        <div
          style={{
            padding: 8,
            borderRadius: 'var(--radius-sm)',
            border: '1px solid var(--color-border)',
            background: 'var(--color-surface-el)',
            maxHeight: 180,
            overflowY: 'auto',
          }}
        >
          {skills.length === 0 ? (
            <div style={{ fontSize: 11, color: 'var(--color-text-muted)' }}>
              No saved skills yet. Click "Save as skill" after setting up controls you use often.
            </div>
          ) : (
            skills.map((s) => (
              <div
                key={s.id}
                style={{
                  display: 'flex',
                  alignItems: 'center',
                  gap: 6,
                  padding: 4,
                  borderRadius: 4,
                  cursor: 'pointer',
                }}
              >
                <button
                  type="button"
                  onClick={() => applySkill(s)}
                  style={{
                    flex: 1,
                    textAlign: 'left',
                    background: 'transparent',
                    border: 'none',
                    color: 'var(--color-text)',
                    fontSize: 12,
                    cursor: 'pointer',
                    padding: 4,
                  }}
                >
                  <div style={{ fontWeight: 600 }}>{s.name}</div>
                  {s.description && (
                    <div style={{ fontSize: 10, color: 'var(--color-text-muted)' }}>{s.description}</div>
                  )}
                </button>
                <button
                  type="button"
                  onClick={() => deleteSkill(s.id, s.name)}
                  style={{
                    background: 'transparent',
                    border: 'none',
                    color: 'var(--color-text-muted)',
                    cursor: 'pointer',
                    padding: 4,
                  }}
                  title="Delete skill"
                >
                  <Trash2 size={11} />
                </button>
              </div>
            ))
          )}
        </div>
      )}

      {/* Tone */}
      <Group label="Tone" Icon={Gauge}>
        {TONE_OPTIONS.map((o) => (
          <Chip key={o.key} label={o.label} active={tone === o.key} onClick={() => setTone(tone === o.key ? null : o.key)} />
        ))}
      </Group>

      {/* Length */}
      <Group label="Length" Icon={SlidersHorizontal}>
        {LENGTH_OPTIONS.map((o) => (
          <Chip key={o.key} label={o.label} active={length === o.key} onClick={() => setLength(length === o.key ? null : o.key)} />
        ))}
      </Group>

      {/* Audience */}
      <Group label="Audience" Icon={Users}>
        {AUDIENCE_OPTIONS.map((o) => (
          <Chip key={o.key} label={o.label} active={audience === o.key} onClick={() => setAudience(audience === o.key ? null : o.key)} />
        ))}
      </Group>

      {/* Angles */}
      <div>
        <div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 5, fontWeight: 600 }}>
          Angles (multi)
        </div>
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 4 }}>
          {ANGLE_OPTIONS.map((opt) => (
            <Chip
              key={opt.key}
              label={opt.label}
              active={angles.includes(opt.key)}
              onClick={() => toggleAngle(opt.key)}
              small
            />
          ))}
        </div>
      </div>

      {/* Custom instruction */}
      <div>
        <div style={{ fontSize: 11, color: 'var(--color-text-secondary)', marginBottom: 5, fontWeight: 600 }}>
          Custom instruction (optional)
        </div>
        <textarea
          className="input"
          value={customInstruction}
          onChange={(e) => setCustomInstruction(e.target.value)}
          placeholder="e.g. Mention the March 15 hearing and include a quote from Sen. Warren…"
          style={{ fontSize: 11, minHeight: 50, resize: 'vertical' }}
        />
      </div>

      {/* Accumulated prompt — auto-populates, editable */}
      <div>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 5 }}>
          <span style={{ fontSize: 11, color: 'var(--color-text-secondary)', fontWeight: 600 }}>
            Full prompt (editable)
          </span>
          {promptEdited && (
            <button
              type="button"
              onClick={() => setPromptEdited(false)}
              style={{
                background: 'transparent',
                border: 'none',
                color: 'var(--color-text-muted)',
                fontSize: 10,
                cursor: 'pointer',
                marginLeft: 'auto',
              }}
              title="Reset to auto-generated prompt"
            >
              Reset to auto
            </button>
          )}
        </div>
        <textarea
          className="input"
          value={promptField}
          onChange={(e) => {
            setPromptField(e.target.value);
            setPromptEdited(true);
          }}
          placeholder="This field auto-fills as you pick options above. Edit freely."
          style={{ fontSize: 11, minHeight: 70, resize: 'vertical' }}
        />
      </div>

      {/* Actions */}
      <div style={{ display: 'flex', gap: 6 }}>
        <button
          type="button"
          className="btn btn-primary btn-sm"
          onClick={handleGo}
          disabled={isProcessing || !hasEmail || !promptField.trim()}
          style={{ flex: 1, gap: 6 }}
        >
          {isProcessing ? <Loader2 size={12} className="spinner" /> : <Send size={12} />}
          Go
        </button>
        <button
          type="button"
          className="btn btn-secondary btn-sm"
          onClick={() => setShowSaveDialog(true)}
          disabled={!hasSelection}
          style={{ gap: 6 }}
          title="Save current selections as a reusable skill"
        >
          <BookmarkPlus size={12} />
          Save as skill
        </button>
      </div>

      {/* Save dialog */}
      {showSaveDialog && (
        <div
          style={{
            padding: 10,
            borderRadius: 'var(--radius-sm)',
            border: '1px solid var(--color-primary)',
            background: 'rgba(16,185,129,0.06)',
            display: 'flex',
            flexDirection: 'column',
            gap: 6,
          }}
        >
          <div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text)' }}>
            Save as skill
          </div>
          <input
            className="input"
            placeholder="Name (e.g. Donor thank-you, urgent)"
            value={saveName}
            onChange={(e) => setSaveName(e.target.value)}
            style={{ fontSize: 11 }}
            autoFocus
          />
          <input
            className="input"
            placeholder="Description (optional)"
            value={saveDesc}
            onChange={(e) => setSaveDesc(e.target.value)}
            style={{ fontSize: 11 }}
          />
          <div style={{ display: 'flex', gap: 6 }}>
            <button
              type="button"
              className="btn btn-primary btn-sm"
              onClick={saveSkill}
              disabled={savingSkill || !saveName.trim()}
              style={{ flex: 1 }}
            >
              {savingSkill ? <Loader2 size={11} className="spinner" /> : 'Save'}
            </button>
            <button
              type="button"
              className="btn btn-ghost btn-sm"
              onClick={() => setShowSaveDialog(false)}
            >
              Cancel
            </button>
          </div>
        </div>
      )}
      </>
      )}
    </div>
  );
}

/* ─── Small subcomponents ────────────────────────────────────────────────── */

function Group({ label, Icon, children }: { label: string; Icon: typeof Gauge; children: React.ReactNode }) {
  return (
    <div>
      <div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 5 }}>
        <Icon size={11} style={{ color: 'var(--color-text-muted)' }} />
        <span style={{ fontSize: 11, color: 'var(--color-text-secondary)', fontWeight: 600 }}>
          {label}
        </span>
      </div>
      <div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>{children}</div>
    </div>
  );
}

function Chip({ label, active, onClick, small }: { label: string; active: boolean; onClick: () => void; small?: boolean }) {
  return (
    <button
      type="button"
      onClick={onClick}
      style={{
        padding: small ? '4px 6px' : '4px 10px',
        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: small ? 10 : 11,
        cursor: 'pointer',
        transition: 'var(--transition)',
        textAlign: 'left',
      }}
    >
      {label}
    </button>
  );
}