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