Initial commit

This commit is contained in:
2026-02-21 09:44:18 -03:00
commit 5dfc2cbd85
65 changed files with 11989 additions and 0 deletions

795
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,795 @@
/**
* Main application layout and orchestration for document and settings workspaces.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ActionModal from './components/ActionModal';
import DocumentGrid from './components/DocumentGrid';
import DocumentViewer from './components/DocumentViewer';
import PathInput from './components/PathInput';
import ProcessingLogPanel from './components/ProcessingLogPanel';
import SearchFiltersBar from './components/SearchFiltersBar';
import SettingsScreen from './components/SettingsScreen';
import UploadSurface from './components/UploadSurface';
import {
clearProcessingLogs,
deleteDocument,
exportContentsMarkdown,
getAppSettings,
listDocuments,
listPaths,
listProcessingLogs,
listTags,
listTypes,
resetAppSettings,
searchDocuments,
trashDocument,
updateAppSettings,
uploadDocuments,
} from './lib/api';
import type { AppSettings, AppSettingsUpdate, DmsDocument, ProcessingLogEntry } from './types';
type AppScreen = 'documents' | 'settings';
type DocumentView = 'active' | 'trash';
interface DialogOption {
key: string;
label: string;
tone?: 'neutral' | 'primary' | 'warning' | 'danger';
}
interface DialogState {
title: string;
message: string;
options: DialogOption[];
}
/**
* Defines the root DMS frontend component.
*/
export default function App(): JSX.Element {
const DEFAULT_PAGE_SIZE = 12;
const [screen, setScreen] = useState<AppScreen>('documents');
const [documentView, setDocumentView] = useState<DocumentView>('active');
const [documents, setDocuments] = useState<DmsDocument[]>([]);
const [totalDocuments, setTotalDocuments] = useState<number>(0);
const [currentPage, setCurrentPage] = useState<number>(1);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isUploading, setIsUploading] = useState<boolean>(false);
const [searchText, setSearchText] = useState<string>('');
const [activeSearchQuery, setActiveSearchQuery] = useState<string>('');
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<string[]>([]);
const [exportPathInput, setExportPathInput] = useState<string>('');
const [tagFilter, setTagFilter] = useState<string>('');
const [typeFilter, setTypeFilter] = useState<string>('');
const [pathFilter, setPathFilter] = useState<string>('');
const [processedFrom, setProcessedFrom] = useState<string>('');
const [processedTo, setProcessedTo] = useState<string>('');
const [knownTags, setKnownTags] = useState<string[]>([]);
const [knownPaths, setKnownPaths] = useState<string[]>([]);
const [knownTypes, setKnownTypes] = useState<string[]>([]);
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
const [settingsSaveAction, setSettingsSaveAction] = useState<(() => Promise<void>) | null>(null);
const [processingLogs, setProcessingLogs] = useState<ProcessingLogEntry[]>([]);
const [isLoadingLogs, setIsLoadingLogs] = useState<boolean>(false);
const [isClearingLogs, setIsClearingLogs] = useState<boolean>(false);
const [processingLogError, setProcessingLogError] = useState<string | null>(null);
const [isSavingSettings, setIsSavingSettings] = useState<boolean>(false);
const [isRunningBulkAction, setIsRunningBulkAction] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [dialogState, setDialogState] = useState<DialogState | null>(null);
const dialogResolverRef = useRef<((value: string) => void) | null>(null);
const pageSize = useMemo(() => {
const configured = appSettings?.display?.cards_per_page;
if (!configured || Number.isNaN(configured)) {
return DEFAULT_PAGE_SIZE;
}
return Math.max(1, Math.min(200, configured));
}, [appSettings]);
const presentDialog = useCallback((title: string, message: string, options: DialogOption[]): Promise<string> => {
setDialogState({ title, message, options });
return new Promise<string>((resolve) => {
dialogResolverRef.current = resolve;
});
}, []);
const requestConfirmation = useCallback(
async (title: string, message: string, confirmLabel = 'Confirm'): Promise<boolean> => {
const choice = await presentDialog(title, message, [
{ key: 'cancel', label: 'Cancel', tone: 'neutral' },
{ key: 'confirm', label: confirmLabel, tone: 'danger' },
]);
return choice === 'confirm';
},
[presentDialog],
);
const closeDialog = useCallback((key: string): void => {
const resolver = dialogResolverRef.current;
dialogResolverRef.current = null;
setDialogState(null);
if (resolver) {
resolver(key);
}
}, []);
const downloadBlob = useCallback((blob: Blob, filename: string): void => {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(objectUrl);
}, []);
const loadCatalogs = useCallback(async (): Promise<void> => {
const [tags, paths, types] = await Promise.all([listTags(true), listPaths(true), listTypes(true)]);
setKnownTags(tags);
setKnownPaths(paths);
setKnownTypes(types);
}, []);
const loadDocuments = useCallback(async (options?: { silent?: boolean }): Promise<void> => {
const silent = options?.silent ?? false;
if (!silent) {
setIsLoading(true);
setError(null);
}
try {
const offset = (currentPage - 1) * pageSize;
const search = activeSearchQuery.trim();
const filters = {
pathFilter,
tagFilter,
typeFilter,
processedFrom,
processedTo,
};
const payload =
search.length > 0
? await searchDocuments(search, {
limit: pageSize,
offset,
onlyTrashed: documentView === 'trash',
...filters,
})
: await listDocuments({
limit: pageSize,
offset,
onlyTrashed: documentView === 'trash',
...filters,
});
setDocuments(payload.items);
setTotalDocuments(payload.total);
if (payload.items.length === 0) {
setSelectedDocumentId(null);
} else if (!payload.items.some((item) => item.id === selectedDocumentId)) {
setSelectedDocumentId(payload.items[0].id);
}
setSelectedDocumentIds((current) => current.filter((documentId) => payload.items.some((item) => item.id === documentId)));
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to load documents');
} finally {
if (!silent) {
setIsLoading(false);
}
}
}, [
activeSearchQuery,
currentPage,
documentView,
pageSize,
pathFilter,
processedFrom,
processedTo,
selectedDocumentId,
tagFilter,
typeFilter,
]);
const loadSettings = useCallback(async (): Promise<void> => {
setError(null);
try {
const payload = await getAppSettings();
setAppSettings(payload);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to load settings');
}
}, []);
const loadProcessingTimeline = useCallback(async (options?: { silent?: boolean }): Promise<void> => {
const silent = options?.silent ?? false;
if (!silent) {
setIsLoadingLogs(true);
}
try {
const payload = await listProcessingLogs({ limit: 180 });
setProcessingLogs(payload.items);
setProcessingLogError(null);
} catch (caughtError) {
setProcessingLogError(caughtError instanceof Error ? caughtError.message : 'Failed to load processing logs');
} finally {
if (!silent) {
setIsLoadingLogs(false);
}
}
}, []);
useEffect(() => {
const bootstrap = async (): Promise<void> => {
try {
await Promise.all([loadDocuments(), loadCatalogs(), loadSettings(), loadProcessingTimeline()]);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to initialize application');
}
};
void bootstrap();
}, [loadCatalogs, loadDocuments, loadProcessingTimeline, loadSettings]);
useEffect(() => {
setSelectedDocumentIds([]);
setCurrentPage(1);
}, [documentView, pageSize]);
useEffect(() => {
if (screen !== 'documents') {
return;
}
void loadDocuments();
}, [loadDocuments, screen]);
useEffect(() => {
if (screen !== 'documents') {
return;
}
const pollInterval = window.setInterval(() => {
void loadDocuments({ silent: true });
}, 3000);
return () => window.clearInterval(pollInterval);
}, [loadDocuments, screen]);
useEffect(() => {
if (screen !== 'documents') {
return;
}
void loadProcessingTimeline();
const pollInterval = window.setInterval(() => {
void loadProcessingTimeline({ silent: true });
}, 1500);
return () => window.clearInterval(pollInterval);
}, [loadProcessingTimeline, screen]);
const selectedDocument = useMemo(
() => documents.find((document) => document.id === selectedDocumentId) ?? null,
[documents, selectedDocumentId],
);
const totalPages = useMemo(() => Math.max(1, Math.ceil(totalDocuments / pageSize)), [pageSize, totalDocuments]);
const allVisibleSelected = useMemo(() => documents.length > 0 && documents.every((document) => selectedDocumentIds.includes(document.id)), [documents, selectedDocumentIds]);
const isProcessingActive = useMemo(() => documents.some((document) => document.status === 'queued'), [documents]);
const typingAnimationEnabled = appSettings?.display?.log_typing_animation_enabled ?? true;
const hasActiveSearch = Boolean(
activeSearchQuery.trim() || tagFilter || typeFilter || pathFilter || processedFrom || processedTo,
);
const handleUpload = useCallback(async (files: File[]): Promise<void> => {
if (files.length === 0) {
return;
}
setIsUploading(true);
setError(null);
try {
const uploadDefaults = appSettings?.upload_defaults ?? { logical_path: 'Inbox', tags: [] };
const tagsCsv = uploadDefaults.tags.join(',');
const firstAttempt = await uploadDocuments(files, {
logicalPath: uploadDefaults.logical_path,
tags: tagsCsv,
conflictMode: 'ask',
});
if (firstAttempt.conflicts.length > 0) {
const choice = await presentDialog(
'Upload Conflicts Detected',
`${firstAttempt.conflicts.length} file(s) already exist. Replace existing records or keep duplicates?`,
[
{ key: 'duplicate', label: 'Keep Duplicates', tone: 'neutral' },
{ key: 'replace', label: 'Replace Existing', tone: 'warning' },
],
);
await uploadDocuments(files, {
logicalPath: uploadDefaults.logical_path,
tags: tagsCsv,
conflictMode: choice === 'replace' ? 'replace' : 'duplicate',
});
}
await Promise.all([loadDocuments(), loadCatalogs(), loadProcessingTimeline()]);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Upload failed');
} finally {
setIsUploading(false);
}
}, [appSettings, loadCatalogs, loadDocuments, loadProcessingTimeline, presentDialog]);
const handleSearch = useCallback(async (): Promise<void> => {
setSelectedDocumentIds([]);
setCurrentPage(1);
setActiveSearchQuery(searchText.trim());
}, [searchText]);
const handleResetSearch = useCallback((): void => {
setSearchText('');
setActiveSearchQuery('');
setTagFilter('');
setTypeFilter('');
setPathFilter('');
setProcessedFrom('');
setProcessedTo('');
setCurrentPage(1);
setSelectedDocumentIds([]);
}, []);
const handleDocumentUpdated = useCallback((updated: DmsDocument): void => {
setDocuments((current) => {
const shouldAppear = documentView === 'trash' ? updated.status === 'trashed' : updated.status !== 'trashed';
if (!shouldAppear) {
return current.filter((document) => document.id !== updated.id);
}
const exists = current.some((document) => document.id === updated.id);
if (!exists) {
return [updated, ...current];
}
return current.map((document) => (document.id === updated.id ? updated : document));
});
if (documentView === 'trash' && updated.status !== 'trashed') {
setSelectedDocumentIds((current) => current.filter((id) => id !== updated.id));
if (selectedDocumentId === updated.id) {
setSelectedDocumentId(null);
}
}
if (documentView === 'active' && updated.status === 'trashed') {
setSelectedDocumentIds((current) => current.filter((id) => id !== updated.id));
if (selectedDocumentId === updated.id) {
setSelectedDocumentId(null);
}
}
void loadCatalogs();
}, [documentView, loadCatalogs, selectedDocumentId]);
const handleDocumentDeleted = useCallback((documentId: string): void => {
setDocuments((current) => current.filter((document) => document.id !== documentId));
setSelectedDocumentIds((current) => current.filter((id) => id !== documentId));
if (selectedDocumentId === documentId) {
setSelectedDocumentId(null);
}
void loadCatalogs();
}, [loadCatalogs, selectedDocumentId]);
const handleToggleChecked = useCallback((documentId: string, checked: boolean): void => {
setSelectedDocumentIds((current) => {
if (checked && !current.includes(documentId)) {
return [...current, documentId];
}
if (!checked) {
return current.filter((item) => item !== documentId);
}
return current;
});
}, []);
const handleToggleSelectAllVisible = useCallback((): void => {
if (documents.length === 0) {
return;
}
if (allVisibleSelected) {
setSelectedDocumentIds([]);
return;
}
setSelectedDocumentIds(documents.map((document) => document.id));
}, [allVisibleSelected, documents]);
const handleTrashSelected = useCallback(async (): Promise<void> => {
if (selectedDocumentIds.length === 0) {
return;
}
setIsRunningBulkAction(true);
setError(null);
try {
await Promise.all(selectedDocumentIds.map((documentId) => trashDocument(documentId)));
setSelectedDocumentIds([]);
await Promise.all([loadDocuments(), loadCatalogs()]);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to trash selected documents');
} finally {
setIsRunningBulkAction(false);
}
}, [loadCatalogs, loadDocuments, selectedDocumentIds]);
const handleTrashDocumentCard = useCallback(async (documentId: string): Promise<void> => {
if (documentView === 'trash') {
return;
}
setError(null);
try {
await trashDocument(documentId);
setSelectedDocumentIds((current) => current.filter((id) => id !== documentId));
if (selectedDocumentId === documentId) {
setSelectedDocumentId(null);
}
await Promise.all([loadDocuments(), loadCatalogs()]);
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : 'Failed to trash document';
setError(message);
throw caughtError instanceof Error ? caughtError : new Error(message);
}
}, [documentView, loadCatalogs, loadDocuments, selectedDocumentId]);
const handleDeleteSelected = useCallback(async (): Promise<void> => {
if (selectedDocumentIds.length === 0) {
return;
}
const confirmed = await requestConfirmation(
'Delete Selected Documents Permanently',
'This removes selected documents and stored files permanently.',
'Delete Permanently',
);
if (!confirmed) {
return;
}
setIsRunningBulkAction(true);
setError(null);
try {
await Promise.all(selectedDocumentIds.map((documentId) => deleteDocument(documentId)));
setSelectedDocumentIds([]);
await Promise.all([loadDocuments(), loadCatalogs()]);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to delete selected documents');
} finally {
setIsRunningBulkAction(false);
}
}, [loadCatalogs, loadDocuments, requestConfirmation, selectedDocumentIds]);
const handleExportSelected = useCallback(async (): Promise<void> => {
if (selectedDocumentIds.length === 0) {
return;
}
setIsRunningBulkAction(true);
setError(null);
try {
const result = await exportContentsMarkdown({
document_ids: selectedDocumentIds,
only_trashed: documentView === 'trash',
include_trashed: false,
});
downloadBlob(result.blob, result.filename);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to export selected markdown files');
} finally {
setIsRunningBulkAction(false);
}
}, [documentView, downloadBlob, selectedDocumentIds]);
const handleExportPath = useCallback(async (): Promise<void> => {
const trimmedPrefix = exportPathInput.trim();
if (!trimmedPrefix) {
setError('Enter a path prefix before exporting by path');
return;
}
setIsRunningBulkAction(true);
setError(null);
try {
const result = await exportContentsMarkdown({
path_prefix: trimmedPrefix,
only_trashed: documentView === 'trash',
include_trashed: false,
});
downloadBlob(result.blob, result.filename);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to export path markdown files');
} finally {
setIsRunningBulkAction(false);
}
}, [documentView, downloadBlob, exportPathInput]);
const handleSaveSettings = useCallback(async (payload: AppSettingsUpdate): Promise<void> => {
setIsSavingSettings(true);
setError(null);
try {
const updated = await updateAppSettings(payload);
setAppSettings(updated);
await loadCatalogs();
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to save settings');
throw caughtError;
} finally {
setIsSavingSettings(false);
}
}, [loadCatalogs]);
const handleSaveSettingsFromHeader = useCallback(async (): Promise<void> => {
if (!settingsSaveAction) {
setError('Settings are still loading');
return;
}
await settingsSaveAction();
}, [settingsSaveAction]);
const handleRegisterSettingsSaveAction = useCallback((action: (() => Promise<void>) | null): void => {
setSettingsSaveAction(() => action);
}, []);
const handleResetSettings = useCallback(async (): Promise<void> => {
const confirmed = await requestConfirmation(
'Reset Settings',
'This resets all settings to defaults and overwrites current values.',
'Reset Settings',
);
if (!confirmed) {
return;
}
setIsSavingSettings(true);
setError(null);
try {
const updated = await resetAppSettings();
setAppSettings(updated);
await loadCatalogs();
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to reset settings');
} finally {
setIsSavingSettings(false);
}
}, [loadCatalogs, requestConfirmation]);
const handleClearProcessingLogs = useCallback(async (): Promise<void> => {
const confirmed = await requestConfirmation(
'Clear Processing Log',
'This clears the full diagnostics timeline.',
'Clear Logs',
);
if (!confirmed) {
return;
}
setIsClearingLogs(true);
try {
await clearProcessingLogs();
await loadProcessingTimeline();
setProcessingLogError(null);
} catch (caughtError) {
setProcessingLogError(caughtError instanceof Error ? caughtError.message : 'Failed to clear processing logs');
} finally {
setIsClearingLogs(false);
}
}, [loadProcessingTimeline, requestConfirmation]);
const handleFilterPathFromCard = useCallback((pathValue: string): void => {
setActiveSearchQuery('');
setSearchText('');
setTagFilter('');
setTypeFilter('');
setProcessedFrom('');
setProcessedTo('');
setPathFilter(pathValue);
setCurrentPage(1);
}, []);
const handleFilterTagFromCard = useCallback((tagValue: string): void => {
setActiveSearchQuery('');
setSearchText('');
setPathFilter('');
setTypeFilter('');
setProcessedFrom('');
setProcessedTo('');
setTagFilter(tagValue);
setCurrentPage(1);
}, []);
return (
<main className="app-shell">
<header className="topbar">
<div>
<h1>LedgerDock</h1>
<p>Document command deck for OCR, routing intelligence, and controlled metadata ops.</p>
</div>
<div className="topbar-controls">
<div className="topbar-nav-group">
<button
type="button"
className={screen === 'documents' && documentView === 'active' ? 'active-view-button' : 'secondary-action'}
onClick={() => {
setScreen('documents');
setDocumentView('active');
}}
>
Documents
</button>
<button
type="button"
className={screen === 'documents' && documentView === 'trash' ? 'active-view-button' : 'secondary-action'}
onClick={() => {
setScreen('documents');
setDocumentView('trash');
}}
>
Trash
</button>
<button
type="button"
className={screen === 'settings' ? 'active-view-button' : 'secondary-action'}
onClick={() => setScreen('settings')}
>
Settings
</button>
</div>
{screen === 'documents' && (
<div className="topbar-document-group">
<UploadSurface onUploadRequested={handleUpload} isUploading={isUploading} variant="inline" />
</div>
)}
{screen === 'settings' && (
<div className="topbar-settings-group">
<button type="button" className="secondary-action" onClick={() => void handleResetSettings()} disabled={isSavingSettings}>
Reset To Defaults
</button>
<button type="button" onClick={() => void handleSaveSettingsFromHeader()} disabled={isSavingSettings || !settingsSaveAction}>
{isSavingSettings ? 'Saving Settings...' : 'Save Settings'}
</button>
</div>
)}
</div>
</header>
{error && <p className="error-banner">{error}</p>}
{screen === 'settings' && (
<SettingsScreen
settings={appSettings}
isSaving={isSavingSettings}
knownTags={knownTags}
knownPaths={knownPaths}
onSave={handleSaveSettings}
onRegisterSaveAction={handleRegisterSettingsSaveAction}
/>
)}
{screen === 'documents' && (
<>
<section className="layout-grid">
<div>
<div className="panel-header document-panel-header">
<div className="document-panel-title-row">
<h2>{documentView === 'trash' ? 'Trashed Documents' : 'Documents'}</h2>
<p>{isLoading ? 'Loading...' : `${totalDocuments} document(s)`}</p>
</div>
<SearchFiltersBar
searchText={searchText}
onSearchTextChange={setSearchText}
onSearchSubmit={() => void handleSearch()}
onReset={handleResetSearch}
hasActiveSearch={hasActiveSearch}
knownTags={knownTags}
knownPaths={knownPaths}
knownTypes={knownTypes}
tagFilter={tagFilter}
onTagFilterChange={(value) => {
setTagFilter(value);
setCurrentPage(1);
}}
typeFilter={typeFilter}
onTypeFilterChange={(value) => {
setTypeFilter(value);
setCurrentPage(1);
}}
pathFilter={pathFilter}
onPathFilterChange={(value) => {
setPathFilter(value);
setCurrentPage(1);
}}
processedFrom={processedFrom}
onProcessedFromChange={(value) => {
setProcessedFrom(value);
setCurrentPage(1);
}}
processedTo={processedTo}
onProcessedToChange={(value) => {
setProcessedTo(value);
setCurrentPage(1);
}}
isLoading={isLoading}
/>
<div className="document-toolbar-row">
<div className="document-toolbar-pagination compact-pagination">
<button type="button" className="secondary-action" onClick={() => setCurrentPage(1)} disabled={isLoading || currentPage <= 1}>
First
</button>
<button type="button" className="secondary-action" onClick={() => setCurrentPage((current) => Math.max(1, current - 1))} disabled={isLoading || currentPage <= 1}>
Prev
</button>
<span className="small">Page {currentPage} / {totalPages}</span>
<button type="button" className="secondary-action" onClick={() => setCurrentPage((current) => Math.min(totalPages, current + 1))} disabled={isLoading || currentPage >= totalPages}>
Next
</button>
<button type="button" className="secondary-action" onClick={() => setCurrentPage(totalPages)} disabled={isLoading || currentPage >= totalPages}>
Last
</button>
</div>
</div>
<div className="document-toolbar-row">
<div className="document-toolbar-selection">
<span className="small">Select:</span>
<button type="button" className="secondary-action" onClick={handleToggleSelectAllVisible} disabled={documents.length === 0}>
{allVisibleSelected ? 'Unselect Page' : 'Select Page'}
</button>
<span className="small">Selected {selectedDocumentIds.length}</span>
{documentView !== 'trash' && (
<button type="button" className="warning-action" onClick={() => void handleTrashSelected()} disabled={isRunningBulkAction || selectedDocumentIds.length === 0}>
Move To Trash
</button>
)}
{documentView === 'trash' && (
<button type="button" className="danger-action" onClick={() => void handleDeleteSelected()} disabled={isRunningBulkAction || selectedDocumentIds.length === 0}>
Delete Permanently
</button>
)}
<button type="button" className="secondary-action" onClick={() => void handleExportSelected()} disabled={isRunningBulkAction || selectedDocumentIds.length === 0}>
Export Selected MD
</button>
</div>
<div className="document-toolbar-export-path">
<PathInput value={exportPathInput} onChange={setExportPathInput} placeholder="Export by path prefix" suggestions={knownPaths} />
<button type="button" className="secondary-action" onClick={() => void handleExportPath()} disabled={isRunningBulkAction}>
Export Path MD
</button>
</div>
</div>
</div>
<DocumentGrid
documents={documents}
selectedDocumentId={selectedDocumentId}
selectedDocumentIds={selectedDocumentIds}
isTrashView={documentView === 'trash'}
onSelect={(document) => setSelectedDocumentId(document.id)}
onToggleChecked={handleToggleChecked}
onTrashDocument={handleTrashDocumentCard}
onFilterPath={handleFilterPathFromCard}
onFilterTag={handleFilterTagFromCard}
/>
</div>
<DocumentViewer
document={selectedDocument}
isTrashView={documentView === 'trash'}
existingTags={knownTags}
existingPaths={knownPaths}
onDocumentUpdated={handleDocumentUpdated}
onDocumentDeleted={handleDocumentDeleted}
requestConfirmation={requestConfirmation}
/>
</section>
{processingLogError && <p className="error-banner">{processingLogError}</p>}
<ProcessingLogPanel
entries={processingLogs}
isLoading={isLoadingLogs}
isClearing={isClearingLogs}
selectedDocumentId={selectedDocumentId}
isProcessingActive={isProcessingActive}
typingAnimationEnabled={typingAnimationEnabled}
onClear={() => void handleClearProcessingLogs()}
/>
</>
)}
<ActionModal
isOpen={dialogState !== null}
title={dialogState?.title ?? ''}
message={dialogState?.message ?? ''}
options={dialogState?.options ?? []}
onSelect={closeDialog}
onDismiss={() => closeDialog('cancel')}
/>
</main>
);
}