File size: 17,249 Bytes
fbf73be 0e45313 1344296 0e45313 6f25cc6 0e45313 6f25cc6 0e45313 fbf73be 2639468 15f5e73 2639468 0e45313 fbf73be 0e45313 945586b 0e45313 6f25cc6 0e45313 fbf73be d4f8d42 0e45313 1344296 0e45313 fbf73be 0e45313 1344296 fbf73be 0e45313 fbf73be 0e45313 945586b 0e45313 1344296 0e45313 945586b 15f5e73 0e45313 d4f8d42 15f5e73 d755645 f5e8436 d755645 dfff2a2 15f5e73 d755645 f5e8436 d755645 dfff2a2 15f5e73 d755645 f5e8436 d755645 dfff2a2 15f5e73 fbf73be 0e45313 f40c8f3 d4b6cc6 d4f8d42 f8d02de 15f5e73 f8d02de 15f5e73 f8d02de 15f5e73 f8d02de d4f8d42 f8d02de 1344296 d4f8d42 f8d02de d4f8d42 1d20da5 d4f8d42 d755645 d4f8d42 0e45313 d4f8d42 0e45313 d4f8d42 f8d02de d4f8d42 1d20da5 d4f8d42 d755645 d4f8d42 15f5e73 f8d02de d4f8d42 15f5e73 605821c f8d02de 15f5e73 0e45313 6f25cc6 15f5e73 605821c 15f5e73 945586b 15f5e73 605821c 15f5e73 d4f8d42 0e45313 f8d02de 15f5e73 f8d02de 15f5e73 f5e8436 15f5e73 0e45313 2639468 0e45313 15f5e73 605821c f8d02de 0e45313 15f5e73 605821c d4f8d42 f8d02de 15f5e73 d4f8d42 15f5e73 f8d02de 15f5e73 6f25cc6 0e45313 15f5e73 d4f8d42 f8d02de 15f5e73 dfff2a2 f8d02de dfff2a2 f8d02de 15f5e73 d4f8d42 0e45313 d4f8d42 0e45313 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 | 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;
|