n8n anonyme

This commit is contained in:
nBiqoz
2025-06-12 23:17:56 +02:00
parent 07bce46fd7
commit 00ad46a1a6
29 changed files with 8458 additions and 1 deletions

195
app/api/anonymize/route.ts Normal file
View File

@@ -0,0 +1,195 @@
// Fichier : src/app/api/anonymize/route.ts
import { NextResponse } from "next/server";
import mammoth from "mammoth";
import * as pdfjs from "pdfjs-dist";
// --- Vos URLs Presidio ---
const PRESIDIO_ANALYZER_URL =
"http://ocs00s000ssow8kssossocco.51.68.233.212.sslip.io/analyze";
const PRESIDIO_ANONYMIZER_URL =
"http://r8gko4kcwwk4sso40cc0gkg8.51.68.233.212.sslip.io/anonymize";
// Fonction utilitaire pour extraire le texte d'un PDF
const extractTextWithPdfJs = async (pdfData: Uint8Array): Promise<string> => {
pdfjs.GlobalWorkerOptions.workerSrc = `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.min.mjs`;
const doc = await pdfjs.getDocument(pdfData).promise;
const numPages = doc.numPages;
let fullText = "";
for (let i = 1; i <= numPages; i++) {
const page = await doc.getPage(i);
const content = await page.getTextContent();
// ===================================================================
// === CORRECTION CRUCIALE DE L'EXTRACTION DE TEXTE ===
// ===================================================================
// On joint chaque morceau de texte trouvé avec un espace.
// Cela évite de coller des mots comme "beSi".
// Le nettoyage ultérieur se chargera des espaces en trop.
const pageText = content.items
.map((item: unknown) => ("str" in item ? item.str : ""))
.join(" ");
fullText += pageText + "\n"; // On ajoute un saut de ligne entre les pages
}
return fullText;
};
export async function POST(request: Request) {
try {
const formData = await request.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json(
{ error: "Aucun fichier reçu" },
{ status: 400 }
);
}
// --- Extraction du texte ---
let extractedText = "";
const fileArrayBuffer = await file.arrayBuffer();
if (file.type === "application/pdf") {
extractedText = await extractTextWithPdfJs(
new Uint8Array(fileArrayBuffer)
);
} else if (
file.type ===
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
) {
const { value } = await mammoth.extractRawText({
buffer: Buffer.from(fileArrayBuffer),
});
extractedText = value;
} else if (file.type.startsWith("text/")) {
extractedText = await file.text();
} else {
return NextResponse.json(
{ error: `Type de fichier non supporté: ${file.type}` },
{ status: 415 }
);
}
if (!extractedText.trim()) {
return NextResponse.json(
{ error: "Impossible d'extraire du texte de ce fichier." },
{ status: 400 }
);
}
// --- Nettoyage général du texte après extraction ---
const cleanedText = extractedText.replace(/\s+/g, " ").trim();
// La configuration d'analyse reste la même, complète et agressive
const analyzerPayload = {
text: cleanedText,
language: "fr",
ad_hoc_recognizers: [
{
name: "Email Recognizer",
supported_entity: "EMAIL_ADDRESS",
deny_list: ["[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+"],
},
{
name: "French Phone Recognizer",
supported_entity: "PHONE_NUMBER",
deny_list: ["\\b(0|\\+33|0033)[1-9]([-. ]?[0-9]{2}){4}\\b"],
},
{
name: "IBAN Recognizer",
supported_entity: "IBAN",
deny_list: ["\\b[A-Z]{2}[0-9]{2}(?:[ ]?[0-9]{4}){4,7}\\b"],
},
{
name: "BIC/SWIFT Recognizer",
supported_entity: "SWIFT_CODE",
deny_list: [
"\\b([A-Z]{6}[A-Z2-9][A-NP-Z0-9])(X{3}|[A-NP-Z0-9]{3})?\\b",
],
},
{
name: "Belgian Company Number",
supported_entity: "BE_COMPANY_NUMBER",
deny_list: ["\\bBE\\s*0[0-9]{3}[.]?[0-9]{3}[.]?[0-9]{3}\\b"],
},
{
name: "Company Name Recognizer",
supported_entity: "ORGANIZATION",
deny_list: [
"\\b[A-Z][a-zA-ZÀ-ÖØ-öø-ÿ-&' ]+\\s+(SPRL|SA|Partners|Solutions|Capital)\\b",
],
},
{
name: "Specific Company Recognizer",
supported_entity: "ORGANIZATION",
deny_list: [
"TechFlow Solutions SPRL",
"Innovation Capital Partners SA",
],
},
{
name: "Date Recognizer (DD/MM/YYYY)",
supported_entity: "DATE_TIME",
deny_list: [
"\\b(0[1-9]|[12][0-9]|3[01])[-/.](0[1-9]|1[012])[-/.](19|20)\\d\\d\\b",
],
},
],
};
// --- Le reste du processus ne change pas ---
const analyzeResponse = await fetch(PRESIDIO_ANALYZER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(analyzerPayload),
cache: "no-store",
});
if (!analyzeResponse.ok) {
const errorText = await analyzeResponse.text();
return NextResponse.json(
{
error: `Erreur de l'Analyzer [${analyzeResponse.status}]: ${errorText}`,
},
{ status: analyzeResponse.status }
);
}
const analysisResults = await analyzeResponse.json();
const anonymizeResponse = await fetch(PRESIDIO_ANONYMIZER_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: cleanedText,
analyzer_results: analysisResults,
anonymizers: { DEFAULT: { type: "replace", new_value: "<ANONYMISÉ>" } },
}),
cache: "no-store",
});
if (!anonymizeResponse.ok) {
const errorText = await anonymizeResponse.text();
return NextResponse.json(
{
error: `Erreur de l'Anonymizer [${anonymizeResponse.status}]: ${errorText}`,
},
{ status: anonymizeResponse.status }
);
}
const anonymizedData = await anonymizeResponse.json();
const finalResponse = { text: anonymizedData.text, items: analysisResults };
return NextResponse.json(finalResponse);
} catch (error) {
console.error("Erreur critique dans l'API Route:", error);
const errorMessage =
error instanceof Error ? error.message : "Erreur inconnue";
return NextResponse.json(
{ error: `Erreur interne du serveur: ${errorMessage}` },
{ status: 500 }
);
}
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

