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;