570 lines
22 KiB
TypeScript
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'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>
|
|
);
|
|
}
|