122
app/globals.css Normal file
View File

@@ -0,0 +1,122 @@
@import "tw-animate-css";
@import "tailwindcss";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
body {
font-family: var(--font-sans);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

34
app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "LeCercle IA - Anonimyzation",
description: "Anonimyzer vos documents de maniére sécurisée.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

174
app/page.tsx Normal file
View File

@@ -0,0 +1,174 @@
"use client";
import { useState, useCallback } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Upload, FileText, ShieldCheck, Download, Trash2, AlertCircle, X, Zap, Lock, Shield, Clock,
User, AtSign, MapPin, Cake, Home as HomeIcon, Venus, Phone, Building, Fingerprint, CreditCard, Check
} from "lucide-react";
// Interfaces et Données
interface ProcessedFile {
id: string; name: string; status: 'processing' | 'completed' | 'error';
timestamp: Date; originalSize?: string; processedSize?: string;
piiCount?: number; errorMessage?: string; processedBlob?: Blob;
}
interface AnonymizationOptions { [key: string]: boolean; }
const piiOptions = [
{ id: 'name', label: 'Nom & Prénom', icon: User },
{ id: 'email', label: 'Email', icon: AtSign },
{ id: 'location', label: 'Lieu', icon: MapPin },
{ id: 'birthdate', label: 'Date de naissance', icon: Cake },
{ id: 'address', label: 'Adresse', icon: HomeIcon },
{ id: 'gender', label: 'Genre / Sexe', icon: Venus },
{ id: 'phone', label: 'Téléphone', icon: Phone },
{ id: 'organization', label: 'Entreprise', icon: Building },
{ id: 'idNumber', label: 'N° Identification', icon: Fingerprint },
{ id: 'financial', label: 'Données financières', icon: CreditCard },
];
export default function Home() {
// === Déclaration des états (States) ===
const [file, setFile] = useState<File | null>(null);
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [isDragOver, setIsDragOver] = useState(false);
const [history, setHistory] = useState<ProcessedFile[]>([]);
const [error, setError] = useState<string | null>(null);
const [anonymizationOptions, setAnonymizationOptions] = useState<AnonymizationOptions>(
piiOptions.reduce((acc, option) => ({ ...acc, [option.id]: true }), {})
);
// === Fonctions utilitaires et Handlers ===
const handleOptionChange = (id: string) => setAnonymizationOptions(prev => ({ ...prev, [id]: !prev[id] }));
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { if (event.target.files?.length) { setFile(event.target.files[0]); setError(null); }};
const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); if (e.dataTransfer.files?.length) { setFile(e.dataTransfer.files[0]); setError(null); }}, []);
const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); }, []);
const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); }, []);
const formatFileSize = (bytes: number): string => { if (bytes === 0) return '0 Bytes'; const k = 1024; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; };
const clearFile = () => { setFile(null); setError(null); };
const clearHistory = () => setHistory([]);
const removeFromHistory = (id: string) => setHistory(prev => prev.filter(item => item.id !== id));
const handleDownload = (id: string) => { const fileToDownload = history.find(item => item.id === id); if (!fileToDownload?.processedBlob) return; const url = URL.createObjectURL(fileToDownload.processedBlob); const a = document.createElement('a'); a.href = url; a.download = `anonymized_${fileToDownload.name.split('.')[0] || 'file'}.txt`; a.click(); URL.revokeObjectURL(url); a.remove(); };
const getStatusInfo = (item: ProcessedFile) => { switch (item.status) { case 'completed': return { icon: <ShieldCheck className="h-4 w-4 text-white" />, color: 'bg-[#061717]' }; case 'error': return { icon: <AlertCircle className="h-4 w-4 text-white" />, color: 'bg-[#F7AB6E]' }; default: return { icon: <div className="h-2 w-2 rounded-full bg-[#F7AB6E] animate-pulse" />, color: 'bg-[#061717]' }; } };
// === Fonction principale pour l'appel à n8n ===
const processFile = async () => {
if (!file) return;
const n8nWebhookUrl = process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL;
if (!n8nWebhookUrl) {
setError("L'URL du service est manquante. Contactez l'administrateur.");
return;
}
setIsProcessing(true); setProgress(0); setError(null);
const fileId = `${Date.now()}-${file.name}`;
setHistory(prev => [{ id: fileId, name: file.name, status: 'processing', timestamp: new Date(), originalSize: formatFileSize(file.size) }, ...prev]);
setFile(null);
setProgress(10);
try {
const formData = new FormData();
// On attache le fichier binaire
formData.append('file', file);
// On attache les options cochées sous forme de chaîne JSON
formData.append('options', JSON.stringify(anonymizationOptions));
setProgress(30);
const response = await fetch(n8nWebhookUrl, { method: 'POST', body: formData });
setProgress(70);
if (!response.ok) {
const errorResult = await response.json().catch(() => ({ error: `Erreur serveur [${response.status}]` }));
throw new Error(errorResult.error || `Échec du traitement.`);
}
const processedBlob = await response.blob();
const piiCount = parseInt(response.headers.get('X-Pii-Count') || '0', 10);
setProgress(90);
setHistory(prev => prev.map(item => item.id === fileId ? { ...item, status: 'completed', processedSize: formatFileSize(processedBlob.size), piiCount, processedBlob } : item));
setProgress(100);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : "Une erreur inconnue est survenue.";
setError(errorMessage);
setHistory(prev => prev.map(item => item.id === fileId ? { ...item, status: 'error', errorMessage } : item));
} finally {
setIsProcessing(false); setTimeout(() => setProgress(0), 1000);
}
};
// === Rendu du composant (JSX) ===
return (
<div className="h-screen w-screen bg-[#061717] flex flex-col md:flex-row overflow-hidden">
{/* --- Sidebar (Historique) --- */}
<aside className="w-full md:w-80 md:flex-shrink-0 bg-[#061717] border-b-4 md:border-b-0 md:border-r-4 border-white flex flex-col shadow-[4px_0_0_0_black]">
<div className="p-4 border-b-4 border-white bg-[#F7AB6E] shadow-[0_4px_0_0_black] flex items-center justify-between">
<div className="flex items-center gap-3">
<Image src="/logo.svg" alt="LeCercle.IA Logo" width={32} height={32} />
<h2 className="text-lg font-black text-white uppercase tracking-wide">Historique</h2>
</div>
{history.length > 0 && <Button onClick={clearHistory} variant="ghost" size="icon" className="bg-[#061717] text-white border-2 border-white shadow-[3px_3px_0_0_black] hover:shadow-[1px_1px_0_0_black] active:shadow-none active:translate-x-0.5 active:translate-y-0.5"><Trash2 className="h-4 w-4" /></Button>}
</div>
<div className="flex-1 overflow-y-auto p-3 space-y-3">
{history.length === 0 ? (
<div className="text-center py-10 px-4 flex flex-col justify-center h-full">
<div className="w-14 h-14 bg-[#F7AB6E] border-4 border-white shadow-[6px_6px_0_0_black] mx-auto mb-4 flex items-center justify-center"><FileText className="h-7 w-7 text-white" /></div>
<p className="text-white font-black text-base uppercase">Aucun Document</p>
<p className="text-white/70 font-bold mt-1 text-xs">Vos fichiers apparaîtront ici.</p>
</div>
) : (
history.map((item) => {
const status = getStatusInfo(item);
return (<div key={item.id} className="bg-[#061717] border-2 border-white shadow-[4px_4px_0_0_black] p-3 transition-all"><div className="flex items-start justify-between"><div className="flex items-center gap-3 min-w-0"><div className={`flex-shrink-0 w-7 h-7 border-2 border-white shadow-[2px_2px_0_0_black] flex items-center justify-center ${status.color}`}>{status.icon}</div><div className="min-w-0"><p className="text-sm font-black text-white uppercase truncate" title={item.name}>{item.name}</p>{item.status === 'completed' && (<div className="flex items-center gap-2 mt-1"><span className="text-xs font-bold text-white/70">{item.originalSize} {item.processedSize}</span><Badge className="bg-[#F7AB6E] text-white border-2 border-white shadow-[2px_2px_0_0_black] font-black uppercase text-[10px] px-1.5 py-0">{item.piiCount} PII</Badge></div>)}{item.status === 'error' && <p className="text-xs font-bold text-[#F7AB6E] mt-1 uppercase">Erreur</p>}{item.status === 'processing' && <p className="text-xs font-bold text-[#F7AB6E] mt-1 uppercase">Traitement...</p>}</div></div><div className="flex items-center gap-1.5 ml-2">{item.status === 'completed' && (<Button onClick={() => handleDownload(item.id)} variant="ghost" size="icon" className="h-7 w-7 bg-[#061717] text-white border-2 border-white shadow-[2px_2px_0_0_black] hover:shadow-[1px_1px_0_0_black] active:shadow-none active:translate-x-0.5 active:translate-y-0.5"><Download className="h-3 w-3" /></Button>)}<Button onClick={() => removeFromHistory(item.id)} variant="ghost" size="icon" className="h-7 w-7 bg-[#F7AB6E] text-white border-2 border-white shadow-[2px_2px_0_0_black] hover:shadow-[1px_1px_0_0_black] active:shadow-none active:translate-x-0.5 active:translate-y-0.5"><X className="h-3 w-3" /></Button></div></div></div>);
})
)}
</div>
</aside>
{/* --- Contenu Principal --- */}
<main className="flex-1 flex flex-col items-center justify-center p-4 md:p-6 bg-[#061717] overflow-y-auto">
<div className="w-full max-w-xl flex flex-col h-full">
<header className="text-center pt-4">
<div className="inline-block p-2 bg-[#F7AB6E] border-2 border-white shadow-[6px_6px_0_0_black] mb-3"><Lock className="h-8 w-8 text-white" /></div>
<h1 className="text-3xl md:text-4xl font-black text-white uppercase tracking-tighter mb-1">LeCercle.IA</h1>
<p className="text-base md:text-lg font-bold text-white/90 uppercase tracking-wider">Anonymisation RGPD Sécurisé</p>
</header>
<Card className="flex-grow my-4 bg-[#061717] border-4 border-white shadow-[10px_10px_0_0_black] flex flex-col">
<CardContent className="p-4 md:p-6 space-y-4 flex-grow flex flex-col">
<div onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave} className={`relative border-4 border-dashed p-6 text-center transition-all duration-200 ${isDragOver ? 'border-[#F7AB6E] bg-white/5' : file ? 'border-[#F7AB6E] bg-white/5 border-solid' : 'border-white hover:border-[#F7AB6E] hover:bg-white/5'}`}>
<Input type="file" onChange={handleFileChange} className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" accept=".txt,.json,.csv,.pdf,.docx" />
<div className="flex flex-col items-center justify-center space-y-4">
<div className={`w-14 h-14 border-4 border-white shadow-[5px_5px_0_0_black] flex items-center justify-center transition-all duration-200 ${file ? 'bg-[#F7AB6E]' : 'bg-[#061717]'}`}>{file ? <FileText className="h-7 w-7 text-white" /> : <Upload className="h-7 w-7 text-white" />}</div>
{file ? (<div><p className="text-base font-black text-white uppercase">{file.name}</p><p className="text-sm font-bold text-white/70">{formatFileSize(file.size)}</p></div>) : (<div><p className="text-lg font-black text-white uppercase tracking-wide">Glissez votre document</p><p className="text-base font-bold text-white/70 uppercase mt-1">Ou cliquez ici</p></div>)}
</div>
</div>
<div className="flex-grow">
<h3 className="text-base font-black text-white uppercase tracking-wide mb-3">Options d'Anonymisation</h3>
<div className="grid grid-cols-2 lg:grid-cols-2 gap-x-4 gap-y-2">
{piiOptions.map((option) => (<label key={option.id} htmlFor={option.id} className="flex items-center gap-2 cursor-pointer group"><input type="checkbox" id={option.id} className="sr-only peer" checked={anonymizationOptions[option.id]} onChange={() => handleOptionChange(option.id)} /><div className="w-5 h-5 border-2 border-white shadow-[2px_2px_0_0_black] flex items-center justify-center transition-all peer-checked:bg-[#F7AB6E] peer-checked:border-[#F7AB6E]">{anonymizationOptions[option.id] && <Check className="h-4 w-4 text-white" />}</div><span className="font-bold text-xs text-white uppercase">{option.label}</span></label>))}
</div>
</div>
{isProcessing && (<div className="space-y-2"><div className="flex justify-between items-center"><span className="text-base font-black text-white uppercase">Traitement...</span><span className="text-lg font-black text-[#F7AB6E]">{Math.round(progress)}%</span></div><div className="h-4 bg-[#061717] border-2 border-white shadow-[3px_3px_0_0_black]"><div className="h-full bg-[#F7AB6E] transition-all duration-300" style={{ width: `${progress}%` }} /></div></div>)}
{error && (<div className="flex items-start gap-3 p-3 bg-[#F7AB6E] border-2 border-white shadow-[4px_4px_0_0_black]"><AlertCircle className="h-5 w-5 text-white flex-shrink-0 mt-0.5" /><p className="text-sm font-bold text-white uppercase">{error}</p></div>)}
<div className="flex items-stretch gap-3 pt-2">
<Button onClick={processFile} disabled={!file || isProcessing} className="flex-1 bg-[#F7AB6E] text-white border-4 border-white shadow-[6px_6px_0_0_black] hover:shadow-[3px_3px_0_0_black] active:shadow-none active:translate-x-1 active:translate-y-1 h-12 text-base font-black uppercase tracking-wide disabled:opacity-50">{isProcessing ? <div className="animate-spin rounded-full h-6 w-6 border-4 border-white border-t-transparent" /> : <><Zap className="h-5 w-5 mr-2" />Anonymiser</>}</Button>
{file && !isProcessing && <Button onClick={clearFile} className="h-12 w-12 p-0 bg-[#061717] text-white border-4 border-white shadow-[6px_6px_0_0_black] hover:shadow-[3px_3px_0_0_black] active:shadow-none active:translate-x-1 active:translate-y-1"><X className="h-6 w-6" /></Button>}
</div>
</CardContent>
</Card>
<div className="grid grid-cols-3 gap-3 pb-4">
{[{ icon: Shield, title: "RGPD", subtitle: "Conforme" }, { icon: Clock, title: "Rapide", subtitle: "Local" }, { icon: Lock, title: "Sécurisé", subtitle: "Sans Serveur" }].map((item, index) => (<div key={index} className="bg-[#061717] border-2 border-white shadow-[4px_4px_0_0_black] p-3 text-center"><div className="w-8 h-8 bg-[#F7AB6E] border-2 border-white shadow-[2px_2px_0_0_black] mx-auto mb-2 flex items-center justify-center"><item.icon className="h-4 w-4 text-white" /></div><p className="text-sm font-black text-white uppercase">{item.title}</p><p className="text-xs font-bold text-white/70 uppercase">{item.subtitle}</p></div>))}
</div>
</div>
</main>
</div>
);
}