Files
Anonyme/app/page.tsx
2025-06-14 21:08:43 +02:00

570 lines
22 KiB
TypeScript

"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,
Eye,
} from "lucide-react";
import MarkdownModal from "./components/MarkdownModal";
// Définition de la structure d'un objet page, exporté pour être réutilisé
export type PageObject = {
pageNumber: number;
htmlContent: string;
};
// Interface mise à jour pour utiliser la nouvelle structure PageObject
interface ProcessedFile {
id: string;
name: string;
status: "processing" | "completed" | "error";
timestamp: Date;
originalSize?: string;
processedSize?: string;
piiCount?: number;
errorMessage?: string;
processedBlob?: Blob;
textContent?: PageObject[];
}
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() {
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 }), {})
);
// CORRECTION : L'état de la modale attend maintenant la nouvelle structure de données
const [modalContent, setModalContent] = useState<PageObject[] | null>(null);
const handleOptionChange = (id: string) =>
setAnonymizationOptions((prev) => ({ ...prev, [id]: !prev[id] }));
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.length) {
setFile(e.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(() => {
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 handleDownloadTxt = (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 handlePreview = (id: string) => {
const fileToPreview = history.find((item) => item.id === id);
if (fileToPreview?.textContent) {
setModalContent(fileToPreview.textContent);
}
};
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]",
};
}
};
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();
formData.append("file", file);
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 result = await response.json();
const docData = result.anonymizedDocument;
if (!docData || !Array.isArray(docData.pages)) {
throw new Error(
"Format de réponse invalide du service d'anonymisation."
);
}
const textContent: PageObject[] = docData.pages;
const piiCount: number = docData.piiCount || 0;
const fullText = textContent
.map(
(page) =>
`--- Page ${page.pageNumber} ---\n${page.htmlContent.replace(
/<[^>]*>/g,
"\n"
)}`
)
.join("\n\n");
const processedBlob = new Blob([fullText], {
type: "text/plain;charset=utf-8",
});
setProgress(90);
setHistory((prev) =>
prev.map((item) =>
item.id === fileId
? {
...item,
status: "completed",
processedSize: formatFileSize(processedBlob.size),
piiCount,
processedBlob,
textContent,
}
: 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);
}
};
return (
<div className="h-screen w-screen bg-[#061717] flex flex-col md:flex-row overflow-hidden">
<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 ? (
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" && item.textContent && (
<Button
onClick={() => handlePreview(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"
>
<Eye className="h-3 w-3" />
</Button>
)}
{item.status === "completed" && (
<Button
onClick={() => handleDownloadTxt(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 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>
)}
</div>
</aside>
<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&apos;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>
<MarkdownModal
content={modalContent}
onClose={() => setModalContent(null)}
/>
</div>
);
}