version nice
This commit is contained in:
@@ -19,15 +19,16 @@ export async function POST(req: NextRequest) {
|
||||
let fileContent = "";
|
||||
const fileType = file.type;
|
||||
|
||||
// --- LOGIQUE D'EXTRACTION DE TEXTE (INCHANGÉE) ---
|
||||
// --- LOGIQUE D'EXTRACTION DE TEXTE ---
|
||||
if (fileType === "application/pdf") {
|
||||
console.log("📄 Traitement PDF en cours...");
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
const data = await pdf(buffer);
|
||||
fileContent = data.text;
|
||||
fileContent = data.text || "";
|
||||
console.log("✅ Extraction PDF réussie, longueur:", fileContent.length);
|
||||
} catch (pdfError) {
|
||||
console.error("❌ Erreur PDF:", pdfError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Erreur traitement PDF: ${
|
||||
@@ -45,12 +46,13 @@ export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const result = await mammoth.extractRawText({ arrayBuffer });
|
||||
fileContent = result.value;
|
||||
fileContent = result.value || "";
|
||||
console.log(
|
||||
"✅ Extraction Word réussie, longueur:",
|
||||
fileContent.length
|
||||
);
|
||||
} catch (wordError) {
|
||||
console.error("❌ Erreur Word:", wordError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Erreur traitement Word: ${
|
||||
@@ -69,6 +71,7 @@ export async function POST(req: NextRequest) {
|
||||
fileContent.length
|
||||
);
|
||||
} catch (textError) {
|
||||
console.error("❌ Erreur texte:", textError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Erreur lecture texte: ${
|
||||
@@ -88,12 +91,19 @@ export async function POST(req: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier si c'est juste pour l'extraction de texte (lecture simple)
|
||||
const isSimpleExtraction =
|
||||
req.headers.get("x-simple-extraction") === "true";
|
||||
|
||||
if (isSimpleExtraction) {
|
||||
// Retourner juste le texte extrait
|
||||
return NextResponse.json({ text: fileContent }, { status: 200 });
|
||||
}
|
||||
|
||||
// ==========================================================
|
||||
// CONFIGURATION PRESIDIO ANALYZER (SIMPLIFIÉE)
|
||||
// CONFIGURATION PRESIDIO ANALYZER (pour l'anonymisation complète)
|
||||
// ==========================================================
|
||||
|
||||
// Toute la configuration (recognizers, allow_list, etc.) est maintenant dans le default.yaml du service.
|
||||
// L'API a juste besoin d'envoyer le texte et la langue.
|
||||
const analyzerConfig = {
|
||||
text: fileContent,
|
||||
language: "fr",
|
||||
@@ -104,70 +114,72 @@ export async function POST(req: NextRequest) {
|
||||
const presidioAnalyzerUrl =
|
||||
"http://analyzer.151.80.20.211.sslip.io/analyze";
|
||||
|
||||
const analyzeResponse = await fetch(presidioAnalyzerUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(analyzerConfig),
|
||||
});
|
||||
try {
|
||||
const analyzeResponse = await fetch(presidioAnalyzerUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(analyzerConfig),
|
||||
});
|
||||
|
||||
console.log("📊 Statut Analyzer:", analyzeResponse.status);
|
||||
if (!analyzeResponse.ok) {
|
||||
const errorBody = await analyzeResponse.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Erreur Analyzer: ${errorBody}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
console.log("📊 Statut Analyzer:", analyzeResponse.status);
|
||||
if (!analyzeResponse.ok) {
|
||||
const errorBody = await analyzeResponse.text();
|
||||
console.error("❌ Erreur Analyzer:", errorBody);
|
||||
// Fallback: retourner juste le texte si Presidio n'est pas disponible
|
||||
return NextResponse.json({ text: fileContent }, { status: 200 });
|
||||
}
|
||||
|
||||
const analyzerResults = await analyzeResponse.json();
|
||||
console.log("✅ Analyzer a trouvé", analyzerResults.length, "entités.");
|
||||
|
||||
// =========================================================================
|
||||
// CONFIGURATION PRESIDIO ANONYMIZER
|
||||
// =========================================================================
|
||||
|
||||
const anonymizerConfig = {
|
||||
text: fileContent,
|
||||
analyzer_results: analyzerResults,
|
||||
};
|
||||
|
||||
console.log("🔍 Appel à Presidio Anonymizer...");
|
||||
const presidioAnonymizerUrl =
|
||||
"http://anonymizer.151.80.20.211.sslip.io/anonymize";
|
||||
|
||||
const anonymizeResponse = await fetch(presidioAnonymizerUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(anonymizerConfig),
|
||||
});
|
||||
|
||||
console.log("📊 Statut Anonymizer:", anonymizeResponse.status);
|
||||
if (!anonymizeResponse.ok) {
|
||||
const errorBody = await anonymizeResponse.text();
|
||||
console.error("❌ Erreur Anonymizer:", errorBody);
|
||||
// Fallback: retourner juste le texte si Presidio n'est pas disponible
|
||||
return NextResponse.json({ text: fileContent }, { status: 200 });
|
||||
}
|
||||
|
||||
const anonymizerResult = await anonymizeResponse.json();
|
||||
console.log("✅ Anonymisation réussie.");
|
||||
|
||||
const result = {
|
||||
text: fileContent,
|
||||
anonymizedText: anonymizerResult.text,
|
||||
piiCount: analyzerResults.length,
|
||||
};
|
||||
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (presidioError) {
|
||||
console.error("❌ Erreur Presidio:", presidioError);
|
||||
// Fallback: retourner juste le texte extrait
|
||||
return NextResponse.json({ text: fileContent }, { status: 200 });
|
||||
}
|
||||
|
||||
const analyzerResults = await analyzeResponse.json();
|
||||
console.log("✅ Analyzer a trouvé", analyzerResults.length, "entités.");
|
||||
|
||||
// =========================================================================
|
||||
// CONFIGURATION PRESIDIO ANONYMIZER
|
||||
// =========================================================================
|
||||
|
||||
// L'Anonymizer lira la config d'anonymisation du default.yaml de l'Analyzer
|
||||
// ou vous pouvez définir des transformations spécifiques ici si besoin.
|
||||
// Pour commencer, on envoie juste les résultats de l'analyse.
|
||||
const anonymizerConfig = {
|
||||
text: fileContent,
|
||||
analyzer_results: analyzerResults,
|
||||
};
|
||||
|
||||
console.log("🔍 Appel à Presidio Anonymizer...");
|
||||
const presidioAnonymizerUrl =
|
||||
"http://anonymizer.151.80.20.211.sslip.io/anonymize";
|
||||
|
||||
const anonymizeResponse = await fetch(presidioAnonymizerUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify(anonymizerConfig),
|
||||
});
|
||||
|
||||
console.log("📊 Statut Anonymizer:", anonymizeResponse.status);
|
||||
if (!anonymizeResponse.ok) {
|
||||
const errorBody = await anonymizeResponse.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Erreur Anonymizer: ${errorBody}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const anonymizerResult = await anonymizeResponse.json();
|
||||
console.log("✅ Anonymisation réussie.");
|
||||
|
||||
const result = {
|
||||
anonymizedText: anonymizerResult.text,
|
||||
piiCount: analyzerResults.length,
|
||||
};
|
||||
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (err: unknown) {
|
||||
console.error("❌ Erreur générale:", err);
|
||||
return NextResponse.json(
|
||||
|
||||
181
app/components/AnonymizationInterface.tsx
Normal file
181
app/components/AnonymizationInterface.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { CheckCircle } from "lucide-react";
|
||||
|
||||
interface AnonymizationInterfaceProps {
|
||||
isProcessing: boolean;
|
||||
outputText?: string;
|
||||
sourceText?: string;
|
||||
}
|
||||
|
||||
export const AnonymizationInterface = ({
|
||||
isProcessing,
|
||||
outputText,
|
||||
sourceText,
|
||||
}: AnonymizationInterfaceProps) => {
|
||||
// Fonction pour détecter quels types de données ont été anonymisés
|
||||
const getAnonymizedDataTypes = () => {
|
||||
if (!outputText || !sourceText) return new Set();
|
||||
|
||||
const anonymizedTypes = new Set<string>();
|
||||
|
||||
// Détecter les patterns d'anonymisation dans le texte de sortie
|
||||
|
||||
// Noms (Prénoms, Noms de famille, Noms complets)
|
||||
if (outputText.includes("[Nom1]") || outputText.includes("[Nom")) {
|
||||
anonymizedTypes.add("Prénoms");
|
||||
anonymizedTypes.add("Noms de famille");
|
||||
anonymizedTypes.add("Noms complets");
|
||||
}
|
||||
|
||||
// Emails
|
||||
if (outputText.includes("[Email1]") || outputText.includes("[Email")) {
|
||||
anonymizedTypes.add("Adresses e-mail");
|
||||
}
|
||||
|
||||
// Téléphones
|
||||
if (
|
||||
outputText.includes("[Téléphone1]") ||
|
||||
outputText.includes("[Téléphone")
|
||||
) {
|
||||
anonymizedTypes.add("Numéros de téléphone");
|
||||
}
|
||||
|
||||
// Adresses
|
||||
if (outputText.includes("[Adresse1]") || outputText.includes("[Adresse")) {
|
||||
anonymizedTypes.add("Adresses");
|
||||
}
|
||||
|
||||
// Numéros d'ID / Sécurité sociale
|
||||
if (
|
||||
outputText.includes("[NuméroSS1]") ||
|
||||
outputText.includes("[NuméroSS") ||
|
||||
outputText.includes("[ID")
|
||||
) {
|
||||
anonymizedTypes.add("Numéros d'ID");
|
||||
}
|
||||
|
||||
// Dates
|
||||
if (
|
||||
outputText.includes("[Date") ||
|
||||
/\[\d{2}\/\d{2}\/\d{4}\]/.test(outputText)
|
||||
) {
|
||||
anonymizedTypes.add("Dates");
|
||||
}
|
||||
|
||||
// Valeurs monétaires
|
||||
if (outputText.includes("[Montant") || /\[\d+[€$]\]/.test(outputText)) {
|
||||
anonymizedTypes.add("Valeurs monétaires");
|
||||
}
|
||||
|
||||
// Noms de domaine
|
||||
if (outputText.includes("[Domaine") || /\[.*\.com\]/.test(outputText)) {
|
||||
anonymizedTypes.add("Noms de domaine");
|
||||
}
|
||||
|
||||
// Valeurs numériques
|
||||
if (
|
||||
/\[\d+\]/.test(outputText) &&
|
||||
!outputText.includes("[Téléphone") &&
|
||||
!outputText.includes("[Montant")
|
||||
) {
|
||||
anonymizedTypes.add("Valeurs numériques");
|
||||
}
|
||||
|
||||
// Texte personnalisé (si du texte a été modifié mais pas avec les patterns spécifiques)
|
||||
if (sourceText !== outputText && anonymizedTypes.size === 0) {
|
||||
anonymizedTypes.add("Texte personnalisé");
|
||||
}
|
||||
|
||||
return anonymizedTypes;
|
||||
};
|
||||
|
||||
// Structure exacte de SupportedDataTypes (récupérée dynamiquement)
|
||||
const supportedDataStructure = [
|
||||
{
|
||||
items: ["Prénoms", "Numéros de téléphone", "Noms de domaine"],
|
||||
},
|
||||
{
|
||||
items: ["Noms de famille", "Adresses", "Dates"],
|
||||
},
|
||||
{
|
||||
items: ["Noms complets", "Numéros d'ID", "Valeurs numériques"],
|
||||
},
|
||||
{
|
||||
items: ["Adresses e-mail", "Valeurs monétaires", "Texte personnalisé"],
|
||||
},
|
||||
];
|
||||
|
||||
if (isProcessing) {
|
||||
return (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-6">
|
||||
<div className="flex items-center justify-center space-x-3 mb-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-gray-500"></div>
|
||||
<h4 className="text-sm font-semibold text-gray-700">
|
||||
Anonymisation en cours...
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-gray-500 rounded-full animate-pulse"></div>
|
||||
<span className="text-xs text-gray-600">Analyse du contenu</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-500 rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.5s" }}
|
||||
></div>
|
||||
<span className="text-xs text-gray-600">
|
||||
Détection des données sensibles
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-2 h-2 bg-gray-500 rounded-full animate-pulse"
|
||||
style={{ animationDelay: "1s" }}
|
||||
></div>
|
||||
<span className="text-xs text-gray-600">
|
||||
Application de l'anonymisation
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (outputText) {
|
||||
const anonymizedTypes = getAnonymizedDataTypes();
|
||||
|
||||
return (
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-6">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<h4 className="text-sm font-semibold text-green-700">
|
||||
Anonymisation terminée avec succès
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs">
|
||||
{supportedDataStructure.map((column, columnIndex) => (
|
||||
<div key={columnIndex} className="flex flex-col space-y-2">
|
||||
{column.items.map((item, itemIndex) => {
|
||||
const isAnonymized = anonymizedTypes.has(item);
|
||||
return (
|
||||
<span
|
||||
key={itemIndex}
|
||||
className={
|
||||
isAnonymized
|
||||
? "text-green-700 font-medium"
|
||||
: "text-gray-400"
|
||||
}
|
||||
>
|
||||
{isAnonymized ? "✓" : "•"} {item}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
178
app/components/AnonymizationLogic.tsx
Normal file
178
app/components/AnonymizationLogic.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useState } from "react";
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
interface AnonymizationLogicProps {
|
||||
sourceText: string;
|
||||
fileContent: string;
|
||||
uploadedFile: File | null;
|
||||
setOutputText: (text: string) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setEntityMappings: (mappings: EntityMapping[]) => void;
|
||||
}
|
||||
|
||||
export const useAnonymization = ({
|
||||
sourceText,
|
||||
fileContent,
|
||||
uploadedFile,
|
||||
setOutputText,
|
||||
setError,
|
||||
setEntityMappings,
|
||||
}: AnonymizationLogicProps) => {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const anonymizeData = async () => {
|
||||
const textToProcess = sourceText || fileContent || "";
|
||||
|
||||
if (!textToProcess.trim()) {
|
||||
setError(
|
||||
"Veuillez saisir du texte à anonymiser ou télécharger un fichier"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
setOutputText("");
|
||||
setEntityMappings([]);
|
||||
|
||||
try {
|
||||
if (
|
||||
uploadedFile &&
|
||||
uploadedFile.type === "application/pdf" &&
|
||||
!fileContent
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", uploadedFile);
|
||||
|
||||
const response = await fetch("/api/process-document", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Erreur lors du traitement du PDF");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
if (data.anonymizedText) {
|
||||
setOutputText(data.anonymizedText);
|
||||
// TODO: Extraire les mappings depuis les résultats Presidio
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
// Simulation des mappings pour le fallback
|
||||
const mappings: EntityMapping[] = [];
|
||||
let anonymized = textToProcess;
|
||||
|
||||
// Noms
|
||||
const nameMatches = textToProcess.matchAll(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g);
|
||||
let nameCounter = 1;
|
||||
for (const match of nameMatches) {
|
||||
const replacement = `[Nom${nameCounter}]`;
|
||||
mappings.push({
|
||||
originalValue: match[0],
|
||||
anonymizedValue: replacement,
|
||||
entityType: "PERSON",
|
||||
startIndex: match.index!,
|
||||
endIndex: match.index! + match[0].length
|
||||
});
|
||||
anonymized = anonymized.replace(match[0], replacement);
|
||||
nameCounter++;
|
||||
}
|
||||
|
||||
// Téléphones
|
||||
const phoneMatches = textToProcess.matchAll(/\b0[1-9](?:[\s.-]?\d{2}){4}\b/g);
|
||||
let phoneCounter = 1;
|
||||
for (const match of phoneMatches) {
|
||||
const replacement = `[Téléphone${phoneCounter}]`;
|
||||
mappings.push({
|
||||
originalValue: match[0],
|
||||
anonymizedValue: replacement,
|
||||
entityType: "PHONE_NUMBER",
|
||||
startIndex: match.index!,
|
||||
endIndex: match.index! + match[0].length
|
||||
});
|
||||
anonymized = anonymized.replace(match[0], replacement);
|
||||
phoneCounter++;
|
||||
}
|
||||
|
||||
// Emails
|
||||
const emailMatches = textToProcess.matchAll(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g);
|
||||
let emailCounter = 1;
|
||||
for (const match of emailMatches) {
|
||||
const replacement = `[Email${emailCounter}]`;
|
||||
mappings.push({
|
||||
originalValue: match[0],
|
||||
anonymizedValue: replacement,
|
||||
entityType: "EMAIL_ADDRESS",
|
||||
startIndex: match.index!,
|
||||
endIndex: match.index! + match[0].length
|
||||
});
|
||||
anonymized = anonymized.replace(match[0], replacement);
|
||||
emailCounter++;
|
||||
}
|
||||
|
||||
// Adresses
|
||||
const addressMatches = textToProcess.matchAll(/\b\d{1,3}\s+[a-zA-Z\s]+,\s*\d{5}\s+[a-zA-Z\s]+\b/g);
|
||||
let addressCounter = 1;
|
||||
for (const match of addressMatches) {
|
||||
const replacement = `[Adresse${addressCounter}]`;
|
||||
mappings.push({
|
||||
originalValue: match[0],
|
||||
anonymizedValue: replacement,
|
||||
entityType: "LOCATION",
|
||||
startIndex: match.index!,
|
||||
endIndex: match.index! + match[0].length
|
||||
});
|
||||
anonymized = anonymized.replace(match[0], replacement);
|
||||
addressCounter++;
|
||||
}
|
||||
|
||||
// Numéros de sécurité sociale
|
||||
const ssnMatches = textToProcess.matchAll(/\b\d\s\d{2}\s\d{2}\s\d{2}\s\d{3}\s\d{3}\s\d{2}\b/g);
|
||||
let ssnCounter = 1;
|
||||
for (const match of ssnMatches) {
|
||||
const replacement = `[NuméroSS${ssnCounter}]`;
|
||||
mappings.push({
|
||||
originalValue: match[0],
|
||||
anonymizedValue: replacement,
|
||||
entityType: "FR_NIR",
|
||||
startIndex: match.index!,
|
||||
endIndex: match.index! + match[0].length
|
||||
});
|
||||
anonymized = anonymized.replace(match[0], replacement);
|
||||
ssnCounter++;
|
||||
}
|
||||
|
||||
setOutputText(anonymized);
|
||||
setEntityMappings(mappings);
|
||||
} catch (error) {
|
||||
console.error("Erreur anonymisation:", error);
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erreur lors de l'anonymisation"
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { anonymizeData, isProcessing };
|
||||
};
|
||||
48
app/components/DocumentPreview.tsx
Normal file
48
app/components/DocumentPreview.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { FileText } from "lucide-react";
|
||||
|
||||
interface DocumentPreviewProps {
|
||||
uploadedFile: File | null;
|
||||
fileContent: string;
|
||||
sourceText: string;
|
||||
}
|
||||
|
||||
export const DocumentPreview: React.FC<DocumentPreviewProps> = ({
|
||||
uploadedFile,
|
||||
fileContent,
|
||||
sourceText,
|
||||
}) => {
|
||||
if (!uploadedFile && (!sourceText || !sourceText.trim())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||
<div className="bg-orange-50 border-b border-orange-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<FileText className="h-5 w-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
{uploadedFile && (
|
||||
<p className="text-sm text-orange-600">
|
||||
{uploadedFile.name} • {(uploadedFile.size / 1024).toFixed(1)}{" "}
|
||||
KB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 max-h-64 overflow-y-auto">
|
||||
<pre className="text-sm text-gray-700 whitespace-pre-wrap font-mono">
|
||||
{sourceText || fileContent || "Aucun contenu à afficher"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
23
app/components/DownloadActions.tsx
Normal file
23
app/components/DownloadActions.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
interface DownloadActionsProps {
|
||||
outputText: string;
|
||||
}
|
||||
|
||||
export const useDownloadActions = ({ outputText }: DownloadActionsProps) => {
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(outputText);
|
||||
};
|
||||
|
||||
const downloadText = () => {
|
||||
const blob = new Blob([outputText], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "texte-anonymise.txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return { copyToClipboard, downloadText };
|
||||
};
|
||||
135
app/components/EntityMappingTable.tsx
Normal file
135
app/components/EntityMappingTable.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
interface EntityMappingTableProps {
|
||||
mappings: EntityMapping[];
|
||||
}
|
||||
|
||||
export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
|
||||
if (!mappings || mappings.length === 0) {
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base sm:text-lg font-medium text-[#092727]">
|
||||
Tableau de mapping des entités
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-500 text-center py-4 text-sm">
|
||||
Aucune entité sensible détectée dans le texte.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mt-6">
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base sm:text-lg font-medium text-[#092727]">
|
||||
Tableau de mapping des entités ({mappings.length} entité
|
||||
{mappings.length > 1 ? "s" : ""} anonymisée
|
||||
{mappings.length > 1 ? "s" : ""})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:px-6">
|
||||
{/* Version mobile : Cards empilées */}
|
||||
<div className="block sm:hidden space-y-4">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
Type d'entité
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e] text-xs"
|
||||
>
|
||||
{mapping.entityType}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-600 block mb-1">
|
||||
Valeur originale
|
||||
</span>
|
||||
<div className="font-mono text-xs bg-red-50 text-red-700 p-2 rounded border break-all">
|
||||
{mapping.originalValue}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-600 block mb-1">
|
||||
Valeur anonymisée
|
||||
</span>
|
||||
<div className="font-mono text-xs bg-green-50 text-green-700 p-2 rounded border break-all">
|
||||
{mapping.anonymizedValue}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Version desktop : Table classique */}
|
||||
<div className="hidden sm:block overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-[#092727] min-w-[120px]">
|
||||
Type d'entité
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
|
||||
Valeur originale
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
|
||||
Valeur anonymisée
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mappings.map((mapping, index) => (
|
||||
<TableRow key={index} className="hover:bg-gray-50">
|
||||
<TableCell className="py-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e]"
|
||||
>
|
||||
{mapping.entityType}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm bg-red-50 text-red-700 py-4 max-w-[200px] break-all">
|
||||
{mapping.originalValue}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm bg-green-50 text-green-700 py-4 max-w-[200px] break-all">
|
||||
{mapping.anonymizedValue}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
92
app/components/FileHandler.tsx
Normal file
92
app/components/FileHandler.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
interface FileHandlerProps {
|
||||
setUploadedFile: (file: File | null) => void;
|
||||
setSourceText: (text: string) => void;
|
||||
setFileContent: (content: string) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setIsLoadingFile?: (loading: boolean) => void; // Ajouter cette propriété
|
||||
}
|
||||
|
||||
export const useFileHandler = ({
|
||||
setUploadedFile,
|
||||
setSourceText,
|
||||
setFileContent,
|
||||
setError,
|
||||
setIsLoadingFile,
|
||||
}: FileHandlerProps) => {
|
||||
const handleFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploadedFile(file);
|
||||
setError(null);
|
||||
setSourceText("");
|
||||
setFileContent("");
|
||||
|
||||
if (file.type === "text/plain") {
|
||||
try {
|
||||
const text = await file.text();
|
||||
setFileContent(text);
|
||||
setSourceText(text);
|
||||
} catch {
|
||||
setError("Erreur lors de la lecture du fichier texte");
|
||||
setUploadedFile(null);
|
||||
}
|
||||
} else if (file.type === "application/pdf") {
|
||||
// Activer le loader immédiatement pour les PDF
|
||||
setIsLoadingFile?.(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await fetch("/api/process-document", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur HTTP: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
const extractedText = data.text || data.anonymizedText || "";
|
||||
|
||||
if (!extractedText || extractedText.trim().length === 0) {
|
||||
throw new Error(
|
||||
"Le fichier PDF ne contient pas de texte extractible"
|
||||
);
|
||||
}
|
||||
|
||||
setFileContent(extractedText);
|
||||
setSourceText(extractedText);
|
||||
} catch (error) {
|
||||
console.error("Erreur PDF:", error);
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erreur lors de la lecture du fichier PDF"
|
||||
);
|
||||
setUploadedFile(null);
|
||||
setFileContent("");
|
||||
setSourceText("");
|
||||
} finally {
|
||||
// Désactiver le loader une fois terminé
|
||||
setIsLoadingFile?.(false);
|
||||
}
|
||||
} else {
|
||||
setError(
|
||||
"Type de fichier non supporté. Veuillez utiliser un fichier TXT ou PDF."
|
||||
);
|
||||
setUploadedFile(null);
|
||||
}
|
||||
};
|
||||
|
||||
return { handleFileChange };
|
||||
};
|
||||
220
app/components/FileUploadComponent.tsx
Normal file
220
app/components/FileUploadComponent.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Upload, FileText, AlertTriangle } from "lucide-react";
|
||||
import { SampleTextComponent } from "./SampleTextComponent";
|
||||
import { SupportedDataTypes } from "./SupportedDataTypes";
|
||||
import { AnonymizationInterface } from "./AnonymizationInterface";
|
||||
|
||||
interface FileUploadComponentProps {
|
||||
uploadedFile: File | null;
|
||||
handleFileChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
sourceText: string;
|
||||
setSourceText: (text: string) => void;
|
||||
setUploadedFile: (file: File | null) => void;
|
||||
setFileContent: (content: string) => void;
|
||||
onAnonymize?: () => void;
|
||||
isProcessing?: boolean;
|
||||
canAnonymize?: boolean;
|
||||
isLoadingFile?: boolean;
|
||||
onRestart?: () => void;
|
||||
outputText?: string;
|
||||
}
|
||||
|
||||
export const FileUploadComponent = ({
|
||||
uploadedFile,
|
||||
handleFileChange,
|
||||
sourceText,
|
||||
setSourceText,
|
||||
setUploadedFile,
|
||||
setFileContent,
|
||||
onAnonymize,
|
||||
isProcessing = false,
|
||||
canAnonymize = false,
|
||||
isLoadingFile = false,
|
||||
onRestart,
|
||||
outputText,
|
||||
}: FileUploadComponentProps) => {
|
||||
// Si un fichier est uploadé ou qu'il y a du texte d'exemple, on affiche le preview
|
||||
if (uploadedFile || (sourceText && sourceText.trim())) {
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-6">
|
||||
{/* Preview du document avec en-tête simple */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||
<div className="bg-orange-50 border-b border-orange-200 px-4 sm:px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<FileText className="h-4 w-4 sm:h-5 sm:w-5 text-orange-600" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{uploadedFile ? (
|
||||
<p className="text-xs sm:text-sm text-orange-600 truncate">
|
||||
{uploadedFile.name} •{" "}
|
||||
{(uploadedFile.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs sm:text-sm text-orange-600">
|
||||
Demo - Exemple de texte
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 sm:p-6">
|
||||
{/* Zone de texte avec limite de hauteur et scroll */}
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 sm:p-4 max-h-48 overflow-y-auto">
|
||||
{isLoadingFile ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 sm:h-6 sm:w-6 border-b-2 border-[#f7ab6e]"></div>
|
||||
<span className="text-xs sm:text-sm text-gray-600">
|
||||
Chargement du fichier en cours...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs sm:text-sm text-gray-700 whitespace-pre-wrap font-mono">
|
||||
{sourceText || "Aucun contenu à afficher"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Disclaimer déplacé en dessous du texte */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-start gap-2 p-3 bg-[#f7ab6e] bg-opacity-10 border border-[#f7ab6e] border-opacity-30 rounded-lg">
|
||||
<AlertTriangle className="h-4 w-4 text-[#f7ab6e] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-[10px] sm:text-[11px] text-[#092727] leading-relaxed">
|
||||
Cet outil IA peut ne pas détecter toutes les informations
|
||||
sensibles.
|
||||
<br />
|
||||
Vérifiez le résultat avant de le partager.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Boutons d'action - Responsive mobile */}
|
||||
{canAnonymize && !isLoadingFile && (
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-4">
|
||||
{/* Bouton Anonymiser en premier */}
|
||||
{onAnonymize && (
|
||||
<button
|
||||
onClick={onAnonymize}
|
||||
disabled={isProcessing}
|
||||
className="w-full sm:w-auto bg-[#f7ab6e] hover:bg-[#f7ab6e]/90 disabled:opacity-50 disabled:cursor-not-allowed text-white px-6 py-3 rounded-lg text-sm font-medium transition-colors duration-300 flex items-center justify-center space-x-3"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
<span>Anonymisation en cours...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Anonymiser mes données</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bouton Recommencer */}
|
||||
{onRestart && (
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="w-full sm:w-auto bg-gray-500 hover:bg-gray-600 text-white px-6 py-3 rounded-lg text-sm font-medium transition-colors duration-300 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<span>Recommencer</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affichage conditionnel : Interface d'anonymisation OU Types de données supportées */}
|
||||
{isProcessing || outputText ? (
|
||||
<AnonymizationInterface
|
||||
isProcessing={isProcessing}
|
||||
outputText={outputText}
|
||||
sourceText={sourceText}
|
||||
/>
|
||||
) : (
|
||||
<SupportedDataTypes />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Si pas de fichier ni de texte, on affiche la zone de drop
|
||||
return (
|
||||
<div className="w-full flex flex-col space-y-5">
|
||||
{/* Drop Zone - Responsive */}
|
||||
<label className="flex flex-col items-center justify-center cursor-pointer group transition-all duration-300 border-2 border-dashed border-[#092727] rounded-xl bg-gray-50 hover:bg-gray-100 hover:border-[#0a3030] p-6 sm:p-8">
|
||||
{/* Upload Icon */}
|
||||
<div className="w-12 h-12 sm:w-16 sm:h-16 bg-[#f7ab6e] group-hover:bg-[#f7ab6e]/75 rounded-full flex items-center justify-center mb-4 transition-colors duration-300">
|
||||
<Upload className="h-6 w-6 sm:h-8 sm:w-8 text-white transition-colors duration-300" />
|
||||
</div>
|
||||
|
||||
{/* Main Text */}
|
||||
<h3 className="text-base sm:text-lg font-semibold text-[#092727] mb-2 group-hover:text-[#0a3030] transition-colors duration-300 text-center">
|
||||
Déposer votre fichier ici
|
||||
</h3>
|
||||
<p className="text-sm sm:text-base text-[#092727] opacity-80 mb-4 text-center group-hover:opacity-90 transition-opacity duration-300">
|
||||
ou cliquez pour sélectionner un fichier
|
||||
</p>
|
||||
|
||||
{/* File Info */}
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 text-xs sm:text-sm text-[#092727] opacity-70">
|
||||
<span>📄 Fichiers TXT, PDF</span>
|
||||
<span className="hidden sm:inline">•</span>
|
||||
<span>Max 5MB</span>
|
||||
</div>
|
||||
|
||||
{/* Hidden Input */}
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
accept=".txt,.pdf"
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Bouton d'exemple repositionné juste en dessous */}
|
||||
<div className="flex justify-center">
|
||||
<SampleTextComponent
|
||||
setSourceText={setSourceText}
|
||||
setFileContent={setFileContent}
|
||||
setUploadedFile={setUploadedFile}
|
||||
variant="button"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Supported Data Types */}
|
||||
<SupportedDataTypes />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
92
app/components/ProgressBar.tsx
Normal file
92
app/components/ProgressBar.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Check, Upload, Eye, Shield } from "lucide-react";
|
||||
|
||||
interface ProgressBarProps {
|
||||
currentStep: number;
|
||||
steps: string[];
|
||||
}
|
||||
|
||||
export const ProgressBar = ({ currentStep, steps }: ProgressBarProps) => {
|
||||
// Icônes pour chaque étape
|
||||
const getStepIcon = (stepNumber: number, isCompleted: boolean) => {
|
||||
if (isCompleted) {
|
||||
return <Check className="h-3 w-3" />;
|
||||
}
|
||||
|
||||
switch (stepNumber) {
|
||||
case 1:
|
||||
return <Upload className="h-3 w-3" />;
|
||||
case 2:
|
||||
return <Eye className="h-3 w-3" />;
|
||||
case 3:
|
||||
return <Shield className="h-3 w-3" />;
|
||||
default:
|
||||
return stepNumber;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-2xl mx-auto mb-4 px-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex items-center justify-between w-full max-w-xs sm:max-w-md">
|
||||
{steps.map((step, index) => {
|
||||
const stepNumber = index + 1;
|
||||
const isCompleted = stepNumber < currentStep;
|
||||
const isCurrent = stepNumber === currentStep;
|
||||
|
||||
return (
|
||||
<div key={index} className="flex items-center">
|
||||
{/* Step Circle */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-5 h-5 sm:w-6 sm:h-6 rounded-full flex items-center justify-center font-medium text-xs transition-all duration-300 ${
|
||||
isCompleted
|
||||
? "bg-[#f7ab6e] text-white"
|
||||
: isCurrent
|
||||
? "bg-[#f7ab6e] text-white ring-2 ring-[#f7ab6e] ring-opacity-20"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
{getStepIcon(stepNumber, isCompleted)}
|
||||
</div>
|
||||
<span
|
||||
className={`mt-1 text-[8px] sm:text-[10px] font-medium text-center max-w-[60px] sm:max-w-none leading-tight ${
|
||||
isCompleted || isCurrent
|
||||
? "text-[#092727]"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
style={{
|
||||
wordBreak: 'break-word',
|
||||
hyphens: 'auto'
|
||||
}}
|
||||
>
|
||||
{step === "Anonymisation" ? (
|
||||
<>
|
||||
<span className="hidden sm:inline">Anonymisation</span>
|
||||
<span className="sm:hidden">Anonym.</span>
|
||||
</>
|
||||
) : (
|
||||
step
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex-1 mx-2 sm:mx-4">
|
||||
<div
|
||||
className={`h-0.5 w-6 sm:w-12 transition-all duration-300 ${
|
||||
stepNumber < currentStep
|
||||
? "bg-[#f7ab6e]"
|
||||
: "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
67
app/components/ResultPreviewComponent.tsx
Normal file
67
app/components/ResultPreviewComponent.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Copy, Download, AlertTriangle } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
interface ResultPreviewComponentProps {
|
||||
outputText: string;
|
||||
copyToClipboard: () => void;
|
||||
downloadText: () => void;
|
||||
highlightEntities: (text: string) => ReactNode;
|
||||
}
|
||||
|
||||
export const ResultPreviewComponent = ({
|
||||
outputText,
|
||||
copyToClipboard,
|
||||
downloadText,
|
||||
highlightEntities,
|
||||
}: ResultPreviewComponentProps) => {
|
||||
if (!outputText) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-[#f7ab6e] border-opacity-30 pb-2">
|
||||
<h3 className="text-lg font-medium text-[#092727]">
|
||||
Document anonymisé
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
disabled={!outputText}
|
||||
className="p-2 text-[#092727] hover:text-[#f7ab6e] disabled:opacity-50"
|
||||
title="Copier"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={downloadText}
|
||||
disabled={!outputText}
|
||||
className="p-2 text-[#092727] hover:text-[#f7ab6e] disabled:opacity-50"
|
||||
title="Télécharger"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-[#f7ab6e] border-opacity-30 rounded-lg bg-white min-h-[400px] flex flex-col">
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
<div className="h-full min-h-[300px] text-[#092727] whitespace-pre-wrap overflow-y-auto">
|
||||
<div className="leading-relaxed">
|
||||
{highlightEntities(outputText)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-[#f7ab6e] border-opacity-30">
|
||||
<div className="flex items-start gap-2 p-2 bg-[#f7ab6e] bg-opacity-10 rounded-md">
|
||||
<AlertTriangle className="h-4 w-4 text-[#f7ab6e] mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm text-[#092727]">
|
||||
Vérifiez le résultat pour vous assurer que toutes les informations
|
||||
privées sont supprimées et éviter une divulgation accidentelle.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
46
app/components/SampleTextComponent.tsx
Normal file
46
app/components/SampleTextComponent.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
interface SampleTextComponentProps {
|
||||
setSourceText: (text: string) => void;
|
||||
setFileContent: (content: string) => void;
|
||||
setUploadedFile: (file: File | null) => void;
|
||||
variant?: "default" | "button"; // Ajouter une variante
|
||||
}
|
||||
|
||||
export const SampleTextComponent = ({
|
||||
setSourceText,
|
||||
setFileContent,
|
||||
setUploadedFile,
|
||||
}: SampleTextComponentProps) => {
|
||||
const loadSampleText = () => {
|
||||
const sampleText = `Date : 15 mars 2025
|
||||
Dans le cadre du litige opposant Madame Els Vandermeulen (née le 12/08/1978, demeurant 45 Avenue Louise, 1050 Ixelles, Tel: 02/456.78.90) à Monsieur Karel Derycke, gérant de la SPRL DigitalConsult (BCE: 0123.456.789), nous analysons les éléments suivants :
|
||||
|
||||
**Contexte financier :**
|
||||
Le contrat de prestation signé le 3 janvier 2024 prévoyait un montant de 75 000 € HTVA. Les virements effectués depuis le compte IBAN BE68 5390 0754 7034 (BNP Paribas Fortis) vers le compte bénéficiaire BE71 0961 2345 6789 montrent des irrégularités.
|
||||
|
||||
**Témoins clés :**
|
||||
|
||||
- Dr. Marie Claes (expert-comptable, n° IEC: 567890)
|
||||
- M. Pieter Van Der Berg (consultant IT, email: p.vanderberg@itconsult.be)
|
||||
|
||||
**Données sensibles :**
|
||||
Le serveur compromis contenait 12 000 dossiers clients avec numéros de registre national. L’incident du 28 février 2024 a exposé les données personnelles stockées sur l’adresse IP 10.0.0.45 dans les bureaux situés Rue de la Loi 200, 1040 Etterbeek.
|
||||
|
||||
|
||||
Coordonnées bancaires : BE43 0017 5555 5557 (CBC Banque)
|
||||
TVA intracommunautaire : BE0987.654.321`;
|
||||
setSourceText(sampleText);
|
||||
setFileContent(sampleText);
|
||||
setUploadedFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={loadSampleText}
|
||||
className="bg-[#f7ab6e] hover:bg-[#f7ab6e]/90 cursor-pointer text-white px-6 py-3 rounded-lg text-sm font-medium transition-colors duration-300"
|
||||
>
|
||||
Essayez avec un texte d'exemple
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
31
app/components/SupportedDataTypes.tsx
Normal file
31
app/components/SupportedDataTypes.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
export const SupportedDataTypes = () => {
|
||||
return (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-xl p-6">
|
||||
<h4 className="text-sm font-semibold text-[#092727] mb-4">
|
||||
Types de données supportées :
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs text-[#092727] opacity-80">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<span>• Prénoms</span>
|
||||
<span>• Numéros de téléphone</span>
|
||||
<span>• Noms de domaine</span>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<span>• Noms de famille</span>
|
||||
<span>• Adresses</span>
|
||||
<span>• Dates</span>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<span>• Noms complets</span>
|
||||
<span>• Numéros d'ID</span>
|
||||
<span>• Valeurs numériques</span>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2">
|
||||
<span>• Adresses e-mail</span>
|
||||
<span>• Valeurs monétaires</span>
|
||||
<span>• Texte personnalisé</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -25,7 +25,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased w-screen bg-red-500`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
|
||||
569
app/page.tsx
569
app/page.tsx
@@ -1,485 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Upload, Download, Copy, AlertTriangle } from "lucide-react";
|
||||
import { FileUploadComponent } from "./components/FileUploadComponent";
|
||||
import { ResultPreviewComponent } from "./components/ResultPreviewComponent";
|
||||
import { EntityMappingTable } from "./components/EntityMappingTable";
|
||||
import { ProgressBar } from "./components/ProgressBar";
|
||||
import { useFileHandler } from "./components/FileHandler";
|
||||
import { useAnonymization } from "./components/AnonymizationLogic";
|
||||
import { useDownloadActions } from "./components/DownloadActions";
|
||||
import { highlightEntities } from "./utils/highlightEntities";
|
||||
|
||||
export type PageObject = {
|
||||
pageNumber: number;
|
||||
htmlContent: string;
|
||||
};
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [sourceText, setSourceText] = useState<string>("");
|
||||
const [outputText, setOutputText] = useState<string>("");
|
||||
const [activeTab, setActiveTab] = useState<"text" | "url">("text");
|
||||
const [urlInput, setUrlInput] = useState<string>("");
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [sourceText, setSourceText] = useState("");
|
||||
const [outputText, setOutputText] = useState("");
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null); // Nouveau state pour le fichier
|
||||
const [fileContent, setFileContent] = useState<string>(""); // Nouveau state pour le contenu
|
||||
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||
const [entityMappings, setEntityMappings] = useState<EntityMapping[]>([]);
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files?.length) {
|
||||
const selectedFile = e.target.files[0];
|
||||
setUploadedFile(selectedFile); // Stocker le fichier
|
||||
setSourceText(""); // EFFACER le texte existant quand on upload un fichier
|
||||
setError(null);
|
||||
const progressSteps = ["Téléversement", "Prévisualisation", "Anonymisation"];
|
||||
|
||||
try {
|
||||
// Traitement des fichiers texte - juste lire le contenu
|
||||
if (
|
||||
selectedFile.type === "text/plain" ||
|
||||
selectedFile.name.endsWith(".txt")
|
||||
) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content); // Stocker le contenu sans l'afficher
|
||||
};
|
||||
reader.readAsText(selectedFile);
|
||||
}
|
||||
// Pour les PDF - juste stocker le fichier, pas de traitement automatique
|
||||
else if (
|
||||
selectedFile.type === "application/pdf" ||
|
||||
selectedFile.name.endsWith(".pdf")
|
||||
) {
|
||||
setFileContent(""); // Pas de contenu texte pour les PDF
|
||||
}
|
||||
// Traitement des fichiers Word (optionnel)
|
||||
else if (
|
||||
selectedFile.name.endsWith(".docx") ||
|
||||
selectedFile.name.endsWith(".doc")
|
||||
) {
|
||||
setError(
|
||||
"Les fichiers Word ne sont pas encore supportés. Veuillez convertir en PDF ou texte."
|
||||
);
|
||||
setUploadedFile(null);
|
||||
return;
|
||||
} else {
|
||||
setError(
|
||||
"Format de fichier non supporté. Veuillez utiliser un fichier PDF ou texte."
|
||||
);
|
||||
setUploadedFile(null);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erreur lors du traitement du fichier"
|
||||
);
|
||||
setUploadedFile(null);
|
||||
}
|
||||
}
|
||||
const getCurrentStep = () => {
|
||||
if (outputText) return 3;
|
||||
if (uploadedFile || (sourceText && sourceText.trim())) return 2;
|
||||
return 1;
|
||||
};
|
||||
|
||||
const loadSampleText = () => {
|
||||
const sampleText = `Bonjour,
|
||||
|
||||
Je suis Jean Dupont, né le 15 mars 1985. Mon numéro de téléphone est le 06 12 34 56 78 et mon email est jean.dupont@email.com.
|
||||
|
||||
Mon adresse est 123 rue de la Paix, 75001 Paris.
|
||||
|
||||
Mon numéro de sécurité sociale est 1 85 03 75 123 456 78.
|
||||
|
||||
Cordialement,
|
||||
Jean Dupont`;
|
||||
setSourceText(sampleText);
|
||||
};
|
||||
|
||||
const anonymizeData = async () => {
|
||||
// Si un fichier est uploadé, traiter le fichier
|
||||
if (uploadedFile) {
|
||||
if (
|
||||
uploadedFile.type === "application/pdf" ||
|
||||
uploadedFile.name.endsWith(".pdf")
|
||||
) {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
setOutputText("");
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", uploadedFile);
|
||||
|
||||
const response = await fetch("/api/process-document", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Erreur lors du traitement du PDF");
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.anonymizedText) {
|
||||
setOutputText(result.anonymizedText);
|
||||
} else {
|
||||
throw new Error(result.error || "Erreur lors de l'anonymisation");
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erreur lors du traitement du fichier"
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
// Pour les fichiers texte, utiliser le contenu lu
|
||||
if (!fileContent.trim()) {
|
||||
setError("Le fichier ne contient pas de texte");
|
||||
return;
|
||||
}
|
||||
// Utiliser fileContent au lieu de sourceText pour l'anonymisation
|
||||
const textToAnonymize = fileContent;
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
setOutputText("");
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const anonymized = textToAnonymize
|
||||
.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "[Nom1]")
|
||||
.replace(/\b0[1-9](?:[\s.-]?\d{2}){4}\b/g, "[Téléphone1]")
|
||||
.replace(
|
||||
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
|
||||
"[Email1]"
|
||||
)
|
||||
.replace(
|
||||
/\b\d{1,3}\s+[a-zA-Z\s]+,\s*\d{5}\s+[a-zA-Z\s]+\b/g,
|
||||
"[Adresse1]"
|
||||
)
|
||||
.replace(
|
||||
/\b\d\s\d{2}\s\d{2}\s\d{2}\s\d{3}\s\d{3}\s\d{2}\b/g,
|
||||
"[NuméroSS1]"
|
||||
);
|
||||
|
||||
setOutputText(anonymized);
|
||||
} catch {
|
||||
setError("Erreur lors de l'anonymisation");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Si pas de fichier, traiter le texte saisi manuellement
|
||||
if (!sourceText.trim()) {
|
||||
setError(
|
||||
"Veuillez saisir du texte à anonymiser ou télécharger un fichier"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
// Fonction pour recommencer (retourner à l'état initial)
|
||||
const handleRestart = () => {
|
||||
setSourceText("");
|
||||
setOutputText("");
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const anonymized = sourceText
|
||||
.replace(/\b[A-Z][a-z]+ [A-Z][a-z]+\b/g, "[Nom1]")
|
||||
.replace(/\b0[1-9](?:[\s.-]?\d{2}){4}\b/g, "[Téléphone1]")
|
||||
.replace(
|
||||
/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g,
|
||||
"[Email1]"
|
||||
)
|
||||
.replace(
|
||||
/\b\d{1,3}\s+[a-zA-Z\s]+,\s*\d{5}\s+[a-zA-Z\s]+\b/g,
|
||||
"[Adresse1]"
|
||||
)
|
||||
.replace(
|
||||
/\b\d\s\d{2}\s\d{2}\s\d{2}\s\d{3}\s\d{3}\s\d{2}\b/g,
|
||||
"[NuméroSS1]"
|
||||
);
|
||||
|
||||
setOutputText(anonymized);
|
||||
} catch {
|
||||
setError("Erreur lors de l'anonymisation");
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
setUploadedFile(null);
|
||||
setFileContent("");
|
||||
setError(null);
|
||||
setIsLoadingFile(false);
|
||||
setEntityMappings([]);
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(outputText);
|
||||
};
|
||||
// Hooks personnalisés pour la logique métier
|
||||
const { handleFileChange } = useFileHandler({
|
||||
setUploadedFile,
|
||||
setSourceText,
|
||||
setFileContent,
|
||||
setError,
|
||||
setIsLoadingFile, // Passer le setter
|
||||
});
|
||||
|
||||
const downloadText = () => {
|
||||
const blob = new Blob([outputText], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "texte-anonymise.txt";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
const { anonymizeData, isProcessing } = useAnonymization({
|
||||
sourceText,
|
||||
fileContent,
|
||||
uploadedFile,
|
||||
setOutputText,
|
||||
setError,
|
||||
setEntityMappings,
|
||||
});
|
||||
|
||||
const { copyToClipboard, downloadText } = useDownloadActions({ outputText });
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#092727]">
|
||||
{" "}
|
||||
{/* Fond vert foncé */}
|
||||
<div className="min-h-screen bg-white w-full">
|
||||
{/* Header */}
|
||||
<div className="bg-[#092727] border-b border-white border-opacity-20">
|
||||
{" "}
|
||||
{/* Header vert foncé avec bordure blanche */}
|
||||
<div className="max-w-6xl mx-auto px-4 py-6">
|
||||
<h1 className="text-3xl font-bold text-white text-center mb-2">
|
||||
{" "}
|
||||
{/* Titre en blanc */}
|
||||
<div className="max-w-6xl mx-auto px-2 sm:px-4 py-6 sm:py-8">
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl lg:text-4xl font-bold text-white text-center mb-2 leading-tight">
|
||||
OUTIL D'ANONYMISATION DE DONNÉES
|
||||
</h1>
|
||||
<p className="text-white text-opacity-80 text-center text-sm sm:text-base lg:text-lg px-2">
|
||||
Protégez vos informations sensibles en quelques clics
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left Panel - Source */}
|
||||
<div className="space-y-4">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-white border-opacity-20">
|
||||
<button
|
||||
onClick={() => setActiveTab("text")}
|
||||
className={`px-4 py-2 font-medium border-b-2 transition-colors ${
|
||||
activeTab === "text"
|
||||
? "border-[#f7ab6e] bg-[#f7ab6e] text-[#092727]"
|
||||
: "border-transparent text-white hover:text-[#f7ab6e]"
|
||||
}`}
|
||||
>
|
||||
Texte Source
|
||||
</button>
|
||||
<button
|
||||
disabled
|
||||
className="px-4 py-2 font-medium border-b-2 transition-colors border-transparent text-white opacity-50 cursor-not-allowed relative group"
|
||||
>
|
||||
Saisir URL
|
||||
<span className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-[#f7ab6e] text-[#092727] text-xs px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
|
||||
À venir
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto px-2 sm:px-4 py-4 sm:py-8 space-y-4">
|
||||
{/* Progress Bar */}
|
||||
<ProgressBar currentStep={getCurrentStep()} steps={progressSteps} />
|
||||
|
||||
{/* Content Area - même taille que droite */}
|
||||
<div className="border border-white border-opacity-20 rounded-md bg-[#092727] h-[500px] flex flex-col">
|
||||
{/* Zone de contenu - prend tout l'espace disponible */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeTab === "text" ? (
|
||||
<div className="h-full">
|
||||
{/* Zone de texte - prend TOUT l'espace */}
|
||||
<div className="h-full relative">
|
||||
{/* Placeholder conditionnel : affiché seulement si pas de texte ET pas de fichier uploadé */}
|
||||
{sourceText === "" && !uploadedFile && (
|
||||
<div className="absolute top-3 left-3 text-white text-opacity-60 text-sm pointer-events-none z-10">
|
||||
Tapez votre texte ici ou{" "}
|
||||
<span
|
||||
onClick={loadSampleText}
|
||||
className="text-[#f7ab6e] hover:text-[#f7ab6e] hover:opacity-80 underline cursor-pointer pointer-events-auto"
|
||||
>
|
||||
essayez un texte d'exemple
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Affichage du fichier uploadé dans la zone de texte */}
|
||||
{uploadedFile && (
|
||||
<div className="absolute top-3 left-3 right-3 text-white text-sm pointer-events-none z-10">
|
||||
<div className="inline-flex items-center gap-2 bg-[#f7ab6e] rounded-md p-3 max-w-fit">
|
||||
<Upload className="h-4 w-4 text-[#092727]" />
|
||||
<span className="text-[#092727] text-sm font-medium">
|
||||
{uploadedFile.name}
|
||||
</span>
|
||||
<span className="text-[#092727] text-opacity-80 text-xs">
|
||||
({(uploadedFile.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setUploadedFile(null);
|
||||
setFileContent("");
|
||||
setSourceText("");
|
||||
const fileInput = document.querySelector(
|
||||
'input[type="file"]'
|
||||
) as HTMLInputElement;
|
||||
if (fileInput) fileInput.value = "";
|
||||
}}
|
||||
className="ml-2 text-[#092727] hover:text-white transition-colors p-1 hover:bg-[#092727] hover:bg-opacity-20 rounded pointer-events-auto"
|
||||
title="Supprimer le fichier"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
value={sourceText}
|
||||
onChange={(e) => setSourceText(e.target.value)}
|
||||
className="w-full h-full border-none outline-none resize-none text-white bg-transparent overflow-y-auto p-3 placeholder-white placeholder-opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full p-4">
|
||||
<input
|
||||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
placeholder="Entrez l'URL du site web à anonymiser"
|
||||
className="w-full p-3 border border-white border-opacity-20 rounded-lg outline-none focus:border-[#f7ab6e] bg-transparent text-white placeholder-white placeholder-opacity-60"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bouton télécharger fichier - au-dessus du disclaimer */}
|
||||
<div className="px-4 pb-2 flex-shrink-0">
|
||||
<label className="flex items-center gap-2 px-4 py-2 text-[#f7ab6e] hover:bg-[#f7ab6e] hover:text-white rounded-lg font-medium cursor-pointer transition-colors text-sm w-fit group">
|
||||
<Upload className="h-4 w-4 text-[#f7ab6e] group-hover:text-white transition-colors" />
|
||||
Téléverser votre fichier
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleFileChange}
|
||||
accept=".txt,.pdf,.docx,.doc"
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer - tout en bas */}
|
||||
<div className="p-4 rounded-b-md flex-shrink-0">
|
||||
<div className="flex items-start gap-2 p-2 bg-[#f7ab6e] bg-opacity-20 rounded-md text-[#092727]">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<p className=" text-sm">
|
||||
Cet outil IA peut ne pas détecter toutes les informations
|
||||
sensibles. Vérifiez le résultat avant de le partager.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Output */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-white border-opacity-20 pb-2">
|
||||
<h3 className="text-lg font-medium text-white">
|
||||
Texte de Sortie
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
disabled={!outputText}
|
||||
className="p-2 text-white hover:text-[#f7ab6e] disabled:opacity-50"
|
||||
title="Copier"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={downloadText}
|
||||
disabled={!outputText}
|
||||
className="p-2 text-white hover:text-[#f7ab6e] disabled:opacity-50"
|
||||
title="Télécharger"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Area - même taille que gauche */}
|
||||
<div className="border border-white border-opacity-20 rounded-md bg-[#092727] min-h-[500px] max-h-[500px] flex flex-col">
|
||||
{/* Zone de contenu - 90% */}
|
||||
<div className="flex-1 p-4 overflow-hidden">
|
||||
<div className="h-full min-h-[350px] max-h-[350px] text-white whitespace-pre-wrap overflow-y-auto">
|
||||
{outputText ? (
|
||||
<div className="leading-relaxed">
|
||||
{highlightEntities(outputText)}
|
||||
</div>
|
||||
) : (
|
||||
"Le texte anonymisé apparaîtra ici..."
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disclaimer - 10% intégré dans le même cadre */}
|
||||
<div className="p-4">
|
||||
<div className="flex items-start gap-2 p-2 [#092727] bg-[#f7ab6e] bg-opacity-20 rounded-md">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<p className="text-sm">
|
||||
Vérifiez le résultat expurgé pour vous assurer que toutes
|
||||
les informations privées sont supprimées et éviter une
|
||||
divulgation accidentelle.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Upload Section */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||
<div className="p-3 sm:p-6">
|
||||
<FileUploadComponent
|
||||
uploadedFile={uploadedFile}
|
||||
handleFileChange={handleFileChange}
|
||||
sourceText={sourceText}
|
||||
setSourceText={setSourceText}
|
||||
setUploadedFile={setUploadedFile}
|
||||
setFileContent={setFileContent}
|
||||
onAnonymize={anonymizeData}
|
||||
isProcessing={isProcessing}
|
||||
canAnonymize={
|
||||
uploadedFile !== null ||
|
||||
Boolean(sourceText && sourceText.trim())
|
||||
}
|
||||
isLoadingFile={isLoadingFile}
|
||||
onRestart={handleRestart}
|
||||
outputText={outputText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anonymize Button */}
|
||||
<div className="flex justify-center mt-8">
|
||||
<button
|
||||
onClick={anonymizeData}
|
||||
disabled={isProcessing || (!sourceText.trim() && !uploadedFile)}
|
||||
className="bg-[#f7ab6e] hover:bg-[#f7ab6e] hover:opacity-80 text-[#092727] px-8 py-3 rounded-lg font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||
>
|
||||
{isProcessing
|
||||
? "Anonymisation en cours..."
|
||||
: "Anonymiser mes données"}
|
||||
</button>
|
||||
</div>
|
||||
{/* Result Preview */}
|
||||
{outputText && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||
<div className="p-3 sm:p-6">
|
||||
<ResultPreviewComponent
|
||||
outputText={outputText}
|
||||
copyToClipboard={copyToClipboard}
|
||||
downloadText={downloadText}
|
||||
highlightEntities={highlightEntities}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entity Mapping Table */}
|
||||
{outputText && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||
<div className="p-3 sm:p-6">
|
||||
<EntityMappingTable mappings={entityMappings} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mt-4 p-4 bg-[#f7ab6e] bg-opacity-20 border border-[#f7ab6e] rounded-lg">
|
||||
<p className="text-[#092727] text-sm">{error}</p>
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-3 sm:p-4 mx-2 sm:mx-0">
|
||||
<div className="flex items-center space-x-2">
|
||||
<svg
|
||||
className="w-4 h-4 sm:w-5 sm:h-5 text-red-500 flex-shrink-0"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-red-700 text-xs sm:text-sm font-medium break-words">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Fonction pour mettre en évidence les entités anonymisées
|
||||
const highlightEntities = (text: string) => {
|
||||
if (!text) return text;
|
||||
|
||||
// Pattern pour détecter les entités anonymisées entre crochets
|
||||
const entityPattern = /\[([^\]]+)\]/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = entityPattern.exec(text)) !== null) {
|
||||
// Ajouter le texte avant l'entité
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Ajouter l'entité avec fond orange
|
||||
parts.push(
|
||||
<span
|
||||
key={match.index}
|
||||
className="bg-[#f7ab6e] text-[#092727] px-1 py-0.5 rounded font-medium"
|
||||
>
|
||||
{match[0]}
|
||||
</span>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// Ajouter le reste du texte
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
};
|
||||
|
||||
31
app/utils/highlightEntities.tsx
Normal file
31
app/utils/highlightEntities.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
export const highlightEntities = (text: string) => {
|
||||
if (!text) return text;
|
||||
|
||||
const entityPattern = /\[([^\]]+)\]/g;
|
||||
const parts = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = entityPattern.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, match.index));
|
||||
}
|
||||
|
||||
parts.push(
|
||||
<span
|
||||
key={match.index}
|
||||
className="bg-[#f7ab6e] text-[#092727] px-1 py-0.5 rounded font-medium"
|
||||
>
|
||||
{match[0]}
|
||||
</span>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
};
|
||||
Reference in New Issue
Block a user