fix: sharepoint neutralisierer wieder eingebaut

This commit is contained in:
Ida Dittrich 2026-02-26 15:53:09 +01:00
parent 1ff776c503
commit 39e74110cd
3 changed files with 449 additions and 116 deletions

View file

@ -25,6 +25,7 @@ export interface NeutralizationConfig {
export interface NeutralizationResult { export interface NeutralizationResult {
neutralized_text?: string; neutralized_text?: string;
neutralized_bytes?: string | Uint8Array;
neutralized_file_base64?: string; neutralized_file_base64?: string;
neutralized_file_name?: string; neutralized_file_name?: string;
mime_type?: string; mime_type?: string;

View file

@ -2,14 +2,15 @@
* NeutralizationView * NeutralizationView
* *
* Combined view for the Neutralization feature with two tabs: * Combined view for the Neutralization feature with two tabs:
* - Configuration: Enable/disable neutralization, configure names to parse, SharePoint paths. * - Configuration: Configure names to parse.
* - Playground: Manual text neutralization and placeholder resolution. * - Playground: Upload file, paste text, or neutralize SharePoint files. Resolve placeholders.
*/ */
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import { useFileContext } from '../../../contexts/FileContext'; import { useFileContext } from '../../../contexts/FileContext';
import { useConnections, type Connection } from '../../../hooks/useConnections';
import { import {
getNeutralizationConfig, getNeutralizationConfig,
saveNeutralizationConfig, saveNeutralizationConfig,
@ -20,8 +21,26 @@ import {
type NeutralizationConfig, type NeutralizationConfig,
} from '../../../api/neutralizationApi'; } from '../../../api/neutralizationApi';
import { Tabs } from '../../../components/UiComponents/Tabs'; import { Tabs } from '../../../components/UiComponents/Tabs';
import api from '../../../api';
import styles from './NeutralizationViews.module.css'; 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 // CONFIGURATION TAB
// ============================================================================= // =============================================================================
@ -33,13 +52,10 @@ const ConfigTab: React.FC = () => {
const [config, setConfig] = useState<NeutralizationConfig | null>(null); const [config, setConfig] = useState<NeutralizationConfig | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
const [namesToParse, setNamesToParse] = useState(''); const [namesToParse, setNamesToParse] = useState('');
const [sharepointSourcePath, setSharepointSourcePath] = useState('');
const [sharepointTargetPath, setSharepointTargetPath] = useState('');
const loadConfig = useCallback(async () => { const loadConfig = useCallback(async () => {
setLoading(true); setLoading(true);
@ -49,8 +65,6 @@ const ConfigTab: React.FC = () => {
setConfig(data); setConfig(data);
setEnabled(data.enabled); setEnabled(data.enabled);
setNamesToParse(data.namesToParse || ''); setNamesToParse(data.namesToParse || '');
setSharepointSourcePath(data.sharepointSourcePath || '');
setSharepointTargetPath(data.sharepointTargetPath || '');
} catch (err: unknown) { } catch (err: unknown) {
const errObj = err as { response?: { data?: { detail?: string | { msg?: string }[] } }; message?: string }; const errObj = err as { response?: { data?: { detail?: string | { msg?: string }[] } }; message?: string };
const detail = errObj.response?.data?.detail; const detail = errObj.response?.data?.detail;
@ -85,8 +99,8 @@ const ConfigTab: React.FC = () => {
userId: config?.userId || '', userId: config?.userId || '',
enabled, enabled,
namesToParse, namesToParse,
sharepointSourcePath, sharepointSourcePath: config?.sharepointSourcePath || '',
sharepointTargetPath, sharepointTargetPath: config?.sharepointTargetPath || '',
}); });
showSuccess('Saved', 'Configuration saved successfully.'); showSuccess('Saved', 'Configuration saved successfully.');
await loadConfig(); await loadConfig();
@ -103,34 +117,6 @@ const ConfigTab: React.FC = () => {
} }
}; };
const handleProcessSharepoint = async () => {
if (!sharepointSourcePath.trim() || !sharepointTargetPath.trim()) {
showError('Error', 'Both SharePoint source and target paths are required.');
return;
}
setProcessing(true);
setError(null);
try {
const result = await processSharepointFiles(sharepointSourcePath, sharepointTargetPath);
if (result.success) {
showSuccess('Done', result.message || 'SharePoint files processed successfully.');
} else {
setError(result.message || 'Processing failed');
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';
setError(message);
showError('Error', message);
} finally {
setProcessing(false);
}
};
const dismissError = () => setError(null); const dismissError = () => setError(null);
if (loading) { if (loading) {
@ -154,18 +140,6 @@ const ConfigTab: React.FC = () => {
</div> </div>
)} )}
<div className={styles.formRow}>
<div className={styles.checkboxRow}>
<input
type="checkbox"
id="neutralization-enabled"
checked={enabled}
onChange={(e) => setEnabled(e.target.checked)}
/>
<label htmlFor="neutralization-enabled">Enable Neutralization:</label>
</div>
</div>
<div className={styles.formRow}> <div className={styles.formRow}>
<label htmlFor="names-to-parse">Names to Parse (one per line):</label> <label htmlFor="names-to-parse">Names to Parse (one per line):</label>
<textarea <textarea
@ -178,48 +152,6 @@ const ConfigTab: React.FC = () => {
/> />
</div> </div>
<div className={styles.formRow}>
<label htmlFor="sharepoint-source">SharePoint Source Path:</label>
<div className={styles.inputWithButton}>
<input
id="sharepoint-source"
type="text"
value={sharepointSourcePath}
onChange={(e) => setSharepointSourcePath(e.target.value)}
placeholder="Enter SharePoint source path..."
/>
<button
type="button"
className={styles.secondaryButton}
onClick={() => {}}
title="Browse (not yet implemented)"
>
Browse
</button>
</div>
</div>
<div className={styles.formRow}>
<label htmlFor="sharepoint-target">SharePoint Target Path:</label>
<div className={styles.inputWithButton}>
<input
id="sharepoint-target"
type="text"
value={sharepointTargetPath}
onChange={(e) => setSharepointTargetPath(e.target.value)}
placeholder="Enter SharePoint target path..."
/>
<button
type="button"
className={styles.secondaryButton}
onClick={() => {}}
title="Browse (not yet implemented)"
>
Browse
</button>
</div>
</div>
<div className={styles.buttonRow}> <div className={styles.buttonRow}>
<button <button
type="button" type="button"
@ -229,14 +161,6 @@ const ConfigTab: React.FC = () => {
> >
{saving ? 'Saving...' : 'Save Configuration'} {saving ? 'Saving...' : 'Save Configuration'}
</button> </button>
<button
type="button"
className={styles.secondaryButton}
onClick={handleProcessSharepoint}
disabled={processing || !sharepointSourcePath.trim() || !sharepointTargetPath.trim()}
>
{processing ? 'Processing...' : 'Neutralize Sharepoint Files'}
</button>
</div> </div>
</div> </div>
</div> </div>
@ -250,11 +174,140 @@ const ConfigTab: React.FC = () => {
const PlaygroundTab: React.FC = () => { const PlaygroundTab: React.FC = () => {
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const { refetch: refetchFiles, handleFileDownload } = useFileContext(); 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 [inputText, setInputText] = useState('');
const [neutralizedText, setNeutralizedText] = useState(''); const [neutralizedText, setNeutralizedText] = useState('');
const [neutralizing, setNeutralizing] = useState(false); const [neutralizing, setNeutralizing] = useState(false);
const [resolving, setResolving] = 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<{ const [fileResult, setFileResult] = useState<{
base64?: string; base64?: string;
fileName?: string; fileName?: string;
@ -397,17 +450,42 @@ const PlaygroundTab: React.FC = () => {
setFileResult(null); 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 ( return (
<>
<div className={styles.playgroundSection}> <div className={styles.playgroundSection}>
<h3 className={styles.sectionTitle}>Manual Text &amp; File Neutralization</h3> <h3 className={styles.sectionTitle}>Manual Text &amp; File Neutralization</h3>
<p className={styles.sectionDescription}> <p className={styles.sectionDescription}>
Enter text or upload a file (PDF, DOCX, XLSX, PPTX, TXT, CSV, JSON) to neutralize sensitive data. Upload a file, paste text, or neutralize SharePoint files. Use &quot;Resolve Text&quot; to convert placeholders back to the original text.
Use &quot;Resolve Text&quot; to convert placeholders back to the original text.
</p> </p>
<div className={styles.playgroundCard}> <div className={styles.playgroundCard}>
<div className={styles.formRow}> <div className={styles.formRow}>
<label>Upload file to neutralize:</label> <label>1. Upload file to neutralize:</label>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@ -418,7 +496,7 @@ const PlaygroundTab: React.FC = () => {
<div className={styles.buttonRow}> <div className={styles.buttonRow}>
<button <button
type="button" type="button"
className={styles.secondaryButton} className={styles.primaryButton}
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={neutralizing} disabled={neutralizing}
> >
@ -442,7 +520,7 @@ const PlaygroundTab: React.FC = () => {
</div> </div>
<div className={styles.formRow}> <div className={styles.formRow}>
<label htmlFor="input-text">Or paste text:</label> <label htmlFor="input-text">2. Or paste text:</label>
<textarea <textarea
id="input-text" id="input-text"
className={styles.textareaField} className={styles.textareaField}
@ -452,7 +530,6 @@ const PlaygroundTab: React.FC = () => {
rows={6} rows={6}
/> />
</div> </div>
<div className={styles.buttonRow}> <div className={styles.buttonRow}>
<button <button
type="button" type="button"
@ -473,17 +550,81 @@ const PlaygroundTab: React.FC = () => {
</div> </div>
<div className={styles.formRow}> <div className={styles.formRow}>
<label htmlFor="neutralized-text">Neutralized Text:</label> <label>3. Or neutralize SharePoint files:</label>
<textarea <div className={styles.formRow} style={{ gap: '0.75rem', marginTop: 0 }}>
id="neutralized-text" <div>
className={styles.textareaField} <label htmlFor="sharepoint-source" style={{ fontSize: '0.8125rem', fontWeight: 400 }}>Source:</label>
value={neutralizedText} <div className={styles.inputWithButton}>
onChange={(e) => setNeutralizedText(e.target.value)} <input
placeholder="Neutralized text will appear here (or use Download for binary files)...." id="sharepoint-source"
rows={8} type="text"
readOnly={false} 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>
<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) && ( {(neutralizedText || fileResult) && (
<button <button
@ -496,6 +637,98 @@ const PlaygroundTab: React.FC = () => {
)} )}
</div> </div>
</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>
)}
</>
); );
}; };

View file

@ -129,6 +129,21 @@
color: var(--text-tertiary, #999); color: var(--text-tertiary, #999);
} }
/* Read-only output block */
.outputBlock {
width: 100%;
min-height: 120px;
padding: 0.625rem 0.875rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.9375rem;
font-family: inherit;
background: var(--surface-color, #f8f9fa);
color: var(--text-primary, #1a1a1a);
white-space: pre-wrap;
word-break: break-word;
}
/* Buttons */ /* Buttons */
.buttonRow { .buttonRow {
display: flex; display: flex;
@ -259,6 +274,89 @@
font-size: 0.8125rem; font-size: 0.8125rem;
} }
/* SharePoint path picker modal */
.modalOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modalContent {
background: var(--bg-primary, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 12px;
padding: 1.5rem;
max-width: 480px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modalTitle {
font-size: 1.125rem;
font-weight: 600;
margin: 0 0 1rem 0;
}
.folderSelect {
width: 100%;
padding: 0.625rem 0.875rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.9375rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #1a1a1a);
margin-bottom: 0.75rem;
}
.folderList {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
background: var(--bg-primary, #ffffff);
margin-bottom: 0.75rem;
}
.folderItem {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
}
.folderItem:last-child {
border-bottom: none;
}
.folderItem:hover {
background: var(--surface-color, #f8f9fa);
}
.folderName {
flex: 1;
color: var(--text-primary, #1a1a1a);
font-size: 0.875rem;
}
.selectButton {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
border: 1px solid var(--border-color, #d0d0d0);
border-radius: 4px;
background: var(--bg-primary, #ffffff);
cursor: pointer;
}
.selectButton:hover {
background: var(--surface-color, #f5f5f5);
}
/* Loading */ /* Loading */
.loading { .loading {
display: flex; display: flex;
@ -297,7 +395,8 @@
} }
:global(.dark-theme) .inputWithButton input, :global(.dark-theme) .inputWithButton input,
:global(.dark-theme) .textareaField { :global(.dark-theme) .textareaField,
:global(.dark-theme) .outputBlock {
background: var(--surface-dark, #2a2a2a); background: var(--surface-dark, #2a2a2a);
border-color: var(--border-dark, #444); border-color: var(--border-dark, #444);
color: var(--text-primary-dark, #ffffff); color: var(--text-primary-dark, #ffffff);