| 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; |
|
|