redaction / ui /src /App.tsx
gni
feat: add frontend error notification for rate limiting
945586b
import { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import {
Shield, Lock, CheckCircle2,
Database, Languages, Fingerprint, Zap,
Palette, Upload, Trash2
} from 'lucide-react';
interface EntityMeta {
type: string;
text: string;
score: number;
start: number;
end: number;
}
interface RedactResponse {
original_text: string;
redacted_text: string;
detected_language: string;
entities: EntityMeta[];
}
type Theme = 'premium' | 'light' | 'dark';
const EXAMPLES = [
{ id: "PRO-01", label: "Procès Verbal", lang: "fr", text: `PROCÈS-VERBAL DE RÉUNION DE CHANTIER - RÉNOVATION COMPLEXE HÔTELIER\n\nDate : 20 Mars 2026\nLieu : 142 Avenue des Champs-Élysées, 75008 Paris.\n\nPRÉSENTS :\n- M. Alexandre de La Rochefoucauld (Directeur de projet, Groupe Immobilier "Lux-Horizon" - SIRET 321 654 987 00054).\n- Mme Valérie Marchand (Architecte, Cabinet "Marchand & Associés").\n- M. Thomas Dubois (Ingénieur sécurité, joignable au 06.45.12.89.33).\n\nORDRE DU JOUR :\nValidation des acomptes sur l'IBAN FR76 3000 1000 2000 3000 4000 500.` },
{ id: "MED-02", label: "Medical Record", lang: "en", text: `CLINICAL DISCHARGE SUMMARY - PATIENT ID: #XP-99021\n\nPATIENT INFORMATION:\nName: Sarah-Jane Montgomery\nDOB: 12/05/1982\nAddress: 1244 North Oak Street, San Francisco, CA 94102\nSSN : 123-45-6789. Email: sj.montgomery@provider.net.` }
];
function App() {
const [text, setText] = useState('');
const [language, setLanguage] = useState('auto');
const [theme, setTheme] = useState<Theme>(() => (localStorage.getItem('pg-theme') as Theme) || 'premium');
const [result, setResult] = useState<RedactResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [apiStatus, setApiStatus] = useState<'checking' | 'online' | 'offline'>('online');
const [copied, setCopied] = useState(false);
const [isThemeOpen, setIsThemeOpen] = useState(false);
const [isLangOpen, setIsLangOpen] = useState(false);
const themeRef = useRef<HTMLDivElement>(null);
const langRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const API_URL = import.meta.env.VITE_API_URL || '';
useEffect(() => {
localStorage.setItem('pg-theme', theme);
const handleClickOutside = (e: MouseEvent) => {
if (themeRef.current && !themeRef.current.contains(e.target as Node)) setIsThemeOpen(false);
if (langRef.current && !langRef.current.contains(e.target as Node)) setIsLangOpen(false);
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [theme]);
useEffect(() => {
const checkStatus = async () => {
try { await axios.get(`${API_URL}/api/status`); setApiStatus('online'); }
catch (err) { setApiStatus('offline'); }
};
checkStatus();
}, [API_URL]);
const handleRedact = async () => {
if (!text.trim()) return;
setLoading(true);
setError(null);
try {
const response = await axios.post(`${API_URL}/api/redact`, { text, language });
setResult(response.data);
} catch (err: any) {
console.error(err);
const msg = err.response?.data?.detail || 'An unexpected error occurred.';
setError(typeof msg === 'string' ? msg : 'Analysis failed. Please try again.');
setTimeout(() => setError(null), 6000);
}
finally { setTimeout(() => setLoading(false), 400); }
};
const themeClasses = {
premium: {
body: 'bg-slate-50',
header: 'bg-white border-slate-200 text-slate-900',
panel: 'bg-white border-slate-200',
panelHeader: 'bg-slate-50 text-slate-500',
input: 'bg-white text-slate-900 placeholder-slate-300',
output: 'bg-slate-50 text-slate-800',
tag: 'bg-blue-600 text-white shadow-sm',
footer: 'bg-slate-100/50 border-slate-200',
btn: 'bg-slate-900 hover:bg-black text-white',
btnDisabled: 'bg-slate-200 text-slate-400 cursor-not-allowed',
itemHover: 'hover:bg-slate-100 text-slate-900',
dropdown: 'bg-white border-slate-200 shadow-2xl',
itemCard: 'bg-white border-slate-200 text-slate-900 shadow-sm'
},
light: {
body: 'bg-white',
header: 'bg-white border-black text-black',
panel: 'bg-white border-black',
panelHeader: 'bg-white border-b-black text-black',
input: 'bg-white text-black placeholder-gray-300',
output: 'bg-white text-black',
tag: 'bg-black text-white rounded-none border border-white',
footer: 'bg-white border-black',
btn: 'bg-black hover:bg-zinc-800 text-white',
btnDisabled: 'bg-zinc-100 text-zinc-300 cursor-not-allowed border border-zinc-200',
itemHover: 'hover:bg-zinc-100 text-black',
dropdown: 'bg-white border-black shadow-xl',
itemCard: 'bg-white border-black text-black'
},
dark: {
body: 'bg-[#020617]',
header: 'bg-slate-900/50 border-slate-800 text-white',
panel: 'bg-slate-900/30 border-slate-800',
panelHeader: 'bg-slate-900/50 text-slate-400',
input: 'bg-transparent text-white placeholder-slate-700',
output: 'bg-black/20 text-blue-400',
tag: 'bg-blue-500 text-black font-black',
footer: 'bg-black/40 border-slate-800',
btn: 'bg-blue-600 hover:bg-blue-500 text-white shadow-blue-500/20',
btnDisabled: 'bg-blue-900/20 text-blue-800 cursor-not-allowed border border-blue-900/30',
itemHover: 'hover:bg-slate-800 text-white',
dropdown: 'bg-slate-900 border-slate-800 shadow-2xl shadow-black',
itemCard: 'bg-slate-900 border-slate-800 text-white shadow-lg shadow-black/20'
}
}[theme];
return (
<div className={`min-h-screen md:h-screen flex flex-col font-sans transition-colors duration-300 ${themeClasses.body} ${themeClasses.header.split(' ')[2]} overflow-x-hidden`}>
{/* HEADER */}
<header className={`flex-none flex items-center justify-between px-4 sm:px-8 py-4 border-b ${themeClasses.header} z-50`}>
<div className="flex items-center gap-4 sm:gap-6">
<div className="flex items-center gap-2 sm:gap-3">
<div className={`p-1.5 sm:p-2 rounded-xl ${theme === 'dark' ? 'bg-blue-600' : 'bg-black'} text-white shadow-lg`}>
<Shield className="w-4 h-4 sm:w-5 sm:h-5" />
</div>
<h1 className="text-lg sm:text-xl font-bold tracking-tight">Redac</h1>
</div>
<div className="flex items-center gap-2 ml-2 sm:ml-4 opacity-50">
<div className={`w-2 h-2 rounded-full ${apiStatus === 'online' ? 'bg-emerald-500' : 'bg-rose-500'}`} />
<span className="text-[9px] sm:text-[10px] font-bold uppercase tracking-widest">{apiStatus}</span>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<a
href="https://github.com/gni/redac"
target="_blank"
rel="noopener noreferrer"
className={`flex items-center gap-2 px-3 sm:px-4 py-2 border rounded-full text-[10px] sm:text-xs font-semibold transition-all ${themeClasses.panel} hover:border-blue-500 mr-2`}
>
<svg className="w-3.5 h-3.5 sm:w-4 sm:h-4 fill-current" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
<span className="hidden xs:inline">GitHub</span>
</a>
<div className="relative" ref={themeRef}>
<button onClick={() => setIsThemeOpen(!isThemeOpen)} className={`flex items-center gap-2 px-3 sm:px-4 py-2 border rounded-full text-[10px] sm:text-xs font-semibold transition-all ${themeClasses.panel} hover:border-blue-500`}>
<Palette className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> <span className="hidden xs:inline">{theme}</span>
</button>
{isThemeOpen && (
<div className={`absolute right-0 mt-2 w-40 sm:w-48 p-2 rounded-2xl z-[100] border ${themeClasses.dropdown}`}>
{['premium', 'light', 'dark'].map((t) => (
<button key={t} onClick={() => { setTheme(t as Theme); setIsThemeOpen(false); }} className={`w-full text-left px-3 sm:px-4 py-2 sm:py-2.5 text-[10px] sm:text-xs font-medium rounded-xl transition-colors ${theme === t ? 'bg-blue-600 text-white' : `${themeClasses.itemHover}`}`}>{t}</button>
))}
</div>
)}
</div>
<div className="relative" ref={langRef}>
<button onClick={() => setIsLangOpen(!isLangOpen)} className={`flex items-center gap-2 px-3 sm:px-4 py-2 border rounded-full text-[10px] sm:text-xs font-semibold transition-all ${themeClasses.panel} hover:border-blue-500`}>
<Languages className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> <span className="hidden xs:inline">{language}</span>
</button>
{isLangOpen && (
<div className={`absolute right-0 mt-2 w-40 sm:w-48 p-2 rounded-2xl z-[100] border ${themeClasses.dropdown}`}>
{['auto', 'en', 'fr'].map((l) => (
<button key={l} onClick={() => { setLanguage(l); setIsLangOpen(false); }} className={`w-full text-left px-3 sm:px-4 py-2 sm:py-2.5 text-[10px] sm:text-xs font-medium rounded-xl transition-colors ${language === l ? 'bg-blue-600 text-white' : `${themeClasses.itemHover}`}`}>{l}</button>
))}
</div>
)}
</div>
</div>
</header>
{/* MAIN WORKSPACE */}
<main className="flex-grow flex flex-col md:flex-row overflow-y-auto md:overflow-hidden">
{/* PANEL 1: SOURCE */}
<div className={`w-full md:w-1/2 flex flex-col border-b md:border-b-0 md:border-r flex-none md:flex-grow ${themeClasses.panel.split(' ')[1]}`}>
<div className={`flex-none flex items-center justify-between px-4 sm:px-8 py-4 border-b ${themeClasses.panelHeader}`}>
<div className="flex items-center gap-3 text-[10px] sm:text-xs font-bold uppercase tracking-widest"><Database className="w-4 h-4 text-blue-600" /> Source Document</div>
<div className="flex gap-2 sm:gap-3">
<button onClick={() => fileInputRef.current?.click()} className="p-1.5 rounded-lg hover:bg-black/10 transition-colors"><Upload className="w-4 h-4" /></button>
<button onClick={() => {setText(''); setResult(null);}} className="p-1.5 rounded-lg hover:bg-black/10 transition-colors text-rose-500"><Trash2 className="w-4 h-4" /></button>
<input type="file" ref={fileInputRef} onChange={(e) => {const f=e.target.files?.[0]; if(f){const r=new FileReader(); r.onload=(ev)=>setText(ev.target?.result as string); r.readAsText(f);}}} className="hidden" />
</div>
</div>
<div className="flex-grow md:flex-grow relative overflow-hidden bg-inherit h-[500px] md:h-auto md:min-h-0">
{loading && <div className="loading-progress"><div className="loading-progress-bar" /></div>}
{/* Error Alert */}
{error && (
<div className="absolute top-4 left-4 right-4 z-[60] animate-in slide-in-from-top-4 duration-300">
<div className="bg-rose-500 text-white px-4 py-3 rounded-xl shadow-2xl flex items-center justify-between gap-3 border border-rose-400">
<div className="flex items-center gap-3">
<Shield className="w-5 h-5 flex-none" />
<p className="text-[11px] sm:text-xs font-bold leading-tight uppercase tracking-wider">{error}</p>
</div>
<button onClick={() => setError(null)} className="p-1 hover:bg-white/20 rounded-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
<textarea
className={`w-full h-full p-4 sm:p-10 bg-transparent border-none outline-none text-[15px] sm:text-[16px] leading-[1.8] resize-none font-sans custom-scrollbar ${themeClasses.input}`}
placeholder="Enter your document content here..."
value={text}
onChange={(e) => setText(e.target.value)}
/>
</div>
<div className={`flex-none p-4 sm:p-8 border-t ${themeClasses.footer}`}>
<div className="flex gap-3 sm:gap-4 mb-4 overflow-x-auto pb-2 scrollbar-hide">
{EXAMPLES.map((ex, i) => (
<button key={i} onClick={() => { setText(ex.text); setLanguage(ex.lang); setResult(null); }} className={`px-3 sm:px-4 py-2 border rounded-xl text-[10px] sm:text-xs font-bold transition-all whitespace-nowrap ${theme === 'light' ? 'border-black hover:bg-black hover:text-white' : 'border-slate-200/20 hover:border-slate-400 bg-white/5'}`}>{ex.label}</button>
))}
</div>
<button onClick={handleRedact} disabled={loading || !text.trim()} className={`w-full py-4 sm:py-5 rounded-2xl font-bold text-xs sm:text-sm tracking-widest uppercase transition-all flex items-center justify-center gap-3 sm:gap-4 ${loading || !text.trim() ? `${themeClasses.btnDisabled}` : `${themeClasses.btn} shadow-xl active:scale-[0.98]`}`}>
{loading ? 'ANALYZING DOCUMENT...' : <><Zap className="w-4 h-4 fill-white" /> SANITIZE CONTENT</>}
</button>
</div>
</div>
{/* PANEL 2: RESULT */}
<div className={`w-full md:w-1/2 flex flex-col flex-none md:flex-grow ${themeClasses.output.split(' ')[0]}`}>
<div className={`flex-none flex items-center justify-between px-4 sm:px-8 py-4 border-b ${themeClasses.panelHeader}`}>
<div className="flex items-center gap-3 text-[10px] sm:text-xs font-bold uppercase tracking-widest text-blue-600"><CheckCircle2 className="w-4 h-4" /> Secured View</div>
{result && <button onClick={() => {navigator.clipboard.writeText(result.redacted_text); setCopied(true); setTimeout(()=>setCopied(false), 2000)}} className={`px-3 sm:px-4 py-1.5 rounded-full ${themeClasses.btn} text-[9px] sm:text-[10px] font-bold transition-all`}>{copied ? 'COPIED' : 'COPY RESULT'}</button>}
</div>
<div className={`flex-grow p-4 sm:p-10 font-sans text-[15px] sm:text-[16px] leading-[1.8] whitespace-pre-wrap overflow-y-auto custom-scrollbar h-[500px] md:h-auto md:min-h-0 ${themeClasses.output.split(' ')[1]}`}>
{!result ? (
<div className="h-full min-h-[200px] flex flex-col items-center justify-center opacity-20 text-center">
<Lock className="w-12 h-12 sm:w-16 sm:h-16 mb-4 stroke-1" />
<p className="font-bold tracking-widest uppercase text-[10px] sm:text-xs">Waiting for Sanitization</p>
</div>
) : (
<div className="animate-in fade-in duration-500">
{result.redacted_text.split(/(<[^>]+>)/g).map((part, i) => (
part.startsWith('<') && part.endsWith('>') ? (
<span key={i} className={`inline-block px-1.5 sm:px-2 py-0.5 mx-0.5 sm:mx-1 rounded font-bold text-[10px] sm:text-[12px] uppercase tracking-tighter ${themeClasses.tag}`}>{part}</span>
) : part
))}
</div>
)}
</div>
{/* RISK LIST FOOTER */}
{result && (
<div className={`flex-none h-auto md:h-48 border-t p-4 sm:p-6 overflow-y-auto custom-scrollbar ${themeClasses.footer}`}>
<div className="flex items-center gap-3 mb-4 opacity-60 font-bold text-[9px] sm:text-[10px] uppercase tracking-widest"><Fingerprint className="w-4 h-4" /> Detected Information</div>
<div className="flex flex-wrap gap-2">
{result.entities.map((ent, idx) => (
<div key={idx} className={`px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border flex items-center gap-2 sm:gap-3 ${themeClasses.itemCard}`}>
<span className="text-[8px] sm:text-[9px] font-black text-blue-600 uppercase">{ent.type}</span>
<span className={`text-[10px] sm:text-xs font-bold truncate max-w-[80px] sm:max-w-[120px]`}>"{ent.text}"</span>
<span className="text-[9px] sm:text-[10px] font-bold text-slate-400">{ent.score}%</span>
</div>
))}
</div>
</div>
)}
</div>
</main>
</div>
);
}
export default App;