750 lines
27 KiB
TypeScript
750 lines
27 KiB
TypeScript
/**
|
||
* NeutralizationView
|
||
*
|
||
* Combined view for the Neutralization feature with two tabs:
|
||
* - Configuration: Configure names to parse.
|
||
* - Playground: Upload file, paste text, or neutralize SharePoint files. Resolve placeholders.
|
||
*/
|
||
|
||
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||
import { useToast } from '../../../contexts/ToastContext';
|
||
import { useFileContext } from '../../../contexts/FileContext';
|
||
import { useConnections, type Connection } from '../../../hooks/useConnections';
|
||
import {
|
||
getNeutralizationConfig,
|
||
saveNeutralizationConfig,
|
||
processSharepointFiles,
|
||
neutralizeText,
|
||
neutralizeFile,
|
||
resolveText,
|
||
type NeutralizationConfig,
|
||
} from '../../../api/neutralizationApi';
|
||
import { Tabs } from '../../../components/UiComponents/Tabs';
|
||
import api from '../../../api';
|
||
import styles from './NeutralizationViews.module.css';
|
||
|
||
interface SiteOption {
|
||
value: string;
|
||
label: string;
|
||
siteId: string;
|
||
siteName: string;
|
||
webUrl: string;
|
||
path: string;
|
||
}
|
||
|
||
interface FolderOption {
|
||
value: string;
|
||
label: string;
|
||
siteId: string;
|
||
folderName: string;
|
||
path: string;
|
||
}
|
||
|
||
// =============================================================================
|
||
// CONFIGURATION TAB
|
||
// =============================================================================
|
||
|
||
const ConfigTab: React.FC = () => {
|
||
const { instance } = useCurrentInstance();
|
||
const { showSuccess, showError } = useToast();
|
||
|
||
const [config, setConfig] = useState<NeutralizationConfig | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [saving, setSaving] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const [enabled, setEnabled] = useState(true);
|
||
const [namesToParse, setNamesToParse] = useState('');
|
||
|
||
const loadConfig = useCallback(async () => {
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const data = await getNeutralizationConfig();
|
||
setConfig(data);
|
||
setEnabled(data.enabled);
|
||
setNamesToParse(data.namesToParse || '');
|
||
} catch (err: unknown) {
|
||
const errObj = err as { response?: { data?: { detail?: string | { msg?: string }[] } }; message?: string };
|
||
const detail = errObj.response?.data?.detail;
|
||
const message =
|
||
(typeof detail === 'string' ? detail : null) ||
|
||
(Array.isArray(detail) ? detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join(', ') : null) ||
|
||
errObj.message ||
|
||
'Error loading configuration';
|
||
setError(message);
|
||
showError('Error', message);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [showError]);
|
||
|
||
useEffect(() => {
|
||
loadConfig();
|
||
}, [loadConfig]);
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true);
|
||
setError(null);
|
||
try {
|
||
const mandateId = instance?.mandateId;
|
||
const featureInstanceId = instance?.id;
|
||
if (!mandateId || !featureInstanceId) {
|
||
throw new Error('Missing mandate or instance context');
|
||
}
|
||
await saveNeutralizationConfig({
|
||
mandateId,
|
||
featureInstanceId,
|
||
userId: config?.userId || '',
|
||
enabled,
|
||
namesToParse,
|
||
sharepointSourcePath: config?.sharepointSourcePath || '',
|
||
sharepointTargetPath: config?.sharepointTargetPath || '',
|
||
});
|
||
showSuccess('Saved', 'Configuration saved successfully.');
|
||
await loadConfig();
|
||
} catch (err: unknown) {
|
||
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||
const message =
|
||
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
|
||
errObj.message ||
|
||
'Failed to save configuration';
|
||
setError(message);
|
||
showError('Error', message);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const dismissError = () => setError(null);
|
||
|
||
if (loading) {
|
||
return <div className={styles.loading}>Loading configuration...</div>;
|
||
}
|
||
|
||
return (
|
||
<div className={styles.configSection}>
|
||
<h3 className={styles.sectionTitle}>Neutralization Configuration</h3>
|
||
<p className={styles.sectionDescription}>
|
||
Configure data neutralization settings for this instance.
|
||
</p>
|
||
|
||
<div className={styles.configCard}>
|
||
{error && (
|
||
<div className={styles.errorMessage}>
|
||
<span>{error}</span>
|
||
<button type="button" onClick={dismissError} aria-label="Dismiss">
|
||
×
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className={styles.formRow}>
|
||
<label htmlFor="names-to-parse">Names to Parse (one per line):</label>
|
||
<textarea
|
||
id="names-to-parse"
|
||
className={styles.textareaField}
|
||
value={namesToParse}
|
||
onChange={(e) => setNamesToParse(e.target.value)}
|
||
placeholder="Enter names to parse, one per line..."
|
||
rows={6}
|
||
/>
|
||
</div>
|
||
|
||
<div className={styles.buttonRow}>
|
||
<button
|
||
type="button"
|
||
className={styles.primaryButton}
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
>
|
||
{saving ? 'Saving...' : 'Save Configuration'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// =============================================================================
|
||
// PLAYGROUND TAB
|
||
// =============================================================================
|
||
|
||
const PlaygroundTab: React.FC = () => {
|
||
const { showSuccess, showError } = useToast();
|
||
const { refetch: refetchFiles, handleFileDownload } = useFileContext();
|
||
const { connections } = useConnections();
|
||
|
||
const msftConnections = connections.filter(
|
||
(c: Connection) => (c.authority === 'msft' || c.type === 'msft') && c.status === 'active'
|
||
);
|
||
const hasMsftConnection = msftConnections.length > 0;
|
||
const msftConnection = msftConnections[0] ?? null;
|
||
|
||
const [inputText, setInputText] = useState('');
|
||
const [neutralizedText, setNeutralizedText] = useState('');
|
||
const [neutralizing, setNeutralizing] = useState(false);
|
||
const [resolving, setResolving] = useState(false);
|
||
const [sharepointSourcePath, setSharepointSourcePath] = useState('');
|
||
const [sharepointTargetPath, setSharepointTargetPath] = useState('');
|
||
const [processingSharepoint, setProcessingSharepoint] = useState(false);
|
||
|
||
const [browseTarget, setBrowseTarget] = useState<'source' | 'target' | null>(null);
|
||
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
|
||
const [folderOptions, setFolderOptions] = useState<FolderOption[]>([]);
|
||
const [selectedSite, setSelectedSite] = useState<SiteOption | null>(null);
|
||
const [browseCurrentPath, setBrowseCurrentPath] = useState('');
|
||
const [isLoadingSites, setIsLoadingSites] = useState(false);
|
||
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
|
||
const [browseError, setBrowseError] = useState<string | null>(null);
|
||
|
||
const getConnectionReference = useCallback((conn: Connection): string => {
|
||
return conn.connectionReference || `connection:${conn.authority || conn.type}:${conn.externalUsername}`;
|
||
}, []);
|
||
|
||
const loadSiteOptions = useCallback(async () => {
|
||
if (!msftConnection) return;
|
||
setIsLoadingSites(true);
|
||
setBrowseError(null);
|
||
try {
|
||
const ref = getConnectionReference(msftConnection);
|
||
const params = new URLSearchParams({ connectionReference: ref });
|
||
const response = await api.get(`/api/sharepoint/folder-options?${params}`);
|
||
setSiteOptions(response.data || []);
|
||
} catch (err: unknown) {
|
||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||
setBrowseError(typeof detail === 'string' ? detail : 'Failed to load SharePoint sites');
|
||
setSiteOptions([]);
|
||
} finally {
|
||
setIsLoadingSites(false);
|
||
}
|
||
}, [msftConnection, getConnectionReference]);
|
||
|
||
const loadFolderOptions = useCallback(async (siteId: string, path: string = '') => {
|
||
if (!msftConnection || !siteId) return;
|
||
setIsLoadingFolders(true);
|
||
setBrowseError(null);
|
||
try {
|
||
const ref = getConnectionReference(msftConnection);
|
||
const params = new URLSearchParams({ connectionReference: ref, siteId });
|
||
if (path) params.append('path', path);
|
||
const response = await api.get(`/api/sharepoint/folder-options?${params}`);
|
||
setFolderOptions(response.data || []);
|
||
} catch (err: unknown) {
|
||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||
setBrowseError(typeof detail === 'string' ? detail : 'Failed to load folders');
|
||
setFolderOptions([]);
|
||
} finally {
|
||
setIsLoadingFolders(false);
|
||
}
|
||
}, [msftConnection, getConnectionReference]);
|
||
|
||
const handleOpenBrowse = useCallback((target: 'source' | 'target') => {
|
||
setBrowseTarget(target);
|
||
setSelectedSite(null);
|
||
setBrowseCurrentPath('');
|
||
setFolderOptions([]);
|
||
setBrowseError(null);
|
||
loadSiteOptions();
|
||
}, [loadSiteOptions]);
|
||
|
||
const handleCloseBrowse = useCallback(() => {
|
||
setBrowseTarget(null);
|
||
setSelectedSite(null);
|
||
setBrowseCurrentPath('');
|
||
}, []);
|
||
|
||
const handleSiteChange = useCallback((siteId: string) => {
|
||
const site = siteOptions.find((s) => s.siteId === siteId) || null;
|
||
setSelectedSite(site);
|
||
setBrowseCurrentPath('');
|
||
if (site) {
|
||
loadFolderOptions(site.siteId, '');
|
||
} else {
|
||
setFolderOptions([]);
|
||
}
|
||
}, [siteOptions, loadFolderOptions]);
|
||
|
||
const handleFolderNavigate = useCallback((folder: FolderOption) => {
|
||
setBrowseCurrentPath(folder.path);
|
||
loadFolderOptions(selectedSite!.siteId, folder.path);
|
||
}, [selectedSite, loadFolderOptions]);
|
||
|
||
const handleFolderSelect = useCallback((folder: FolderOption) => {
|
||
if (!selectedSite) return;
|
||
const fullPath = selectedSite.webUrl + (folder.path ? '/' + folder.path : '');
|
||
if (browseTarget === 'source') setSharepointSourcePath(fullPath);
|
||
else setSharepointTargetPath(fullPath);
|
||
handleCloseBrowse();
|
||
}, [selectedSite, browseTarget, handleCloseBrowse]);
|
||
|
||
const handleSelectCurrentFolder = useCallback(() => {
|
||
if (!selectedSite) return;
|
||
const fullPath = selectedSite.webUrl + (browseCurrentPath ? '/' + browseCurrentPath : '');
|
||
if (browseTarget === 'source') setSharepointSourcePath(fullPath);
|
||
else setSharepointTargetPath(fullPath);
|
||
handleCloseBrowse();
|
||
}, [selectedSite, browseCurrentPath, browseTarget, handleCloseBrowse]);
|
||
|
||
const handleGoUp = useCallback(() => {
|
||
if (!browseCurrentPath || !selectedSite) return;
|
||
const parts = browseCurrentPath.split('/');
|
||
parts.pop();
|
||
const parentPath = parts.join('/');
|
||
setBrowseCurrentPath(parentPath);
|
||
loadFolderOptions(selectedSite.siteId, parentPath);
|
||
}, [browseCurrentPath, selectedSite, loadFolderOptions]);
|
||
|
||
useEffect(() => {
|
||
let mounted = true;
|
||
getNeutralizationConfig()
|
||
.then((data) => {
|
||
if (mounted) {
|
||
setSharepointSourcePath(data.sharepointSourcePath || '');
|
||
setSharepointTargetPath(data.sharepointTargetPath || '');
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
return () => { mounted = false; };
|
||
}, []);
|
||
const [fileResult, setFileResult] = useState<{
|
||
base64?: string;
|
||
fileName?: string;
|
||
mimeType?: string;
|
||
fileId?: string;
|
||
} | null>(null);
|
||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||
|
||
const handleNeutralize = async () => {
|
||
if (!inputText.trim()) {
|
||
showError('Error', 'Please enter text to neutralize.');
|
||
return;
|
||
}
|
||
setNeutralizing(true);
|
||
setFileResult(null);
|
||
try {
|
||
const result = await neutralizeText(inputText);
|
||
setNeutralizedText(result.neutralized_text || '');
|
||
showSuccess('Done', 'Text neutralized successfully.');
|
||
} catch (err: unknown) {
|
||
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||
const message =
|
||
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
|
||
errObj.message ||
|
||
'Failed to neutralize text';
|
||
showError('Error', message);
|
||
} finally {
|
||
setNeutralizing(false);
|
||
}
|
||
};
|
||
|
||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
setNeutralizing(true);
|
||
setFileResult(null);
|
||
setNeutralizedText('');
|
||
try {
|
||
const result = await neutralizeFile(file);
|
||
const base64Data = result.neutralized_file_base64 || (typeof result.neutralized_bytes === 'string' ? result.neutralized_bytes : null);
|
||
const fileName = result.neutralized_file_name || `neutralized_${file.name}`;
|
||
if (base64Data) {
|
||
setFileResult({
|
||
base64: base64Data,
|
||
fileName,
|
||
mimeType: result.mime_type || 'application/octet-stream',
|
||
fileId: result.neutralized_file_id,
|
||
});
|
||
setNeutralizedText('');
|
||
showSuccess('Done', 'File neutralized. Download below or find it in your Files.');
|
||
} else if (result.neutralized_text !== undefined) {
|
||
setNeutralizedText(result.neutralized_text || '');
|
||
setFileResult(result.neutralized_file_id ? { fileName, fileId: result.neutralized_file_id } : { fileName });
|
||
showSuccess('Done', result.neutralized_file_id ? 'File neutralized. Download or find in Files.' : 'File neutralized.');
|
||
} else {
|
||
const err = (result.processed_info as { error?: string })?.error || 'No result returned';
|
||
console.warn('[Neutralization] Unexpected result:', result);
|
||
showError('Error', err);
|
||
}
|
||
if (result.original_file_id || result.neutralized_file_id) {
|
||
refetchFiles();
|
||
}
|
||
} catch (err: unknown) {
|
||
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||
const message =
|
||
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
|
||
errObj.message ||
|
||
'Failed to neutralize file';
|
||
showError('Error', message);
|
||
} finally {
|
||
setNeutralizing(false);
|
||
e.target.value = '';
|
||
}
|
||
};
|
||
|
||
const handleDownload = async () => {
|
||
if (!fileResult?.fileName) return;
|
||
if (fileResult.fileId) {
|
||
try {
|
||
await handleFileDownload(fileResult.fileId, fileResult.fileName);
|
||
} catch {
|
||
showError('Error', 'Failed to download file');
|
||
}
|
||
return;
|
||
}
|
||
if (fileResult.base64) {
|
||
try {
|
||
const bin = atob(fileResult.base64);
|
||
const bytes = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||
const blob = new Blob([bytes], { type: fileResult.mimeType || 'application/octet-stream' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = fileResult.fileName;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch (e) {
|
||
showError('Error', 'Failed to download file');
|
||
}
|
||
return;
|
||
}
|
||
if (neutralizedText) {
|
||
const blob = new Blob([neutralizedText], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = fileResult.fileName;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
return;
|
||
}
|
||
showError('Error', 'No file data available to download. The neutralized file may not have been generated.');
|
||
};
|
||
|
||
const handleResolve = async () => {
|
||
if (!neutralizedText.trim()) {
|
||
showError('Error', 'No neutralized text to resolve.');
|
||
return;
|
||
}
|
||
setResolving(true);
|
||
try {
|
||
const result = await resolveText(neutralizedText);
|
||
setNeutralizedText(result.resolved_text || '');
|
||
showSuccess('Done', 'Text resolved successfully.');
|
||
} catch (err: unknown) {
|
||
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||
const message =
|
||
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
|
||
errObj.message ||
|
||
'Failed to resolve text';
|
||
showError('Error', message);
|
||
} finally {
|
||
setResolving(false);
|
||
}
|
||
};
|
||
|
||
const handleClear = () => {
|
||
setNeutralizedText('');
|
||
setFileResult(null);
|
||
};
|
||
|
||
const handleProcessSharepoint = async () => {
|
||
if (!sharepointSourcePath.trim() || !sharepointTargetPath.trim()) {
|
||
showError('Error', 'Both SharePoint source and target paths are required.');
|
||
return;
|
||
}
|
||
setProcessingSharepoint(true);
|
||
try {
|
||
const result = await processSharepointFiles(sharepointSourcePath, sharepointTargetPath);
|
||
if (result.success) {
|
||
showSuccess('Done', result.message || 'SharePoint files processed successfully.');
|
||
} else {
|
||
showError('Error', result.message || 'Processing failed');
|
||
}
|
||
} catch (err: unknown) {
|
||
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
|
||
const message =
|
||
(typeof errObj.response?.data?.detail === 'string' ? errObj.response.data.detail : null) ||
|
||
errObj.message ||
|
||
'Failed to process SharePoint files';
|
||
showError('Error', message);
|
||
} finally {
|
||
setProcessingSharepoint(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<div className={styles.playgroundSection}>
|
||
<h3 className={styles.sectionTitle}>Manual Text & File Neutralization</h3>
|
||
<p className={styles.sectionDescription}>
|
||
Upload a file, paste text, or neutralize SharePoint files. Use "Resolve Text" to convert placeholders back to the original text.
|
||
</p>
|
||
|
||
<div className={styles.playgroundCard}>
|
||
<div className={styles.formRow}>
|
||
<label>1. Upload file to neutralize:</label>
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
accept=".pdf,.docx,.xlsx,.xlsm,.pptx,.txt,.csv,.json"
|
||
onChange={handleFileSelect}
|
||
style={{ display: 'none' }}
|
||
/>
|
||
<div className={styles.buttonRow}>
|
||
<button
|
||
type="button"
|
||
className={styles.primaryButton}
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={neutralizing}
|
||
>
|
||
{neutralizing ? 'Processing...' : 'Upload & Neutralize File'}
|
||
</button>
|
||
{fileResult && (
|
||
<button
|
||
type="button"
|
||
className={styles.primaryButton}
|
||
onClick={handleDownload}
|
||
>
|
||
Download {fileResult.fileName || 'Neutralized File'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
{fileResult?.fileId && (
|
||
<p className={styles.sectionDescription} style={{ marginTop: 4, marginBottom: 0 }}>
|
||
File saved to your Files. You can also download it from Basisdaten → Files.
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className={styles.formRow}>
|
||
<label htmlFor="input-text">2. Or paste text:</label>
|
||
<textarea
|
||
id="input-text"
|
||
className={styles.textareaField}
|
||
value={inputText}
|
||
onChange={(e) => setInputText(e.target.value)}
|
||
placeholder="Enter text to neutralize..."
|
||
rows={6}
|
||
/>
|
||
</div>
|
||
<div className={styles.buttonRow}>
|
||
<button
|
||
type="button"
|
||
className={styles.primaryButton}
|
||
onClick={handleNeutralize}
|
||
disabled={neutralizing}
|
||
>
|
||
{neutralizing ? 'Neutralizing...' : 'Neutralize Text'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={styles.secondaryButton}
|
||
onClick={handleResolve}
|
||
disabled={resolving || !neutralizedText.trim()}
|
||
>
|
||
{resolving ? 'Resolving...' : 'Resolve Text'}
|
||
</button>
|
||
</div>
|
||
|
||
<div className={styles.formRow}>
|
||
<label>3. Or neutralize SharePoint files:</label>
|
||
<div className={styles.formRow} style={{ gap: '0.75rem', marginTop: 0 }}>
|
||
<div>
|
||
<label htmlFor="sharepoint-source" style={{ fontSize: '0.8125rem', fontWeight: 400 }}>Source:</label>
|
||
<div className={styles.inputWithButton}>
|
||
<input
|
||
id="sharepoint-source"
|
||
type="text"
|
||
value={sharepointSourcePath}
|
||
onChange={(e) => setSharepointSourcePath(e.target.value)}
|
||
placeholder="SharePoint source path..."
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={styles.secondaryButton}
|
||
onClick={() => handleOpenBrowse('source')}
|
||
disabled={!hasMsftConnection}
|
||
title={hasMsftConnection ? 'Browse SharePoint' : 'Add a Microsoft connection first (Basisdaten → Connections)'}
|
||
>
|
||
Browse
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="sharepoint-target" style={{ fontSize: '0.8125rem', fontWeight: 400 }}>Target:</label>
|
||
<div className={styles.inputWithButton}>
|
||
<input
|
||
id="sharepoint-target"
|
||
type="text"
|
||
value={sharepointTargetPath}
|
||
onChange={(e) => setSharepointTargetPath(e.target.value)}
|
||
placeholder="SharePoint target path..."
|
||
/>
|
||
<button
|
||
type="button"
|
||
className={styles.secondaryButton}
|
||
onClick={() => handleOpenBrowse('target')}
|
||
disabled={!hasMsftConnection}
|
||
title={hasMsftConnection ? 'Browse SharePoint' : 'Add a Microsoft connection first (Basisdaten → Connections)'}
|
||
>
|
||
Browse
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className={styles.buttonRow}>
|
||
<button
|
||
type="button"
|
||
className={styles.secondaryButton}
|
||
onClick={handleProcessSharepoint}
|
||
disabled={processingSharepoint || !sharepointSourcePath.trim() || !sharepointTargetPath.trim()}
|
||
title={
|
||
!sharepointSourcePath.trim() || !sharepointTargetPath.trim()
|
||
? 'Enter source and target SharePoint paths to enable'
|
||
: undefined
|
||
}
|
||
>
|
||
{processingSharepoint ? 'Processing...' : 'Neutralize SharePoint Files'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{neutralizedText && !fileResult && (
|
||
<div className={styles.formRow}>
|
||
<label htmlFor="neutralized-text">Neutralized Text:</label>
|
||
<div
|
||
id="neutralized-text"
|
||
className={styles.outputBlock}
|
||
role="region"
|
||
aria-live="polite"
|
||
>
|
||
{neutralizedText}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{(neutralizedText || fileResult) && (
|
||
<button
|
||
type="button"
|
||
className={styles.clearLink}
|
||
onClick={handleClear}
|
||
>
|
||
Clear Result
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{browseTarget && (
|
||
<div className={styles.modalOverlay} onClick={handleCloseBrowse}>
|
||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||
<h3 className={styles.modalTitle}>
|
||
Browse SharePoint {browseTarget === 'source' ? 'Source' : 'Target'} Folder
|
||
</h3>
|
||
{browseError && (
|
||
<div className={styles.errorMessage} style={{ marginBottom: '0.75rem' }}>
|
||
{browseError}
|
||
</div>
|
||
)}
|
||
{isLoadingSites ? (
|
||
<div className={styles.loading}>Loading sites...</div>
|
||
) : (
|
||
<>
|
||
<label htmlFor="browse-site" style={{ fontSize: '0.875rem', fontWeight: 500 }}>Site:</label>
|
||
<select
|
||
id="browse-site"
|
||
className={styles.folderSelect}
|
||
value={selectedSite?.siteId || ''}
|
||
onChange={(e) => handleSiteChange(e.target.value)}
|
||
>
|
||
<option value="">Select a site...</option>
|
||
{siteOptions.map((site) => (
|
||
<option key={site.siteId} value={site.siteId}>
|
||
{site.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</>
|
||
)}
|
||
{selectedSite && (
|
||
<>
|
||
<p style={{ fontSize: '0.875rem', color: 'var(--text-secondary)', margin: '0 0 0.5rem 0' }}>
|
||
Current path: <strong>{selectedSite.path}{browseCurrentPath ? '/' + browseCurrentPath : ''}</strong>
|
||
</p>
|
||
{isLoadingFolders ? (
|
||
<div className={styles.loading}>Loading folders...</div>
|
||
) : (
|
||
<div>
|
||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||
{browseCurrentPath && (
|
||
<button type="button" className={styles.secondaryButton} onClick={handleGoUp}>
|
||
↑ Go Up
|
||
</button>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={styles.primaryButton}
|
||
onClick={handleSelectCurrentFolder}
|
||
>
|
||
Select This Folder
|
||
</button>
|
||
</div>
|
||
<div className={styles.folderList}>
|
||
{folderOptions.map((folder) => (
|
||
<div key={folder.value} className={styles.folderItem}>
|
||
<span
|
||
className={styles.folderName}
|
||
onClick={() => handleFolderNavigate(folder)}
|
||
>
|
||
📁 {folder.label}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
className={styles.selectButton}
|
||
onClick={(e) => { e.stopPropagation(); handleFolderSelect(folder); }}
|
||
>
|
||
Select
|
||
</button>
|
||
</div>
|
||
))}
|
||
{folderOptions.length === 0 && (
|
||
<div style={{ padding: '0.75rem', color: 'var(--text-secondary)', fontSize: '0.875rem' }}>
|
||
No subfolders in this location
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
<div style={{ marginTop: '1rem' }}>
|
||
<button type="button" className={styles.secondaryButton} onClick={handleCloseBrowse}>
|
||
Cancel
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
};
|
||
|
||
// =============================================================================
|
||
// MAIN VIEW
|
||
// =============================================================================
|
||
|
||
const TABS = [
|
||
{ id: 'config', label: 'Configuration', content: <ConfigTab /> },
|
||
{ id: 'playground', label: 'Playground', content: <PlaygroundTab /> },
|
||
];
|
||
|
||
export const NeutralizationView: React.FC = () => {
|
||
return (
|
||
<Tabs tabs={TABS} defaultTabId="playground" />
|
||
);
|
||
};
|
||
|
||
export default NeutralizationView;
|