interface interactive
This commit is contained in:
@@ -15,7 +15,7 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Vérifications supplémentaires
|
||||
if (file.size === 0) {
|
||||
return NextResponse.json(
|
||||
@@ -23,19 +23,20 @@ export async function POST(req: NextRequest) {
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > 50 * 1024 * 1024) { // 50MB
|
||||
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
// 50MB
|
||||
return NextResponse.json(
|
||||
{ error: "Le fichier est trop volumineux (max 50MB)." },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
console.log("📁 Fichier reçu:", {
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: `${(file.size / 1024 / 1024).toFixed(2)} MB`,
|
||||
lastModified: new Date(file.lastModified).toISOString()
|
||||
lastModified: new Date(file.lastModified).toISOString(),
|
||||
});
|
||||
|
||||
let fileContent = "";
|
||||
@@ -45,55 +46,56 @@ export async function POST(req: NextRequest) {
|
||||
if (fileType === "application/pdf") {
|
||||
console.log("📄 Traitement PDF en cours...");
|
||||
console.log("📊 Taille du fichier:", file.size, "bytes");
|
||||
|
||||
|
||||
try {
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
console.log("📦 Buffer créé, taille:", buffer.length);
|
||||
|
||||
|
||||
const data = await pdf(buffer);
|
||||
fileContent = data.text || "";
|
||||
|
||||
|
||||
console.log("✅ Extraction PDF réussie, longueur:", fileContent.length);
|
||||
console.log("📄 Nombre de pages:", data.numpages);
|
||||
console.log("ℹ️ Info PDF:", data.info?.Title || "Titre non disponible");
|
||||
|
||||
|
||||
// ✅ Vérification améliorée
|
||||
if (!fileContent.trim()) {
|
||||
console.log("⚠️ PDF vide - Détails:", {
|
||||
pages: data.numpages,
|
||||
metadata: data.metadata,
|
||||
info: data.info,
|
||||
extractedLength: fileContent.length
|
||||
extractedLength: fileContent.length,
|
||||
});
|
||||
|
||||
|
||||
// Détecter si c'est un PDF scanné
|
||||
const isScanned = data.info?.Creator?.includes('RICOH') ||
|
||||
data.info?.Creator?.includes('Canon') ||
|
||||
data.info?.Creator?.includes('HP') ||
|
||||
data.info?.Producer?.includes('Scanner') ||
|
||||
(data.numpages > 0 && fileContent.length < 50);
|
||||
|
||||
const errorMessage = isScanned
|
||||
const isScanned =
|
||||
data.info?.Creator?.includes("RICOH") ||
|
||||
data.info?.Creator?.includes("Canon") ||
|
||||
data.info?.Creator?.includes("HP") ||
|
||||
data.info?.Producer?.includes("Scanner") ||
|
||||
(data.numpages > 0 && fileContent.length < 50);
|
||||
|
||||
const errorMessage = isScanned
|
||||
? `Ce PDF semble être un document scanné (créé par: ${data.info?.Creator}). Les documents scannés contiennent des images de texte, pas du texte extractible.\n\n💡 Solutions :\n- Utilisez un PDF créé depuis Word/Google Docs\n- Appliquez l'OCR avec Adobe Acrobat\n- Recréez le document au lieu de le scanner`
|
||||
: `Le PDF ne contient pas de texte extractible.\n\nCela peut être dû à :\n- PDF scanné (image uniquement)\n- PDF protégé\n- PDF avec texte en images\n- Nombre de pages: ${data.numpages}`;
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: errorMessage },
|
||||
{ status: 400 }
|
||||
);
|
||||
|
||||
return NextResponse.json({ error: errorMessage }, { status: 400 });
|
||||
}
|
||||
} catch (pdfError) {
|
||||
console.error("❌ Erreur PDF détaillée:", {
|
||||
message: pdfError instanceof Error ? pdfError.message : "Erreur inconnue",
|
||||
message:
|
||||
pdfError instanceof Error ? pdfError.message : "Erreur inconnue",
|
||||
stack: pdfError instanceof Error ? pdfError.stack : undefined,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
fileType: file.type
|
||||
fileType: file.type,
|
||||
});
|
||||
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: `Impossible de traiter ce PDF (${file.name}). Erreur: ${pdfError instanceof Error ? pdfError.message : "Erreur inconnue"}. Vérifiez que le PDF n'est pas protégé, corrompu ou scanné.`,
|
||||
error: `Impossible de traiter ce PDF (${file.name}). Erreur: ${
|
||||
pdfError instanceof Error ? pdfError.message : "Erreur inconnue"
|
||||
}. Vérifiez que le PDF n'est pas protégé, corrompu ou scanné.`,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@@ -170,10 +172,10 @@ export async function POST(req: NextRequest) {
|
||||
};
|
||||
|
||||
console.log("🔍 Appel à Presidio Analyzer...");
|
||||
|
||||
|
||||
// ✅ Définir l'URL AVANT de l'utiliser
|
||||
const presidioAnalyzerUrl = "http://analyzer.151.80.20.211.sslip.io/analyze";
|
||||
|
||||
const presidioAnalyzerUrl = "http://localhost:5001/analyze";
|
||||
|
||||
try {
|
||||
const analyzeResponse = await fetch(presidioAnalyzerUrl, {
|
||||
method: "POST",
|
||||
@@ -183,10 +185,10 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
body: JSON.stringify(analyzerConfig),
|
||||
});
|
||||
|
||||
|
||||
console.log("📊 Statut Analyzer:", analyzeResponse.status);
|
||||
console.log("📊 Headers Analyzer:", analyzeResponse.headers);
|
||||
|
||||
|
||||
if (!analyzeResponse.ok) {
|
||||
const errorBody = await analyzeResponse.text();
|
||||
console.error("❌ Erreur Analyzer:", errorBody);
|
||||
@@ -209,8 +211,7 @@ export async function POST(req: NextRequest) {
|
||||
};
|
||||
|
||||
console.log("🔍 Appel à Presidio Anonymizer...");
|
||||
const presidioAnonymizerUrl =
|
||||
"http://anonymizer.151.80.20.211.sslip.io/anonymize";
|
||||
const presidioAnonymizerUrl = "http://localhost:5001/anonymize";
|
||||
|
||||
const anonymizeResponse = await fetch(presidioAnonymizerUrl, {
|
||||
method: "POST",
|
||||
@@ -232,13 +233,66 @@ export async function POST(req: NextRequest) {
|
||||
const anonymizerResult = await anonymizeResponse.json();
|
||||
console.log("✅ Anonymisation réussie.");
|
||||
|
||||
// 🔧 NOUVELLE FONCTION SIMPLIFIÉE pour extraire les valeurs de remplacement
|
||||
// Ajouter cette interface au début du fichier
|
||||
interface AnalyzerResult {
|
||||
entity_type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// Puis modifier la fonction
|
||||
const extractReplacementValues = (originalText: string, anonymizedText: string, analyzerResults: AnalyzerResult[]) => {
|
||||
const replacementMap: Record<string, string> = {};
|
||||
|
||||
// Créer une copie du texte anonymisé pour le traitement
|
||||
const workingText = anonymizedText; // ✅ Changé de 'let' à 'const'
|
||||
// Supprimer workingOriginal car elle n'est jamais utilisée
|
||||
|
||||
// Trier les résultats par position (du plus grand au plus petit pour éviter les décalages)
|
||||
const sortedResults = [...analyzerResults].sort((a, b) => b.start - a.start);
|
||||
|
||||
for (const result of sortedResults) {
|
||||
const originalValue = originalText.substring(result.start, result.end);
|
||||
|
||||
// Extraire les parties avant et après l'entité dans le texte original
|
||||
const beforeOriginal = originalText.substring(0, result.start);
|
||||
const afterOriginal = originalText.substring(result.end);
|
||||
|
||||
// Trouver les mêmes parties dans le texte anonymisé
|
||||
const beforeIndex = workingText.indexOf(beforeOriginal);
|
||||
const afterIndex = workingText.lastIndexOf(afterOriginal);
|
||||
|
||||
if (beforeIndex !== -1 && afterIndex !== -1) {
|
||||
// Extraire la valeur de remplacement entre ces deux parties
|
||||
const startPos = beforeIndex + beforeOriginal.length;
|
||||
const endPos = afterIndex;
|
||||
const replacementValue = workingText.substring(startPos, endPos);
|
||||
|
||||
// Vérifier que c'est bien un remplacement (commence par [ et finit par ])
|
||||
if (replacementValue.startsWith('[') && replacementValue.endsWith(']')) {
|
||||
replacementMap[originalValue] = replacementValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return replacementMap;
|
||||
};
|
||||
|
||||
const replacementValues = extractReplacementValues(fileContent, anonymizerResult.anonymized_text, analyzerResults);
|
||||
|
||||
// 🔍 AJOUT D'UN LOG POUR DÉBOGUER
|
||||
console.log("🔧 Valeurs de remplacement extraites:", replacementValues);
|
||||
|
||||
const result = {
|
||||
text: fileContent,
|
||||
anonymizedText: anonymizerResult.text,
|
||||
anonymizedText: anonymizerResult.anonymized_text,
|
||||
piiCount: analyzerResults.length,
|
||||
analyzerResults: analyzerResults,
|
||||
replacementValues: replacementValues // Utiliser les nouvelles valeurs
|
||||
};
|
||||
|
||||
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
} catch (presidioError) {
|
||||
console.error("❌ Erreur Presidio:", presidioError);
|
||||
|
||||
@@ -17,7 +17,7 @@ export const AnonymizationInterface = ({
|
||||
|
||||
const anonymizedTypes = new Set<string>();
|
||||
|
||||
if (outputText.includes("<PERSON>")) {
|
||||
if (outputText.includes("<PERSONNE>")) {
|
||||
anonymizedTypes.add("Prénoms");
|
||||
anonymizedTypes.add("Noms de famille");
|
||||
anonymizedTypes.add("Noms complets");
|
||||
|
||||
@@ -1,211 +1,140 @@
|
||||
import { useState } from "react";
|
||||
import { patterns } from "@/app/utils/highlightEntities";
|
||||
import {
|
||||
PresidioAnalyzerResult,
|
||||
EntityMapping,
|
||||
} from "@/app/config/entityLabels";
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
// L'API retourne des objets avec snake_case
|
||||
interface PresidioAnalyzerResult {
|
||||
entity_type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// La réponse de l'API utilise camelCase pour les clés principales
|
||||
// Interface pour la réponse de l'API process-document
|
||||
interface ProcessDocumentResponse {
|
||||
text?: string; // Texte original en cas de fallback
|
||||
text?: string;
|
||||
anonymizedText?: string;
|
||||
analyzerResults?: PresidioAnalyzerResult[];
|
||||
replacementValues?: Record<string, string>; // Nouvelle propriété
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Props du hook
|
||||
interface AnonymizationLogicProps {
|
||||
sourceText: string;
|
||||
fileContent: string;
|
||||
uploadedFile: File | null;
|
||||
setOutputText: (text: string) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setEntityMappings: (mappings: EntityMapping[]) => void;
|
||||
}
|
||||
|
||||
// NOUVEAU: Définir les types pour le paramètre de anonymizeData
|
||||
interface AnonymizeDataParams {
|
||||
file?: File | null;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour la logique d'anonymisation.
|
||||
* Gère l'appel API et la création du tableau de mapping de manière simple et directe.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
const anonymizeData = async ({ file, text }: AnonymizeDataParams) => {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
setOutputText("");
|
||||
setEntityMappings([]);
|
||||
setOutputText("");
|
||||
|
||||
try {
|
||||
// ÉTAPE 1: Construire le FormData ici pour garantir le bon format
|
||||
const formData = new FormData();
|
||||
if (uploadedFile) {
|
||||
formData.append("file", uploadedFile);
|
||||
if (file) {
|
||||
formData.append("file", file);
|
||||
} else if (text) {
|
||||
// Si c'est du texte, on le transforme en Blob pour l'envoyer comme un fichier
|
||||
const textBlob = new Blob([text], { type: "text/plain" });
|
||||
formData.append("file", textBlob, "input.txt");
|
||||
} else {
|
||||
const textBlob = new Blob([textToProcess], { type: "text/plain" });
|
||||
const textFile = new File([textBlob], "input.txt", {
|
||||
type: "text/plain",
|
||||
});
|
||||
formData.append("file", textFile);
|
||||
throw new Error("Aucune donnée à anonymiser (ni fichier, ni texte).");
|
||||
}
|
||||
|
||||
const response = await fetch("/api/process-document", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
body: formData, // Le Content-Type sera automatiquement défini par le navigateur
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `Erreur HTTP: ${response.status}`;
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
if (errorData.error) errorMessage = errorData.error;
|
||||
} catch {
|
||||
/* Ignore */
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data: ProcessDocumentResponse = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error);
|
||||
if (!response.ok || data.error) {
|
||||
throw new Error(
|
||||
data.error || "Erreur lors de la communication avec l'API."
|
||||
);
|
||||
}
|
||||
|
||||
// Utiliser camelCase pour les propriétés de la réponse principale
|
||||
if (data.anonymizedText && data.analyzerResults) {
|
||||
setOutputText(data.anonymizedText);
|
||||
const originalText = data.text || "";
|
||||
const presidioResults = data.analyzerResults || [];
|
||||
const replacementValues = data.replacementValues || {}; // Récupérer les valeurs de remplacement
|
||||
|
||||
const entityTypeMap = new Map<string, string>();
|
||||
patterns.forEach((p) => {
|
||||
const match = p.regex.toString().match(/<([A-Z_]+)>/);
|
||||
if (match && match[1]) {
|
||||
entityTypeMap.set(match[1], p.label);
|
||||
}
|
||||
// 🔍 AJOUT DES CONSOLE.LOG POUR DÉBOGUER
|
||||
console.log("📊 Données reçues de Presidio:", {
|
||||
originalTextLength: originalText.length,
|
||||
presidioResultsCount: presidioResults.length,
|
||||
presidioResults: presidioResults,
|
||||
replacementValues: replacementValues,
|
||||
replacementValuesKeys: Object.keys(replacementValues),
|
||||
replacementValuesEntries: Object.entries(replacementValues)
|
||||
});
|
||||
|
||||
// ÉTAPE 2 : Passer le texte ORIGINAL à l'état de sortie.
|
||||
setOutputText(originalText);
|
||||
|
||||
// ÉTAPE 3 : Créer le tableau de mapping avec la nouvelle structure
|
||||
const sortedResults = [...presidioResults].sort(
|
||||
(a, b) => a.start - b.start
|
||||
);
|
||||
const mappings: EntityMapping[] = [];
|
||||
|
||||
// Dans la fonction anonymizeData, section création des mappings :
|
||||
for (const result of sortedResults) {
|
||||
const { entity_type, start, end } = result;
|
||||
const detectedText = originalText.substring(start, end);
|
||||
|
||||
// 🔍 CONSOLE.LOG POUR CHAQUE ENTITÉ
|
||||
console.log(`🔍 Entité détectée:`, {
|
||||
entity_type,
|
||||
detectedText,
|
||||
replacementFromMap: replacementValues[detectedText],
|
||||
fallback: `[${entity_type}]`
|
||||
});
|
||||
|
||||
// 1. Compter les occurrences de chaque tag d'entité dans le texte anonymisé
|
||||
const tagCounts = new Map<string, number>();
|
||||
data.analyzerResults.forEach((result) => {
|
||||
const tag = `<${result.entity_type}>`;
|
||||
if (!tagCounts.has(result.entity_type)) {
|
||||
const count = (
|
||||
data.anonymizedText?.match(new RegExp(tag, "g")) || []
|
||||
).length;
|
||||
tagCounts.set(result.entity_type, count);
|
||||
}
|
||||
mappings.push({
|
||||
entity_type: entity_type,
|
||||
start: start,
|
||||
end: end,
|
||||
text: detectedText,
|
||||
replacementValue: replacementValues[detectedText] || `[${entity_type}]`,
|
||||
displayName: replacementValues[detectedText] || `[${entity_type}]`, // Ajouter cette ligne
|
||||
customColor: undefined,
|
||||
});
|
||||
|
||||
// 2. Créer un mapping basé sur l'ordre d'apparition dans le texte anonymisé
|
||||
const uniqueMappings: EntityMapping[] = [];
|
||||
|
||||
// Vérifier que les données nécessaires sont disponibles
|
||||
if (!data.analyzerResults || !data.anonymizedText) {
|
||||
setEntityMappings([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const entityCounters = new Map<string, number>();
|
||||
|
||||
// Parcourir le texte anonymisé pour trouver les tags dans l'ordre
|
||||
const anonymizedText = data.anonymizedText;
|
||||
const allMatches: Array<{
|
||||
match: RegExpMatchArray;
|
||||
entityType: string;
|
||||
position: number;
|
||||
}> = [];
|
||||
|
||||
// Trouver tous les tags dans le texte anonymisé
|
||||
patterns.forEach(pattern => {
|
||||
const entityTypeKey = pattern.regex.toString().match(/<([A-Z_]+)>/)?.[1];
|
||||
if (entityTypeKey) {
|
||||
const regex = new RegExp(pattern.regex.source, 'g');
|
||||
let match;
|
||||
while ((match = regex.exec(anonymizedText)) !== null) {
|
||||
allMatches.push({
|
||||
match,
|
||||
entityType: entityTypeKey,
|
||||
position: match.index
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trier par position dans le texte anonymisé
|
||||
allMatches.sort((a, b) => a.position - b.position);
|
||||
|
||||
// Créer les mappings dans l'ordre d'apparition
|
||||
const seen = new Set<string>();
|
||||
allMatches.forEach(({ entityType }) => {
|
||||
const frenchLabel = entityTypeMap.get(entityType) || entityType;
|
||||
const currentCount = (entityCounters.get(entityType) || 0) + 1;
|
||||
entityCounters.set(entityType, currentCount);
|
||||
|
||||
// Trouver l'entité correspondante dans les résultats d'analyse
|
||||
const correspondingResult = data.analyzerResults
|
||||
?.filter(result => result.entity_type === entityType)
|
||||
.find(result => {
|
||||
const originalValue = textToProcess.substring(result.start, result.end);
|
||||
const uniqueKey = `${frenchLabel}|${originalValue}|${currentCount}`;
|
||||
return !seen.has(uniqueKey);
|
||||
});
|
||||
|
||||
if (correspondingResult) {
|
||||
const originalValue = textToProcess.substring(
|
||||
correspondingResult.start,
|
||||
correspondingResult.end
|
||||
);
|
||||
const uniqueKey = `${frenchLabel}|${originalValue}|${currentCount}`;
|
||||
|
||||
if (!seen.has(uniqueKey)) {
|
||||
uniqueMappings.push({
|
||||
entityType: frenchLabel,
|
||||
originalValue: originalValue,
|
||||
anonymizedValue: `${frenchLabel} [${currentCount}]`,
|
||||
startIndex: correspondingResult.start,
|
||||
endIndex: correspondingResult.end,
|
||||
});
|
||||
seen.add(uniqueKey);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setEntityMappings(uniqueMappings);
|
||||
} else if (data.text) {
|
||||
setOutputText(data.text);
|
||||
setError("Presidio temporairement indisponible. Texte non anonymisé.");
|
||||
}
|
||||
|
||||
// 🔍 CONSOLE.LOG FINAL DES MAPPINGS
|
||||
console.log("📋 Mappings créés:", mappings);
|
||||
|
||||
// ÉTAPE 4 : Mettre à jour l'état global avec les mappings créés.
|
||||
setEntityMappings(mappings);
|
||||
} catch (error) {
|
||||
console.error("Erreur dans useAnonymization:", error);
|
||||
setError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Erreur lors de l'anonymisation avec Presidio"
|
||||
: "Une erreur inconnue est survenue."
|
||||
);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return { anonymizeData, isProcessing };
|
||||
return {
|
||||
anonymizeData,
|
||||
isProcessing,
|
||||
};
|
||||
};
|
||||
|
||||
352
app/components/ContextMenu.tsx
Normal file
352
app/components/ContextMenu.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Trash2, Check, RotateCcw } from "lucide-react";
|
||||
import { COLOR_PALETTE, type ColorOption } from "../config/colorPalette";
|
||||
// import { EntityMapping } from "../config/entityLabels"; // SUPPRIMER cette ligne
|
||||
|
||||
interface ContextMenuProps {
|
||||
contextMenu: {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
selectedText: string;
|
||||
wordIndices: number[];
|
||||
};
|
||||
existingLabels: string[];
|
||||
// entityMappings: EntityMapping[]; // SUPPRIMER cette ligne
|
||||
onApplyLabel: (
|
||||
displayName: string,
|
||||
applyToAll?: boolean
|
||||
) => void;
|
||||
onApplyColor: (
|
||||
color: string,
|
||||
colorName: string,
|
||||
applyToAll?: boolean
|
||||
) => void;
|
||||
onRemoveLabel: (applyToAll?: boolean) => void;
|
||||
getCurrentColor: (selectedText: string) => string;
|
||||
}
|
||||
|
||||
const colorOptions: ColorOption[] = COLOR_PALETTE;
|
||||
|
||||
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
||||
contextMenu,
|
||||
existingLabels,
|
||||
// entityMappings, // SUPPRIMER cette ligne
|
||||
onApplyLabel,
|
||||
onApplyColor,
|
||||
onRemoveLabel,
|
||||
getCurrentColor,
|
||||
}) => {
|
||||
const [customLabel, setCustomLabel] = useState("");
|
||||
const [showNewLabelInput, setShowNewLabelInput] = useState(false);
|
||||
const [showColorPalette, setShowColorPalette] = useState(false);
|
||||
const [applyToAll, setApplyToAll] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Fonction corrigée pour le bouton +
|
||||
const handleNewLabelClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("Bouton + cliqué - Ouverture du champ de saisie");
|
||||
setShowNewLabelInput(true);
|
||||
setShowColorPalette(false);
|
||||
};
|
||||
|
||||
const handleApplyCustomLabel = (e?: React.MouseEvent) => {
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
if (customLabel.trim()) {
|
||||
console.log(
|
||||
"Application du label personnalisé:",
|
||||
customLabel.trim(),
|
||||
"À toutes les occurrences:",
|
||||
applyToAll
|
||||
);
|
||||
onApplyLabel(customLabel.trim(), applyToAll); // CORRIGER: 2 paramètres seulement
|
||||
setCustomLabel("");
|
||||
setShowNewLabelInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Modifier la fonction handleCancelNewLabel pour accepter les deux types d'événements
|
||||
const handleCancelNewLabel = (e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log("Annulation du nouveau label");
|
||||
setShowNewLabelInput(false);
|
||||
setCustomLabel("");
|
||||
};
|
||||
|
||||
// Fonction pour empêcher la propagation des événements
|
||||
const handleMenuClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
// Auto-focus sur l'input quand il apparaît
|
||||
useEffect(() => {
|
||||
if (showNewLabelInput && inputRef.current) {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}, [showNewLabelInput]);
|
||||
|
||||
if (!contextMenu.visible) return null;
|
||||
|
||||
// Calcul du positionnement pour s'assurer que le menu reste visible
|
||||
const calculatePosition = () => {
|
||||
const menuWidth = Math.max(600, contextMenu.selectedText.length * 8 + 400); // Largeur dynamique basée sur le texte
|
||||
const menuHeight = 60; // Hauteur fixe pour une seule ligne
|
||||
const padding = 10;
|
||||
|
||||
let x = contextMenu.x;
|
||||
let y = contextMenu.y;
|
||||
|
||||
// Ajuster X pour rester dans la fenêtre
|
||||
if (x + menuWidth / 2 > window.innerWidth - padding) {
|
||||
x = window.innerWidth - menuWidth / 2 - padding;
|
||||
}
|
||||
if (x - menuWidth / 2 < padding) {
|
||||
x = menuWidth / 2 + padding;
|
||||
}
|
||||
|
||||
// Ajuster Y pour rester dans la fenêtre
|
||||
if (y + menuHeight > window.innerHeight - padding) {
|
||||
y = contextMenu.y - menuHeight - 20; // Afficher au-dessus
|
||||
}
|
||||
|
||||
return { x, y };
|
||||
};
|
||||
|
||||
const position = calculatePosition();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-context-menu
|
||||
className="fixed z-50 bg-white border border-gray-300 rounded-md"
|
||||
style={{
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
transform: "translate(-50%, -10px)",
|
||||
minWidth: "fit-content",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
onClick={handleMenuClick}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Une seule ligne avec tous les contrôles */}
|
||||
<div className="flex items-center px-2 py-1 space-x-2">
|
||||
{/* Texte sélectionné complet */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="text-xs text-gray-800 bg-gray-50 px-2 py-1 rounded font-mono border">
|
||||
{contextMenu.selectedText}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||
|
||||
{/* Labels existants */}
|
||||
{existingLabels.length > 0 && (
|
||||
<>
|
||||
<div className="flex-shrink-0">
|
||||
<select
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.target.value) {
|
||||
const selectedDisplayName = e.target.value; // displayName
|
||||
// CORRECTION: Plus besoin de chercher entity_type !
|
||||
onApplyLabel(selectedDisplayName, applyToAll);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-xs border border-gray-300 rounded px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
defaultValue=""
|
||||
>
|
||||
<option value="" disabled>
|
||||
Chosi
|
||||
</option>
|
||||
{existingLabels.map((label) => (
|
||||
<option key={label} value={label}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Nouveau label */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center space-x-1">
|
||||
{!showNewLabelInput ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewLabelClick}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="px-1 py-1 text-xs text-green-600 border border-green-300 rounded hover:bg-green-50 transition-colors flex items-center justify-center w-6 h-6 focus:outline-none focus:ring-1 focus:ring-green-500"
|
||||
title="Ajouter un nouveau label"
|
||||
>
|
||||
<svg
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center space-x-1">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={customLabel}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setCustomLabel(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleApplyCustomLabel();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleCancelNewLabel(e);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onFocus={(e) => e.stopPropagation()}
|
||||
className="text-xs border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-1 focus:ring-blue-500 w-20"
|
||||
placeholder="Label"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyCustomLabel}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
disabled={!customLabel.trim()}
|
||||
className="px-1 py-1 bg-blue-500 text-white text-xs rounded hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
title="Appliquer le label"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelNewLabel}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="px-1 py-1 text-gray-500 hover:text-gray-700 transition-colors focus:outline-none focus:ring-1 focus:ring-gray-500"
|
||||
title="Annuler"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||
|
||||
{/* Sélecteur de couleur */}
|
||||
<div className="flex-shrink-0 relative">
|
||||
<button
|
||||
type="button"
|
||||
className="w-5 h-5 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-all"
|
||||
style={{
|
||||
backgroundColor: getCurrentColor(contextMenu.selectedText),
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowColorPalette(!showColorPalette);
|
||||
setShowNewLabelInput(false);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
title="Couleur actuelle du label"
|
||||
/>
|
||||
|
||||
{showColorPalette && (
|
||||
<div className="flex items-center space-x-1 bg-gray-50 p-1 rounded border absolute z-10 mt-1 left-0">
|
||||
{colorOptions.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApplyColor(color.value, color.name, applyToAll);
|
||||
setShowColorPalette(false);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="w-4 h-4 rounded-full border-2 border-gray-300 cursor-pointer hover:border-gray-400 transition-all"
|
||||
style={{ backgroundColor: color.value }}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||
|
||||
{/* Bouton supprimer */}
|
||||
<div className="flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveLabel(applyToAll);
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="px-1 py-1 text-xs text-red-600 border border-red-300 rounded hover:bg-red-50 transition-colors flex items-center justify-center w-6 h-6 focus:outline-none focus:ring-1 focus:ring-red-500"
|
||||
title="Supprimer le label"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-gray-300 flex-shrink-0"></div>
|
||||
|
||||
{/* Case à cocher "Toutes les occurrences" */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center space-x-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="applyToAll"
|
||||
checked={applyToAll}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setApplyToAll(e.target.checked);
|
||||
console.log(
|
||||
"Appliquer à toutes les occurrences:",
|
||||
e.target.checked
|
||||
);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="h-3 w-3 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor="applyToAll"
|
||||
className="text-xs text-gray-700 cursor-pointer select-none whitespace-nowrap"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setApplyToAll(!applyToAll);
|
||||
}}
|
||||
>
|
||||
Toutes les occurences
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +1,23 @@
|
||||
import { generateAnonymizedText } from "@/app/utils/generateAnonymizedText";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
|
||||
interface DownloadActionsProps {
|
||||
outputText: string;
|
||||
entityMappings?: EntityMapping[];
|
||||
}
|
||||
|
||||
export const useDownloadActions = ({ outputText }: DownloadActionsProps) => {
|
||||
export const useDownloadActions = ({
|
||||
outputText,
|
||||
entityMappings = [],
|
||||
}: DownloadActionsProps) => {
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(outputText);
|
||||
const anonymizedText = generateAnonymizedText(outputText, entityMappings);
|
||||
navigator.clipboard.writeText(anonymizedText);
|
||||
};
|
||||
|
||||
const downloadText = () => {
|
||||
const blob = new Blob([outputText], { type: "text/plain" });
|
||||
const anonymizedText = generateAnonymizedText(outputText, entityMappings);
|
||||
const blob = new Blob([anonymizedText], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -20,4 +29,4 @@ export const useDownloadActions = ({ outputText }: DownloadActionsProps) => {
|
||||
};
|
||||
|
||||
return { copyToClipboard, downloadText };
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,14 +8,7 @@ import {
|
||||
} 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;
|
||||
}
|
||||
import { EntityMapping } from "../config/entityLabels";
|
||||
|
||||
interface EntityMappingTableProps {
|
||||
mappings: EntityMapping[];
|
||||
@@ -24,65 +17,72 @@ interface EntityMappingTableProps {
|
||||
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
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium text-[#092727]">
|
||||
Entités détectées
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-500 text-center py-4 text-sm">
|
||||
Aucune entité sensible détectée dans le texte.
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
Aucune entité détectée dans le document.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Créer un compteur pour chaque type d'entité
|
||||
const entityCounts: { [key: string]: number } = {};
|
||||
const mappingsWithNumbers = mappings.map((mapping) => {
|
||||
const entityType = mapping.entity_type;
|
||||
entityCounts[entityType] = (entityCounts[entityType] || 0) + 1;
|
||||
return {
|
||||
...mapping,
|
||||
entityNumber: entityCounts[entityType],
|
||||
displayName: mapping.displayName || mapping.replacementValue || `[${entityType}]`,
|
||||
};
|
||||
});
|
||||
|
||||
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" : ""})
|
||||
<Card className="mt-8">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-medium text-[#092727]">
|
||||
Entités détectées ({mappings.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:px-6">
|
||||
<CardContent>
|
||||
{/* Version mobile : Cards empilées */}
|
||||
<div className="block sm:hidden space-y-4">
|
||||
{mappings.map((mapping, index) => (
|
||||
<div className="sm:hidden space-y-4">
|
||||
{mappingsWithNumbers.map((mapping, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 rounded-lg p-4 bg-gray-50"
|
||||
className="border rounded-lg p-4 bg-white shadow-sm"
|
||||
>
|
||||
<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>
|
||||
<div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e] text-xs"
|
||||
className="bg-[#f7ab6e] bg-opacity-20 text-[#092727] border-[#f7ab6e]"
|
||||
>
|
||||
{mapping.entityType}
|
||||
{mapping.displayName}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-600 block mb-1">
|
||||
Valeur originale
|
||||
Texte détecté
|
||||
</span>
|
||||
<div className="font-mono text-xs bg-red-50 text-red-700 p-2 rounded border break-all">
|
||||
{mapping.originalValue}
|
||||
{mapping.text}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-medium text-gray-600 block mb-1">
|
||||
Valeur anonymisée
|
||||
Identifiant
|
||||
</span>
|
||||
<div className="font-mono text-xs bg-green-50 text-green-700 p-2 rounded border break-all">
|
||||
{mapping.anonymizedValue}
|
||||
{mapping.displayName} #{mapping.entityNumber}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,29 +100,29 @@ export const EntityMappingTable = ({ mappings }: EntityMappingTableProps) => {
|
||||
Type d'entité
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
|
||||
Valeur originale
|
||||
Texte détecté
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold text-[#092727] min-w-[150px]">
|
||||
Valeur anonymisée
|
||||
<TableHead className="font-semibold text-[#092727] min-w-[100px]">
|
||||
Identifiant
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{mappings.map((mapping, index) => (
|
||||
{mappingsWithNumbers.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}
|
||||
{mapping.displayName}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm bg-red-50 text-red-700 py-4 max-w-[200px] break-all">
|
||||
{mapping.originalValue}
|
||||
{mapping.text}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm bg-green-50 text-green-700 py-4 max-w-[200px] break-all">
|
||||
{mapping.anonymizedValue}
|
||||
<TableCell className="font-mono text-sm bg-green-50 text-green-700 py-4">
|
||||
{mapping.displayName} #{mapping.entityNumber}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
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é
|
||||
setIsLoadingFile: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useFileHandler = ({
|
||||
setUploadedFile,
|
||||
setSourceText,
|
||||
setFileContent,
|
||||
setError,
|
||||
setIsLoadingFile,
|
||||
}: FileHandlerProps) => {
|
||||
@@ -22,12 +20,10 @@ export const useFileHandler = ({
|
||||
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");
|
||||
@@ -49,11 +45,11 @@ export const useFileHandler = ({
|
||||
if (!response.ok) {
|
||||
// ✅ Récupérer le message d'erreur détaillé du serveur
|
||||
let errorMessage = `Erreur HTTP: ${response.status}`;
|
||||
|
||||
|
||||
try {
|
||||
const responseText = await response.text();
|
||||
console.log("🔍 Réponse brute du serveur:", responseText);
|
||||
|
||||
|
||||
try {
|
||||
const errorData = JSON.parse(responseText);
|
||||
if (errorData.error) {
|
||||
@@ -63,12 +59,14 @@ export const useFileHandler = ({
|
||||
} catch (jsonError) {
|
||||
console.error("❌ Erreur parsing JSON:", jsonError);
|
||||
console.error("❌ Réponse non-JSON:", responseText);
|
||||
errorMessage = `Erreur ${response.status}: ${responseText || 'Réponse invalide du serveur'}`;
|
||||
errorMessage = `Erreur ${response.status}: ${
|
||||
responseText || "Réponse invalide du serveur"
|
||||
}`;
|
||||
}
|
||||
} catch (readError) {
|
||||
console.error("❌ Impossible de lire la réponse:", readError);
|
||||
}
|
||||
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
@@ -86,7 +84,6 @@ export const useFileHandler = ({
|
||||
);
|
||||
}
|
||||
|
||||
setFileContent(extractedText);
|
||||
setSourceText(extractedText);
|
||||
} catch (error) {
|
||||
console.error("Erreur PDF:", error);
|
||||
@@ -96,7 +93,6 @@ export const useFileHandler = ({
|
||||
: "Erreur lors de la lecture du fichier PDF"
|
||||
);
|
||||
setUploadedFile(null);
|
||||
setFileContent("");
|
||||
setSourceText("");
|
||||
} finally {
|
||||
// Désactiver le loader une fois terminé
|
||||
|
||||
@@ -11,14 +11,9 @@ import { SupportedDataTypes } from "./SupportedDataTypes";
|
||||
import { AnonymizationInterface } from "./AnonymizationInterface";
|
||||
import { highlightEntities } from "../utils/highlightEntities";
|
||||
import { useState } from "react";
|
||||
import { EntityMapping } from "../config/entityLabels"; // Importer l'interface unifiée
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
// Supprimer l'interface locale EntityMapping (lignes 15-21)
|
||||
|
||||
interface FileUploadComponentProps {
|
||||
uploadedFile: File | null;
|
||||
@@ -26,7 +21,6 @@ interface FileUploadComponentProps {
|
||||
sourceText: string;
|
||||
setSourceText: (text: string) => void;
|
||||
setUploadedFile: (file: File | null) => void;
|
||||
setFileContent: (content: string) => void;
|
||||
onAnonymize?: () => void;
|
||||
isProcessing?: boolean;
|
||||
canAnonymize?: boolean;
|
||||
@@ -37,7 +31,7 @@ interface FileUploadComponentProps {
|
||||
downloadText?: () => void;
|
||||
isExampleLoaded?: boolean;
|
||||
setIsExampleLoaded?: (loaded: boolean) => void;
|
||||
entityMappings?: EntityMapping[]; // Ajouter cette prop
|
||||
entityMappings?: EntityMapping[];
|
||||
}
|
||||
|
||||
export const FileUploadComponent = ({
|
||||
@@ -46,7 +40,6 @@ export const FileUploadComponent = ({
|
||||
sourceText,
|
||||
setSourceText,
|
||||
setUploadedFile,
|
||||
setFileContent,
|
||||
onAnonymize,
|
||||
isProcessing = false,
|
||||
canAnonymize = false,
|
||||
@@ -56,7 +49,7 @@ export const FileUploadComponent = ({
|
||||
copyToClipboard,
|
||||
downloadText,
|
||||
setIsExampleLoaded,
|
||||
entityMappings, // Ajouter cette prop ici
|
||||
entityMappings,
|
||||
}: FileUploadComponentProps) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
@@ -224,8 +217,8 @@ export const FileUploadComponent = ({
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 sm:p-4 max-h-72 overflow-y-auto overflow-x-hidden">
|
||||
<div className="text-xs sm:text-sm text-gray-700 whitespace-pre-wrap break-words overflow-wrap-anywhere leading-relaxed">
|
||||
{highlightEntities(
|
||||
outputText || "Aucun contenu à afficher",
|
||||
entityMappings
|
||||
sourceText || "Aucun contenu à afficher", // Utiliser sourceText au lieu de outputText
|
||||
entityMappings || [] // Fournir un tableau vide par défaut
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,8 +286,8 @@ export const FileUploadComponent = ({
|
||||
{/* 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 && (
|
||||
{/* Bouton Anonymiser - seulement si pas encore anonymisé */}
|
||||
{onAnonymize && !outputText && (
|
||||
<button
|
||||
onClick={onAnonymize}
|
||||
disabled={isProcessing}
|
||||
@@ -326,7 +319,7 @@ export const FileUploadComponent = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bouton Recommencer */}
|
||||
{/* Bouton Recommencer - toujours visible */}
|
||||
{onRestart && (
|
||||
<button
|
||||
onClick={onRestart}
|
||||
@@ -399,7 +392,6 @@ export const FileUploadComponent = ({
|
||||
<span>Commencez à taper du texte, ou </span>
|
||||
<SampleTextComponent
|
||||
setSourceText={setSourceText}
|
||||
setFileContent={setFileContent}
|
||||
setUploadedFile={setUploadedFile}
|
||||
setIsExampleLoaded={setIsExampleLoaded}
|
||||
variant="link"
|
||||
|
||||
28
app/components/InstructionsPanel.tsx
Normal file
28
app/components/InstructionsPanel.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
export const InstructionsPanel: React.FC = () => {
|
||||
return (
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Info className="h-5 w-5 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium mb-2">Instructions d'utilisation :</p>
|
||||
<ul className="space-y-1 text-blue-700">
|
||||
<li>• Survolez les mots pour les mettre en évidence</li>
|
||||
<li>
|
||||
• Cliquez pour sélectionner un mot, Crtl + clic pour plusieurs
|
||||
mots
|
||||
</li>
|
||||
<li>• Faites clic droit pour ouvrir le menu contextuel</li>
|
||||
<li>• Modifiez les labels et couleurs selon vos besoins</li>
|
||||
<li>
|
||||
• Utilisez "Toutes les occurrences" pour appliquer à
|
||||
tous les mots similaires
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
123
app/components/InteractiveTextEditor.tsx
Normal file
123
app/components/InteractiveTextEditor.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
import { useTextParsing } from "./hooks/useTextParsing";
|
||||
import { useContextMenu } from "./hooks/useContextMenu";
|
||||
import { useColorMapping } from "./hooks/useColorMapping";
|
||||
import { TextDisplay } from "./TextDisplay";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { InstructionsPanel } from "./InstructionsPanel";
|
||||
|
||||
interface InteractiveTextEditorProps {
|
||||
text: string;
|
||||
entityMappings: EntityMapping[];
|
||||
onUpdateMapping: (
|
||||
originalValue: string,
|
||||
newLabel: string,
|
||||
entityType: string,
|
||||
applyToAllOccurrences?: boolean,
|
||||
customColor?: string // Ajouter ce paramètre
|
||||
) => void;
|
||||
onRemoveMapping?: (originalValue: string) => void;
|
||||
}
|
||||
|
||||
export const InteractiveTextEditor: React.FC<InteractiveTextEditorProps> = ({
|
||||
text,
|
||||
entityMappings,
|
||||
onUpdateMapping,
|
||||
onRemoveMapping,
|
||||
}) => {
|
||||
const [selectedWords, setSelectedWords] = useState<Set<number>>(new Set());
|
||||
const [hoveredWord, setHoveredWord] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { words } = useTextParsing(text, entityMappings);
|
||||
const { getCurrentColor } = useColorMapping(entityMappings); // CORRECTION: Passer entityMappings
|
||||
const {
|
||||
contextMenu,
|
||||
showContextMenu,
|
||||
applyLabel,
|
||||
applyColorDirectly,
|
||||
removeLabel,
|
||||
getExistingLabels,
|
||||
} = useContextMenu({
|
||||
entityMappings,
|
||||
words, // NOUVEAU: passer les mots
|
||||
onUpdateMapping,
|
||||
onRemoveMapping,
|
||||
getCurrentColor,
|
||||
setSelectedWords,
|
||||
});
|
||||
|
||||
const handleWordClick = useCallback(
|
||||
(index: number, event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
setSelectedWords((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
setSelectedWords(new Set([index]));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleContextMenu = useCallback(
|
||||
(event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
if (selectedWords.size === 0) return;
|
||||
|
||||
const selectedText = Array.from(selectedWords)
|
||||
.map((index) => {
|
||||
const word = words[index];
|
||||
return word?.isEntity ? word.text : word?.text;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
showContextMenu({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
selectedText,
|
||||
wordIndices: Array.from(selectedWords),
|
||||
});
|
||||
},
|
||||
[selectedWords, words, showContextMenu]
|
||||
);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<InstructionsPanel />
|
||||
|
||||
<TextDisplay
|
||||
words={words}
|
||||
text={text}
|
||||
selectedWords={selectedWords}
|
||||
hoveredWord={hoveredWord}
|
||||
onWordClick={handleWordClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
onWordHover={setHoveredWord}
|
||||
/>
|
||||
|
||||
{contextMenu.visible && (
|
||||
<ContextMenu
|
||||
contextMenu={contextMenu}
|
||||
existingLabels={getExistingLabels()}
|
||||
// entityMappings={entityMappings} // SUPPRIMER cette ligne
|
||||
onApplyLabel={applyLabel}
|
||||
onApplyColor={applyColorDirectly}
|
||||
onRemoveLabel={removeLabel}
|
||||
getCurrentColor={getCurrentColor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,36 +1,203 @@
|
||||
import { Copy, Download, AlertTriangle } from "lucide-react";
|
||||
import { ReactNode } from "react";
|
||||
import { Copy, Download } from "lucide-react";
|
||||
import { InteractiveTextEditor } from "./InteractiveTextEditor";
|
||||
import { isValidEntityBoundary } from "@/app/utils/entityBoundary";
|
||||
import { EntityMapping } from "@/app/config/entityLabels"; // Importer l'interface unifiée
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
// Supprimer l'interface locale et utiliser celle de entityLabels.ts
|
||||
|
||||
interface ResultPreviewComponentProps {
|
||||
outputText: string;
|
||||
sourceText: string;
|
||||
copyToClipboard: () => void;
|
||||
downloadText: () => void;
|
||||
highlightEntities: (text: string, mappings?: EntityMapping[]) => ReactNode;
|
||||
entityMappings?: EntityMapping[];
|
||||
onMappingsUpdate?: (mappings: EntityMapping[]) => void;
|
||||
}
|
||||
|
||||
export const ResultPreviewComponent = ({
|
||||
outputText,
|
||||
sourceText,
|
||||
copyToClipboard,
|
||||
downloadText,
|
||||
highlightEntities,
|
||||
entityMappings,
|
||||
entityMappings = [],
|
||||
onMappingsUpdate,
|
||||
}: ResultPreviewComponentProps) => {
|
||||
// SUPPRIMER cette ligne
|
||||
// const { mappings, updateMapping, removeMappingByValueWithOptions } = useEntityMappings(entityMappings);
|
||||
|
||||
// Utiliser directement entityMappings du parent
|
||||
const handleUpdateMapping = (
|
||||
originalValue: string,
|
||||
newLabel: string,
|
||||
entityType: string,
|
||||
applyToAllOccurrences: boolean = false,
|
||||
customColor?: string,
|
||||
wordStart?: number,
|
||||
wordEnd?: number
|
||||
) => {
|
||||
// Créer les nouveaux mappings directement
|
||||
const filteredMappings = entityMappings.filter(
|
||||
(mapping) => mapping.text !== originalValue
|
||||
);
|
||||
|
||||
const newMappings: EntityMapping[] = [];
|
||||
|
||||
if (applyToAllOccurrences) {
|
||||
// Appliquer à toutes les occurrences
|
||||
let searchIndex = 0;
|
||||
while (true) {
|
||||
const foundIndex = sourceText.indexOf(originalValue, searchIndex);
|
||||
if (foundIndex === -1) break;
|
||||
|
||||
if (isValidEntityBoundary(foundIndex, sourceText, originalValue)) {
|
||||
newMappings.push({
|
||||
text: originalValue,
|
||||
entity_type: entityType,
|
||||
start: foundIndex,
|
||||
end: foundIndex + originalValue.length,
|
||||
displayName: newLabel,
|
||||
customColor: customColor,
|
||||
});
|
||||
}
|
||||
searchIndex = foundIndex + 1;
|
||||
}
|
||||
} else {
|
||||
// CORRECTION: Utiliser wordStart/wordEnd pour cibler le mapping exact
|
||||
if (wordStart !== undefined && wordEnd !== undefined) {
|
||||
// Chercher le mapping exact avec les coordonnées précises
|
||||
const targetMapping = entityMappings.find(
|
||||
(mapping) => mapping.start === wordStart && mapping.end === wordEnd
|
||||
);
|
||||
|
||||
if (targetMapping) {
|
||||
// Mettre à jour le mapping existant spécifique
|
||||
const updatedMappings = entityMappings.map((m) => {
|
||||
if (m.start === wordStart && m.end === wordEnd) {
|
||||
return {
|
||||
...m,
|
||||
entity_type: entityType,
|
||||
displayName: newLabel,
|
||||
customColor: customColor,
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
onMappingsUpdate?.(updatedMappings);
|
||||
return;
|
||||
} else {
|
||||
// Créer un nouveau mapping aux coordonnées précises
|
||||
newMappings.push({
|
||||
text: originalValue,
|
||||
entity_type: entityType,
|
||||
start: wordStart,
|
||||
end: wordEnd,
|
||||
displayName: newLabel,
|
||||
customColor: customColor,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback: logique existante si pas de coordonnées précises
|
||||
const existingMapping = entityMappings.find(
|
||||
(mapping) => mapping.text === originalValue
|
||||
);
|
||||
|
||||
if (existingMapping) {
|
||||
const updatedMappings = entityMappings.map((m) => {
|
||||
if (
|
||||
m.start === existingMapping.start &&
|
||||
m.end === existingMapping.end
|
||||
) {
|
||||
return {
|
||||
...m,
|
||||
entity_type: entityType,
|
||||
displayName: newLabel,
|
||||
customColor: customColor,
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
onMappingsUpdate?.(updatedMappings);
|
||||
return;
|
||||
} else {
|
||||
const foundIndex = sourceText.indexOf(originalValue);
|
||||
if (
|
||||
foundIndex !== -1 &&
|
||||
isValidEntityBoundary(foundIndex, sourceText, originalValue)
|
||||
) {
|
||||
newMappings.push({
|
||||
text: originalValue,
|
||||
entity_type: entityType,
|
||||
start: foundIndex,
|
||||
end: foundIndex + originalValue.length,
|
||||
displayName: newLabel,
|
||||
customColor: customColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notifier le parent avec les nouveaux mappings
|
||||
const allMappings = [...filteredMappings, ...newMappings];
|
||||
const uniqueMappings = allMappings.filter(
|
||||
(mapping, index, self) =>
|
||||
index ===
|
||||
self.findIndex(
|
||||
(m) => m.start === mapping.start && m.end === mapping.end
|
||||
)
|
||||
);
|
||||
|
||||
onMappingsUpdate?.(uniqueMappings.sort((a, b) => a.start - b.start));
|
||||
};
|
||||
|
||||
// NOUVELLE FONCTION: Gestion de la suppression avec applyToAll
|
||||
const handleRemoveMapping = (
|
||||
originalValue: string,
|
||||
applyToAll: boolean = false
|
||||
) => {
|
||||
console.log("handleRemoveMapping appelé:", {
|
||||
originalValue,
|
||||
applyToAll,
|
||||
});
|
||||
|
||||
// Notifier le parent avec les nouveaux mappings
|
||||
if (onMappingsUpdate) {
|
||||
const filteredMappings = entityMappings.filter(
|
||||
(mapping: EntityMapping) => {
|
||||
if (applyToAll) {
|
||||
// Supprimer toutes les occurrences
|
||||
return mapping.text !== originalValue;
|
||||
} else {
|
||||
// Supprimer seulement la première occurrence
|
||||
const firstOccurrenceIndex = entityMappings.findIndex(
|
||||
(m: EntityMapping) => m.text === originalValue
|
||||
);
|
||||
const currentIndex = entityMappings.indexOf(mapping);
|
||||
return !(
|
||||
mapping.text === originalValue &&
|
||||
currentIndex === firstOccurrenceIndex
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
onMappingsUpdate(
|
||||
filteredMappings.sort(
|
||||
(a: EntityMapping, b: EntityMapping) => a.start - b.start
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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é
|
||||
Document anonymisé (Mode interactif)
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
@@ -56,21 +223,12 @@ export const ResultPreviewComponent = ({
|
||||
|
||||
<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, entityMappings)}
|
||||
</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>
|
||||
<InteractiveTextEditor
|
||||
text={sourceText}
|
||||
entityMappings={entityMappings} // Utiliser entityMappings du parent au lieu de mappings
|
||||
onUpdateMapping={handleUpdateMapping}
|
||||
onRemoveMapping={handleRemoveMapping}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
interface SampleTextComponentProps {
|
||||
setSourceText: (text: string) => void;
|
||||
setFileContent: (content: string) => void;
|
||||
setUploadedFile: (file: File | null) => void;
|
||||
setIsExampleLoaded?: (loaded: boolean) => void;
|
||||
variant?: "button" | "link"; // Nouvelle prop
|
||||
variant?: "button" | "link";
|
||||
}
|
||||
|
||||
export const SampleTextComponent = ({
|
||||
setSourceText,
|
||||
setFileContent,
|
||||
setUploadedFile,
|
||||
setIsExampleLoaded,
|
||||
variant = "button",
|
||||
@@ -26,16 +24,15 @@ Le contrat de prestation signé le 3 janvier 2024 prévoyait un montant de 75 00
|
||||
- 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.
|
||||
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);
|
||||
if (setIsExampleLoaded) {
|
||||
setIsExampleLoaded(true); // NOUVEAU - Marquer qu'un exemple est chargé
|
||||
setIsExampleLoaded(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
108
app/components/TextDisplay.tsx
Normal file
108
app/components/TextDisplay.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from "react";
|
||||
import { generateColorFromName } from "@/app/config/colorPalette";
|
||||
import { Word } from "./hooks/useTextParsing";
|
||||
|
||||
interface TextDisplayProps {
|
||||
words: Word[];
|
||||
text: string;
|
||||
selectedWords: Set<number>;
|
||||
hoveredWord: number | null;
|
||||
onWordClick: (index: number, event: React.MouseEvent) => void;
|
||||
onContextMenu: (event: React.MouseEvent) => void;
|
||||
onWordHover: (index: number | null) => void;
|
||||
}
|
||||
|
||||
export const TextDisplay: React.FC<TextDisplayProps> = ({
|
||||
words,
|
||||
text,
|
||||
selectedWords,
|
||||
hoveredWord,
|
||||
onWordClick,
|
||||
onContextMenu,
|
||||
onWordHover,
|
||||
}) => {
|
||||
const renderWord = (word: Word, index: number) => {
|
||||
const isSelected = selectedWords.has(index);
|
||||
const isHovered = hoveredWord === index;
|
||||
|
||||
let className =
|
||||
"inline-block cursor-pointer transition-all duration-200 px-1 py-0.5 rounded-sm ";
|
||||
let backgroundColor = "transparent";
|
||||
|
||||
if (word.isEntity) {
|
||||
// Couleur personnalisée ou générée - Niveau 200
|
||||
if (word.mapping?.customColor) {
|
||||
backgroundColor = word.mapping.customColor;
|
||||
} else if (word.mapping?.displayName) {
|
||||
// Utiliser generateColorFromName pour la cohérence
|
||||
backgroundColor = generateColorFromName(word.mapping.displayName).value;
|
||||
} else if (word.entityType) {
|
||||
backgroundColor = generateColorFromName(word.entityType).value;
|
||||
} else {
|
||||
// Couleur par défaut si aucune information disponible
|
||||
backgroundColor = generateColorFromName("default").value;
|
||||
}
|
||||
|
||||
// Utiliser la classe CSS appropriée
|
||||
if (word.mapping?.displayName) {
|
||||
const colorClass = generateColorFromName(word.mapping.displayName);
|
||||
className += `${colorClass.bgClass} ${colorClass.textClass} border `;
|
||||
} else if (word.entityType) {
|
||||
const colorClass = generateColorFromName(word.entityType);
|
||||
className += `${colorClass.bgClass} ${colorClass.textClass} border `;
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion du survol et sélection - Couleurs claires
|
||||
if (isSelected) {
|
||||
className += "ring-2 ring-blue-400 ";
|
||||
} else if (isHovered) {
|
||||
if (!word.isEntity) {
|
||||
className += "bg-gray-200 ";
|
||||
backgroundColor = "#E5E7EB"; // gray-200
|
||||
}
|
||||
}
|
||||
|
||||
className += "brightness-95 ";
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className={className}
|
||||
style={{
|
||||
backgroundColor: backgroundColor,
|
||||
}}
|
||||
onMouseEnter={() => onWordHover(index)}
|
||||
onMouseLeave={() => onWordHover(null)}
|
||||
onClick={(e) => onWordClick(index, e)}
|
||||
onContextMenu={onContextMenu}
|
||||
title={
|
||||
word.isEntity
|
||||
? `Entité: ${word.entityType} (Original: ${word.text})`
|
||||
: "Cliquez pour sélectionner"
|
||||
}
|
||||
>
|
||||
{word.displayText}{" "}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white border border-gray-200 rounded-lg min-h-[300px] leading-relaxed text-sm">
|
||||
<div className="whitespace-pre-wrap">
|
||||
{words.map((word, index) => {
|
||||
const nextWord = words[index + 1];
|
||||
const spaceBetween = nextWord
|
||||
? text.slice(word.end, nextWord.start)
|
||||
: text.slice(word.end);
|
||||
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
{renderWord(word, index)}
|
||||
<span>{spaceBetween}</span>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
app/components/hooks/useColorMapping.ts
Normal file
61
app/components/hooks/useColorMapping.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
import {
|
||||
COLOR_PALETTE,
|
||||
generateColorFromName,
|
||||
type ColorOption,
|
||||
} from "../../config/colorPalette";
|
||||
|
||||
export const useColorMapping = (entityMappings: EntityMapping[]) => {
|
||||
const colorOptions: ColorOption[] = COLOR_PALETTE;
|
||||
|
||||
const tailwindToHex = useMemo(() => {
|
||||
const mapping: Record<string, string> = {};
|
||||
COLOR_PALETTE.forEach((color) => {
|
||||
mapping[color.bgClass] = color.value;
|
||||
});
|
||||
return mapping;
|
||||
}, []);
|
||||
|
||||
// CORRECTION: Fonction qui prend un texte et retourne la couleur
|
||||
const getCurrentColor = useCallback(
|
||||
(selectedText: string): string => {
|
||||
if (!selectedText || !entityMappings) {
|
||||
return COLOR_PALETTE[0].value;
|
||||
}
|
||||
|
||||
// Chercher le mapping correspondant au texte sélectionné
|
||||
const mapping = entityMappings.find((m) => m.text === selectedText);
|
||||
|
||||
if (mapping?.customColor) {
|
||||
return mapping.customColor;
|
||||
}
|
||||
|
||||
if (mapping?.displayName) {
|
||||
return generateColorFromName(mapping.displayName).value;
|
||||
}
|
||||
|
||||
if (mapping?.entity_type) {
|
||||
return generateColorFromName(mapping.entity_type).value;
|
||||
}
|
||||
|
||||
// Générer une couleur basée sur le texte
|
||||
return generateColorFromName(selectedText).value;
|
||||
},
|
||||
[entityMappings]
|
||||
);
|
||||
|
||||
const getColorByText = useCallback(
|
||||
(selectedText: string) => {
|
||||
return getCurrentColor(selectedText);
|
||||
},
|
||||
[getCurrentColor]
|
||||
);
|
||||
|
||||
return {
|
||||
colorOptions,
|
||||
tailwindToHex,
|
||||
getCurrentColor,
|
||||
getColorByText,
|
||||
};
|
||||
};
|
||||
207
app/components/hooks/useContextMenu.ts
Normal file
207
app/components/hooks/useContextMenu.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
import { Word } from "./useTextParsing"; // AJOUTER cet import
|
||||
|
||||
interface ContextMenuState {
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
selectedText: string;
|
||||
wordIndices: number[];
|
||||
}
|
||||
|
||||
interface UseContextMenuProps {
|
||||
entityMappings: EntityMapping[];
|
||||
words: Word[]; // Maintenant le type Word est reconnu
|
||||
onUpdateMapping: (
|
||||
originalValue: string,
|
||||
newLabel: string,
|
||||
entityType: string,
|
||||
applyToAll?: boolean,
|
||||
customColor?: string,
|
||||
wordStart?: number,
|
||||
wordEnd?: number
|
||||
) => void;
|
||||
onRemoveMapping?: (originalValue: string, applyToAll?: boolean) => void;
|
||||
getCurrentColor: (selectedText: string) => string;
|
||||
setSelectedWords: (words: Set<number>) => void;
|
||||
}
|
||||
|
||||
export const useContextMenu = ({
|
||||
entityMappings,
|
||||
words, // Paramètre ajouté
|
||||
onUpdateMapping,
|
||||
onRemoveMapping,
|
||||
getCurrentColor,
|
||||
setSelectedWords,
|
||||
}: UseContextMenuProps) => {
|
||||
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
selectedText: "",
|
||||
wordIndices: [],
|
||||
});
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu((prev) => ({ ...prev, visible: false }));
|
||||
}, []);
|
||||
|
||||
const showContextMenu = useCallback(
|
||||
(menuData: Omit<ContextMenuState, "visible">) => {
|
||||
setContextMenu({ ...menuData, visible: true });
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getExistingLabels = useCallback(() => {
|
||||
const uniqueLabels = new Set<string>();
|
||||
entityMappings.forEach((mapping) => {
|
||||
uniqueLabels.add(mapping.displayName || mapping.entity_type); // Utiliser displayName
|
||||
});
|
||||
return Array.from(uniqueLabels).sort();
|
||||
}, [entityMappings]);
|
||||
|
||||
// CORRECTION: Accepter displayName comme premier paramètre
|
||||
const applyLabel = useCallback(
|
||||
(displayName: string, applyToAll?: boolean) => {
|
||||
if (!contextMenu.selectedText) return;
|
||||
|
||||
const originalText = contextMenu.selectedText;
|
||||
const firstWordIndex = contextMenu.wordIndices[0];
|
||||
|
||||
// Calculer les vraies coordonnées start/end du mot cliqué
|
||||
const clickedWord = words[firstWordIndex];
|
||||
const wordStart = clickedWord?.start;
|
||||
const wordEnd = clickedWord?.end;
|
||||
|
||||
const existingMapping = entityMappings.find(
|
||||
(m) => m.text === originalText
|
||||
);
|
||||
const entityType =
|
||||
existingMapping?.entity_type ||
|
||||
displayName.replace(/[\[\]]/g, "").toUpperCase();
|
||||
|
||||
onUpdateMapping(
|
||||
originalText,
|
||||
displayName,
|
||||
entityType,
|
||||
applyToAll,
|
||||
undefined, // customColor
|
||||
wordStart, // vraies coordonnées start
|
||||
wordEnd // vraies coordonnées end
|
||||
);
|
||||
|
||||
setSelectedWords(new Set());
|
||||
closeContextMenu();
|
||||
},
|
||||
[
|
||||
contextMenu,
|
||||
words, // NOUVEAU
|
||||
entityMappings,
|
||||
onUpdateMapping,
|
||||
closeContextMenu,
|
||||
setSelectedWords,
|
||||
]
|
||||
);
|
||||
|
||||
// CORRECTION: Accepter applyToAll comme paramètre
|
||||
const applyColorDirectly = useCallback(
|
||||
(color: string, colorName: string, applyToAll?: boolean) => {
|
||||
if (!contextMenu.selectedText) return;
|
||||
|
||||
const existingMapping = entityMappings.find(
|
||||
(mapping) => mapping.text === contextMenu.selectedText
|
||||
);
|
||||
|
||||
console.log("useContextMenu - applyColorDirectly:", {
|
||||
color,
|
||||
colorName,
|
||||
applyToAll,
|
||||
existingMapping,
|
||||
});
|
||||
|
||||
if (existingMapping) {
|
||||
onUpdateMapping(
|
||||
contextMenu.selectedText,
|
||||
existingMapping.displayName || existingMapping.entity_type, // Utiliser displayName
|
||||
existingMapping.entity_type,
|
||||
applyToAll,
|
||||
color
|
||||
);
|
||||
} else {
|
||||
onUpdateMapping(
|
||||
contextMenu.selectedText,
|
||||
"CUSTOM_LABEL",
|
||||
"CUSTOM_LABEL",
|
||||
applyToAll,
|
||||
color
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedWords(new Set());
|
||||
closeContextMenu();
|
||||
},
|
||||
[
|
||||
contextMenu.selectedText,
|
||||
entityMappings, // Ajouter cette dépendance
|
||||
onUpdateMapping,
|
||||
closeContextMenu,
|
||||
setSelectedWords,
|
||||
]
|
||||
);
|
||||
|
||||
// CORRECTION: Accepter applyToAll comme paramètre
|
||||
const removeLabel = useCallback(
|
||||
(applyToAll?: boolean) => {
|
||||
if (!contextMenu.selectedText || !onRemoveMapping) return;
|
||||
|
||||
console.log("useContextMenu - removeLabel:", {
|
||||
selectedText: contextMenu.selectedText,
|
||||
applyToAll,
|
||||
});
|
||||
|
||||
onRemoveMapping(contextMenu.selectedText, applyToAll);
|
||||
setSelectedWords(new Set());
|
||||
closeContextMenu();
|
||||
},
|
||||
[
|
||||
contextMenu.selectedText,
|
||||
onRemoveMapping,
|
||||
closeContextMenu,
|
||||
setSelectedWords,
|
||||
]
|
||||
);
|
||||
|
||||
// Gestion des clics en dehors du menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (contextMenu.visible) {
|
||||
const target = event.target as Element;
|
||||
const contextMenuElement = document.querySelector(
|
||||
"[data-context-menu]"
|
||||
);
|
||||
|
||||
if (contextMenuElement && !contextMenuElement.contains(target)) {
|
||||
setTimeout(() => {
|
||||
closeContextMenu();
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [contextMenu.visible, closeContextMenu]);
|
||||
|
||||
return {
|
||||
contextMenu,
|
||||
showContextMenu,
|
||||
closeContextMenu,
|
||||
applyLabel,
|
||||
applyColorDirectly,
|
||||
removeLabel,
|
||||
getExistingLabels,
|
||||
getCurrentColor,
|
||||
};
|
||||
};
|
||||
98
app/components/hooks/useTextParsing.ts
Normal file
98
app/components/hooks/useTextParsing.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useMemo } from "react";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
|
||||
export interface Word {
|
||||
text: string;
|
||||
displayText: string;
|
||||
start: number;
|
||||
end: number;
|
||||
isEntity: boolean;
|
||||
entityType?: string;
|
||||
entityIndex?: number;
|
||||
mapping?: EntityMapping;
|
||||
}
|
||||
|
||||
export const useTextParsing = (
|
||||
text: string,
|
||||
entityMappings: EntityMapping[]
|
||||
) => {
|
||||
const words = useMemo((): Word[] => {
|
||||
const segments: Word[] = [];
|
||||
let currentIndex = 0;
|
||||
|
||||
const sortedMappings = [...entityMappings].sort(
|
||||
(a, b) => a.start - b.start // CORRECTION: utiliser 'start' au lieu de 'startIndex'
|
||||
);
|
||||
|
||||
sortedMappings.forEach((mapping, mappingIndex) => {
|
||||
if (currentIndex < mapping.start) {
|
||||
// CORRECTION: utiliser 'start'
|
||||
const beforeText = text.slice(currentIndex, mapping.start);
|
||||
const beforeWords = beforeText.split(/\s+/).filter(Boolean);
|
||||
|
||||
beforeWords.forEach((word) => {
|
||||
const wordStart = text.indexOf(word, currentIndex);
|
||||
const wordEnd = wordStart + word.length;
|
||||
|
||||
segments.push({
|
||||
text: word,
|
||||
displayText: word,
|
||||
start: wordStart,
|
||||
end: wordEnd,
|
||||
isEntity: false,
|
||||
});
|
||||
|
||||
currentIndex = wordEnd;
|
||||
});
|
||||
}
|
||||
|
||||
// Utiliser displayName au lieu de entity_type
|
||||
// Ligne 45 - Ajouter du debug
|
||||
console.log("useTextParsing - mapping:", {
|
||||
text: mapping.text,
|
||||
displayName: mapping.displayName,
|
||||
entity_type: mapping.entity_type,
|
||||
});
|
||||
|
||||
const anonymizedText =
|
||||
mapping.displayName || `[${mapping.entity_type.toUpperCase()}]`;
|
||||
|
||||
segments.push({
|
||||
text: mapping.text,
|
||||
displayText: anonymizedText,
|
||||
start: mapping.start,
|
||||
end: mapping.end,
|
||||
isEntity: true,
|
||||
entityType: mapping.entity_type,
|
||||
entityIndex: mappingIndex,
|
||||
mapping: mapping,
|
||||
});
|
||||
|
||||
currentIndex = mapping.end; // CORRECTION: utiliser 'end'
|
||||
});
|
||||
|
||||
if (currentIndex < text.length) {
|
||||
const remainingText = text.slice(currentIndex);
|
||||
const remainingWords = remainingText.split(/\s+/).filter(Boolean);
|
||||
|
||||
remainingWords.forEach((word) => {
|
||||
const wordStart = text.indexOf(word, currentIndex);
|
||||
const wordEnd = wordStart + word.length;
|
||||
|
||||
segments.push({
|
||||
text: word,
|
||||
displayText: word,
|
||||
start: wordStart,
|
||||
end: wordEnd,
|
||||
isEntity: false,
|
||||
});
|
||||
|
||||
currentIndex = wordEnd;
|
||||
});
|
||||
}
|
||||
|
||||
return segments;
|
||||
}, [text, entityMappings]);
|
||||
|
||||
return { words };
|
||||
};
|
||||
83
app/config/colorPalette.ts
Normal file
83
app/config/colorPalette.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export interface ColorOption {
|
||||
name: string;
|
||||
value: string;
|
||||
bgClass: string;
|
||||
textClass: string;
|
||||
}
|
||||
|
||||
// Palette de couleurs harmonisée (équivalent Tailwind 200)
|
||||
export const COLOR_PALETTE: ColorOption[] = [
|
||||
{
|
||||
name: 'Rouge',
|
||||
value: '#fecaca', // red-200
|
||||
bgClass: 'bg-red-200',
|
||||
textClass: 'text-red-800'
|
||||
},
|
||||
{
|
||||
name: 'Orange',
|
||||
value: '#fed7aa', // orange-200
|
||||
bgClass: 'bg-orange-200',
|
||||
textClass: 'text-orange-800'
|
||||
},
|
||||
{
|
||||
name: 'Jaune',
|
||||
value: '#fef3c7', // yellow-200
|
||||
bgClass: 'bg-yellow-200',
|
||||
textClass: 'text-yellow-800'
|
||||
},
|
||||
{
|
||||
name: 'Vert',
|
||||
value: '#bbf7d0', // green-200
|
||||
bgClass: 'bg-green-200',
|
||||
textClass: 'text-green-800'
|
||||
},
|
||||
{
|
||||
name: 'Bleu',
|
||||
value: '#bfdbfe', // blue-200
|
||||
bgClass: 'bg-blue-200',
|
||||
textClass: 'text-blue-800'
|
||||
},
|
||||
{
|
||||
name: 'Indigo',
|
||||
value: '#c7d2fe', // indigo-200
|
||||
bgClass: 'bg-indigo-200',
|
||||
textClass: 'text-indigo-800'
|
||||
},
|
||||
{
|
||||
name: 'Violet',
|
||||
value: '#ddd6fe', // violet-200
|
||||
bgClass: 'bg-violet-200',
|
||||
textClass: 'text-violet-800'
|
||||
},
|
||||
{
|
||||
name: 'Rose',
|
||||
value: '#fbcfe8', // pink-200
|
||||
bgClass: 'bg-pink-200',
|
||||
textClass: 'text-pink-800'
|
||||
}
|
||||
];
|
||||
|
||||
// Fonction pour obtenir une couleur par hash
|
||||
export function generateColorFromName(name: string): ColorOption {
|
||||
// Vérification de sécurité
|
||||
if (!name || typeof name !== 'string' || name.length === 0) {
|
||||
return COLOR_PALETTE[0]; // Retourner la première couleur par défaut
|
||||
}
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
const char = name.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
const index = Math.abs(hash) % COLOR_PALETTE.length;
|
||||
return COLOR_PALETTE[index];
|
||||
}
|
||||
|
||||
// Fonction pour obtenir la couleur hex
|
||||
export function getHexColorFromName(name: string): string {
|
||||
return generateColorFromName(name).value;
|
||||
}
|
||||
|
||||
// Export des valeurs hex pour compatibilité
|
||||
export const COLOR_VALUES = COLOR_PALETTE.map(color => color.value);
|
||||
65
app/config/entityLabels.ts
Normal file
65
app/config/entityLabels.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Configuration des entités basée sur replacements.yaml
|
||||
// Système 100% dynamique
|
||||
// Tout est récupéré depuis Presidio
|
||||
|
||||
export interface EntityPattern {
|
||||
regex: RegExp;
|
||||
className: string;
|
||||
label: string;
|
||||
presidioType: string;
|
||||
}
|
||||
|
||||
export interface PresidioConfig {
|
||||
replacements: Record<string, string>;
|
||||
default_anonymizers: Record<string, string>;
|
||||
}
|
||||
|
||||
// Interface pour les résultats Presidio
|
||||
/**
|
||||
* Interfaces pour les données de Presidio et le mapping.
|
||||
* Simplifié pour ne contenir que les définitions nécessaires.
|
||||
*/
|
||||
|
||||
// Interface pour un résultat d'analyse de Presidio
|
||||
export interface PresidioAnalyzerResult {
|
||||
entity_type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
// Interface pour une ligne du tableau de mapping
|
||||
import { generateColorFromName, getHexColorFromName } from "./colorPalette";
|
||||
|
||||
export interface EntityMapping {
|
||||
entity_type: string;
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
replacementValue?: string;
|
||||
displayName?: string; // Ajouter cette propriété
|
||||
customColor?: string;
|
||||
}
|
||||
|
||||
// Utiliser la palette centralisée
|
||||
export { generateColorFromName, getHexColorFromName };
|
||||
|
||||
/**
|
||||
* Récupère la configuration Presidio depuis l'API
|
||||
*/
|
||||
export const fetchPresidioConfig = async (): Promise<PresidioConfig | null> => {
|
||||
try {
|
||||
const response = await fetch("/api/presidio/config");
|
||||
if (!response.ok) {
|
||||
console.warn("Impossible de récupérer la config Presidio");
|
||||
return null;
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Erreur lors de la récupération de la config Presidio:",
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
137
app/hooks/useEntityMappings.ts
Normal file
137
app/hooks/useEntityMappings.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
|
||||
export const useEntityMappings = (initialMappings: EntityMapping[] = []) => {
|
||||
const [mappings, setMappings] = useState<EntityMapping[]>(initialMappings);
|
||||
|
||||
const updateMapping = useCallback(
|
||||
(
|
||||
originalValue: string,
|
||||
newLabel: string,
|
||||
entityType: string,
|
||||
sourceText: string,
|
||||
applyToAllOccurrences: boolean = false,
|
||||
customColor?: string
|
||||
) => {
|
||||
setMappings((prevMappings) => {
|
||||
let baseMappings = [...prevMappings];
|
||||
const newMappings: EntityMapping[] = [];
|
||||
|
||||
if (applyToAllOccurrences) {
|
||||
// Supprimer toutes les anciennes occurrences et les recréer
|
||||
baseMappings = baseMappings.filter((m) => m.text !== originalValue);
|
||||
|
||||
let searchIndex = 0;
|
||||
while (true) {
|
||||
const foundIndex = sourceText.indexOf(originalValue, searchIndex);
|
||||
if (foundIndex === -1) break;
|
||||
|
||||
newMappings.push({
|
||||
text: originalValue,
|
||||
entity_type: entityType,
|
||||
start: foundIndex,
|
||||
end: foundIndex + originalValue.length,
|
||||
customColor: customColor,
|
||||
});
|
||||
searchIndex = foundIndex + originalValue.length;
|
||||
}
|
||||
} else {
|
||||
// Mettre à jour une seule occurrence ou en créer une nouvelle
|
||||
const existingMapping = prevMappings.find(
|
||||
(m) => m.text === originalValue
|
||||
);
|
||||
|
||||
if (existingMapping) {
|
||||
// Remplacer le mapping existant au lieu de filtrer
|
||||
baseMappings = prevMappings.map((m) => {
|
||||
if (
|
||||
m.start === existingMapping.start &&
|
||||
m.end === existingMapping.end
|
||||
) {
|
||||
return {
|
||||
...m,
|
||||
entity_type: entityType,
|
||||
displayName: newLabel, // Utiliser newLabel au lieu de préserver l'ancien
|
||||
customColor: customColor,
|
||||
};
|
||||
}
|
||||
return m;
|
||||
});
|
||||
} else {
|
||||
// Créer un nouveau mapping pour du texte non reconnu
|
||||
const foundIndex = sourceText.indexOf(originalValue);
|
||||
if (foundIndex !== -1) {
|
||||
newMappings.push({
|
||||
text: originalValue,
|
||||
entity_type: entityType,
|
||||
start: foundIndex,
|
||||
end: foundIndex + originalValue.length,
|
||||
displayName: newLabel, // Utiliser newLabel au lieu de entityType
|
||||
customColor: customColor,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Combiner, dédupliquer et trier
|
||||
const allMappings = [...baseMappings, ...newMappings];
|
||||
const uniqueMappings = allMappings.filter(
|
||||
(mapping, index, self) =>
|
||||
index ===
|
||||
self.findIndex(
|
||||
(m) => m.start === mapping.start && m.end === mapping.end
|
||||
)
|
||||
);
|
||||
|
||||
return uniqueMappings.sort((a, b) => a.start - b.start);
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addMapping = useCallback((mapping: EntityMapping) => {
|
||||
setMappings((prev) => [...prev, mapping]);
|
||||
}, []);
|
||||
|
||||
const removeMapping = useCallback((index: number) => {
|
||||
setMappings((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const removeMappingByValue = useCallback((originalValue: string) => {
|
||||
setMappings((prev) =>
|
||||
prev.filter((mapping) => mapping.text !== originalValue)
|
||||
);
|
||||
}, []);
|
||||
|
||||
// NOUVELLE FONCTION: Suppression avec gestion d'applyToAll
|
||||
const removeMappingByValueWithOptions = useCallback(
|
||||
(originalValue: string, applyToAll: boolean = false) => {
|
||||
setMappings((prev) => {
|
||||
if (applyToAll) {
|
||||
// Supprimer toutes les occurrences du texte
|
||||
return prev.filter((mapping) => mapping.text !== originalValue);
|
||||
} else {
|
||||
// Supprimer seulement la première occurrence ou celle à la position actuelle
|
||||
// Pour l'instant, on supprime la première occurrence trouvée
|
||||
const indexToRemove = prev.findIndex(
|
||||
(mapping) => mapping.text === originalValue
|
||||
);
|
||||
if (indexToRemove !== -1) {
|
||||
return prev.filter((_, index) => index !== indexToRemove);
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
return {
|
||||
mappings,
|
||||
updateMapping,
|
||||
addMapping,
|
||||
removeMapping,
|
||||
removeMappingByValue,
|
||||
removeMappingByValueWithOptions, // Ajouter la nouvelle fonction
|
||||
};
|
||||
};
|
||||
66
app/page.tsx
66
app/page.tsx
@@ -1,31 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { FileUploadComponent } from "./components/FileUploadComponent";
|
||||
|
||||
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 { ResultPreviewComponent } from "./components/ResultPreviewComponent";
|
||||
import { EntityMapping } from "./config/entityLabels"; // Importer l'interface unifiée
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
// Supprimer l'interface locale EntityMapping (lignes 12-18)
|
||||
|
||||
export default function Home() {
|
||||
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 [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||
const [entityMappings, setEntityMappings] = useState<EntityMapping[]>([]);
|
||||
const [isExampleLoaded, setIsExampleLoaded] = useState(false); // NOUVEAU
|
||||
const [isExampleLoaded, setIsExampleLoaded] = useState(false);
|
||||
|
||||
const progressSteps = ["Téléversement", "Prévisualisation", "Anonymisation"];
|
||||
|
||||
@@ -40,35 +34,46 @@ export default function Home() {
|
||||
setSourceText("");
|
||||
setOutputText("");
|
||||
setUploadedFile(null);
|
||||
setFileContent("");
|
||||
setError(null);
|
||||
setIsLoadingFile(false);
|
||||
setEntityMappings([]);
|
||||
setIsExampleLoaded(false); // NOUVEAU
|
||||
setIsExampleLoaded(false);
|
||||
};
|
||||
|
||||
// Fonction pour mettre à jour les mappings depuis l'éditeur interactif
|
||||
const handleMappingsUpdate = useCallback(
|
||||
(updatedMappings: EntityMapping[]) => {
|
||||
setEntityMappings(updatedMappings);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Hooks personnalisés pour la logique métier
|
||||
const { handleFileChange } = useFileHandler({
|
||||
setUploadedFile,
|
||||
setSourceText,
|
||||
setFileContent,
|
||||
setError,
|
||||
setIsLoadingFile, // Passer le setter
|
||||
setIsLoadingFile,
|
||||
});
|
||||
|
||||
const { anonymizeData, isProcessing } = useAnonymization({
|
||||
sourceText,
|
||||
fileContent,
|
||||
uploadedFile,
|
||||
setOutputText,
|
||||
setError,
|
||||
setEntityMappings,
|
||||
});
|
||||
|
||||
const { copyToClipboard, downloadText } = useDownloadActions({ outputText });
|
||||
const { copyToClipboard, downloadText } = useDownloadActions({
|
||||
outputText,
|
||||
entityMappings,
|
||||
});
|
||||
|
||||
// Fonction wrapper pour appeler anonymizeData avec les bonnes données
|
||||
const handleAnonymize = () => {
|
||||
anonymizeData({ file: uploadedFile, text: sourceText });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full overflow-hidden">
|
||||
<div className="min-h-screen w-full overflow-hidden">
|
||||
{/* Main Content */}
|
||||
<div className="max-w-6xl mx-auto px-2 sm:px-4 py-4 sm:py-8 space-y-4">
|
||||
{/* Progress Bar */}
|
||||
@@ -83,8 +88,7 @@ export default function Home() {
|
||||
sourceText={sourceText}
|
||||
setSourceText={setSourceText}
|
||||
setUploadedFile={setUploadedFile}
|
||||
setFileContent={setFileContent}
|
||||
onAnonymize={anonymizeData}
|
||||
onAnonymize={handleAnonymize}
|
||||
isProcessing={isProcessing}
|
||||
canAnonymize={
|
||||
uploadedFile !== null ||
|
||||
@@ -97,11 +101,27 @@ export default function Home() {
|
||||
downloadText={downloadText}
|
||||
isExampleLoaded={isExampleLoaded}
|
||||
setIsExampleLoaded={setIsExampleLoaded}
|
||||
entityMappings={entityMappings} // Ajouter cette ligne
|
||||
entityMappings={entityMappings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Text Editor - Nouveau composant pour l'édition interactive */}
|
||||
{outputText && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||
<div className="p-1 sm:p-3">
|
||||
<ResultPreviewComponent
|
||||
outputText={outputText}
|
||||
sourceText={sourceText}
|
||||
copyToClipboard={copyToClipboard}
|
||||
downloadText={downloadText}
|
||||
entityMappings={entityMappings}
|
||||
onMappingsUpdate={handleMappingsUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Entity Mapping Table - Seulement si outputText existe */}
|
||||
{outputText && (
|
||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden">
|
||||
|
||||
17
app/utils/entityBoundary.ts
Normal file
17
app/utils/entityBoundary.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Vérifie si une entité est située à une limite de mot valide
|
||||
* @param index Position de début de l'entité dans le texte
|
||||
* @param text Texte complet
|
||||
* @param word Mot/entité à vérifier
|
||||
* @returns true si l'entité est à une limite de mot valide
|
||||
*/
|
||||
export const isValidEntityBoundary = (
|
||||
index: number,
|
||||
text: string,
|
||||
word: string
|
||||
): boolean => {
|
||||
const before = index === 0 || /\s/.test(text[index - 1]);
|
||||
const after =
|
||||
index + word.length === text.length || /\s/.test(text[index + word.length]);
|
||||
return before && after;
|
||||
};
|
||||
32
app/utils/generateAnonymizedText.ts
Normal file
32
app/utils/generateAnonymizedText.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { EntityMapping } from "@/app/config/entityLabels";
|
||||
|
||||
export const generateAnonymizedText = (
|
||||
originalText: string,
|
||||
mappings: EntityMapping[]
|
||||
): string => {
|
||||
if (!originalText || !mappings || mappings.length === 0) {
|
||||
return originalText;
|
||||
}
|
||||
|
||||
// Trier les mappings par position de début
|
||||
const sortedMappings = [...mappings].sort((a, b) => a.start - b.start);
|
||||
|
||||
let result = "";
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const mapping of sortedMappings) {
|
||||
// Ajouter le texte avant l'entité
|
||||
result += originalText.slice(lastIndex, mapping.start);
|
||||
|
||||
// Utiliser displayName comme dans le tableau de mapping
|
||||
result += mapping.displayName;
|
||||
|
||||
// Mettre à jour la position
|
||||
lastIndex = mapping.end;
|
||||
}
|
||||
|
||||
// Ajouter le reste du texte
|
||||
result += originalText.slice(lastIndex);
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -1,199 +1,50 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export const patterns = [
|
||||
{
|
||||
regex: /<PERSON>/g,
|
||||
className: "bg-blue-200 text-blue-800",
|
||||
label: "Personne",
|
||||
},
|
||||
{
|
||||
regex: /<EMAIL_ADDRESS>/g,
|
||||
className: "bg-green-200 text-green-800",
|
||||
label: "Adresse Email",
|
||||
},
|
||||
{
|
||||
regex: /<PHONE_NUMBER>/g,
|
||||
className: "bg-purple-200 text-purple-800",
|
||||
label: "N° de Téléphone",
|
||||
},
|
||||
{
|
||||
regex: /<LOCATION>/g,
|
||||
className: "bg-red-200 text-red-800",
|
||||
label: "Lieu",
|
||||
},
|
||||
{
|
||||
regex: /<IBAN>/g,
|
||||
className: "bg-yellow-200 text-yellow-800",
|
||||
label: "IBAN",
|
||||
},
|
||||
{
|
||||
regex: /<ORGANIZATION>/g,
|
||||
className: "bg-indigo-200 text-indigo-800",
|
||||
label: "Organisation",
|
||||
},
|
||||
{
|
||||
regex: /<FLEXIBLE_DATE>/g,
|
||||
className: "bg-pink-200 text-pink-800",
|
||||
label: "Date",
|
||||
},
|
||||
{
|
||||
regex: /<BE_ADDRESS>/g,
|
||||
className: "bg-cyan-200 text-cyan-800",
|
||||
label: "Adresse (BE)",
|
||||
},
|
||||
{
|
||||
regex: /<BE_PHONE_NUMBER>/g,
|
||||
className: "bg-violet-200 text-violet-800",
|
||||
label: "N° de Tél. (BE)",
|
||||
},
|
||||
{
|
||||
regex: /<CREDIT_CARD>/g,
|
||||
className: "bg-orange-200 text-orange-800",
|
||||
label: "Carte de Crédit",
|
||||
},
|
||||
{
|
||||
regex: /<URL>/g,
|
||||
className: "bg-teal-200 text-teal-800",
|
||||
label: "URL",
|
||||
},
|
||||
{
|
||||
regex: /<IP_ADDRESS>/g,
|
||||
className: "bg-gray-300 text-gray-900",
|
||||
label: "Adresse IP",
|
||||
},
|
||||
{
|
||||
regex: /<DATE_TIME>/g,
|
||||
className: "bg-pink-300 text-pink-900",
|
||||
label: "Date & Heure",
|
||||
},
|
||||
{
|
||||
regex: /<NRP>/g,
|
||||
className: "bg-red-300 text-red-900",
|
||||
label: "N° Registre National",
|
||||
},
|
||||
{
|
||||
regex: /<BE_VAT>/g,
|
||||
className: "bg-yellow-300 text-yellow-900",
|
||||
label: "TVA (BE)",
|
||||
},
|
||||
{
|
||||
regex: /<BE_ENTERPRISE_NUMBER>/g,
|
||||
className: "bg-lime-200 text-lime-800",
|
||||
label: "N° d'entreprise (BE)",
|
||||
},
|
||||
{
|
||||
regex: /<BE_PRO_ID>/g,
|
||||
className: "bg-emerald-200 text-emerald-800",
|
||||
label: "ID Pro (BE)",
|
||||
},
|
||||
];
|
||||
|
||||
interface EntityMapping {
|
||||
originalValue: string;
|
||||
anonymizedValue: string;
|
||||
entityType: string;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
import React, { ReactNode } from "react";
|
||||
import {
|
||||
generateColorFromName,
|
||||
EntityMapping,
|
||||
} from "@/app/config/entityLabels";
|
||||
|
||||
export const highlightEntities = (
|
||||
text: string,
|
||||
entityMappings?: EntityMapping[]
|
||||
): ReactNode => {
|
||||
if (!text) return text;
|
||||
|
||||
const replacements: Array<{
|
||||
start: number;
|
||||
end: number;
|
||||
element: ReactNode;
|
||||
}> = [];
|
||||
|
||||
// Trouver toutes les correspondances
|
||||
patterns.forEach((pattern, patternIndex) => {
|
||||
const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
|
||||
let match;
|
||||
let matchCount = 0; // Compteur pour ce type d'entité
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const start = match.index;
|
||||
const end = match.index + match[0].length;
|
||||
|
||||
// Vérifier qu'il n'y a pas de chevauchement avec des remplacements existants
|
||||
const hasOverlap = replacements.some(
|
||||
(r) =>
|
||||
(start >= r.start && start < r.end) || (end > r.start && end <= r.end)
|
||||
);
|
||||
|
||||
if (!hasOverlap) {
|
||||
matchCount++; // Incrémenter le compteur pour ce type
|
||||
let displayLabel = pattern.label;
|
||||
const displayClass = pattern.className;
|
||||
|
||||
if (entityMappings) {
|
||||
// Chercher le mapping correspondant à cette position et ce type
|
||||
const matchingMapping = entityMappings.find(
|
||||
(mapping) => mapping.entityType === pattern.label
|
||||
);
|
||||
|
||||
if (matchingMapping) {
|
||||
// Utiliser directement la valeur anonymisée du mapping
|
||||
// qui correspond à cette occurrence (basée sur l'ordre d'apparition)
|
||||
const entityType = pattern.label;
|
||||
const mappingsOfThisType = entityMappings.filter(
|
||||
(m) => m.entityType === entityType
|
||||
);
|
||||
|
||||
// Prendre le mapping correspondant à cette occurrence
|
||||
if (mappingsOfThisType[matchCount - 1]) {
|
||||
displayLabel = mappingsOfThisType[matchCount - 1].anonymizedValue;
|
||||
} else {
|
||||
// Fallback si pas de mapping trouvé
|
||||
displayLabel = `${entityType} [${matchCount}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const element = (
|
||||
<span
|
||||
key={`${patternIndex}-${start}`}
|
||||
className={`${displayClass} px-2 py-1 rounded-md font-medium text-xs inline-block mx-0.5 shadow-sm border`}
|
||||
title={`${displayLabel} anonymisé`}
|
||||
>
|
||||
{displayLabel}
|
||||
</span>
|
||||
);
|
||||
|
||||
replacements.push({ start, end, element });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trier les remplacements par position
|
||||
replacements.sort((a, b) => a.start - b.start);
|
||||
|
||||
// Construire le résultat final
|
||||
if (replacements.length === 0) {
|
||||
return text;
|
||||
originalText: string,
|
||||
mappings?: EntityMapping[]
|
||||
): ReactNode[] => {
|
||||
if (!originalText || !mappings || mappings.length === 0) {
|
||||
return [originalText];
|
||||
}
|
||||
|
||||
const parts: ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
replacements.forEach((replacement) => {
|
||||
// Ajouter le texte avant le remplacement
|
||||
if (replacement.start > lastIndex) {
|
||||
parts.push(text.slice(lastIndex, replacement.start));
|
||||
// Les mappings sont triés par `start`
|
||||
mappings.forEach((mapping, index) => {
|
||||
const { start, end, entity_type, text } = mapping;
|
||||
|
||||
// Ajouter le segment de texte AVANT l'entité actuelle
|
||||
if (start > lastIndex) {
|
||||
parts.push(originalText.slice(lastIndex, start));
|
||||
}
|
||||
|
||||
// Ajouter l'élément de remplacement
|
||||
parts.push(replacement.element);
|
||||
// Créer et ajouter le badge stylisé pour l'entité
|
||||
const colorOption = generateColorFromName(entity_type);
|
||||
const displayText = mapping.displayName || `[${entity_type.toUpperCase()}]`;
|
||||
|
||||
parts.push(
|
||||
<span
|
||||
key={index}
|
||||
className={`${colorOption.bgClass} ${colorOption.textClass} px-2 py-1 rounded-md font-medium text-xs inline-block mx-0.5 shadow-sm border`}
|
||||
title={`${entity_type}: ${text}`}
|
||||
>
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
|
||||
lastIndex = replacement.end;
|
||||
// Mettre à jour la position pour la prochaine itération
|
||||
lastIndex = end;
|
||||
});
|
||||
|
||||
// Ajouter le texte restant
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.slice(lastIndex));
|
||||
// Ajouter le reste du texte après la dernière entité
|
||||
if (lastIndex < originalText.length) {
|
||||
parts.push(originalText.slice(lastIndex));
|
||||
}
|
||||
|
||||
return parts;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 128 B |
@@ -11,9 +11,7 @@ const config: Config = {
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [
|
||||
typography, // C'est ici qu'on active le plugin pour la classe 'prose'
|
||||
],
|
||||
plugins: [typography],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
Reference in New Issue
Block a user