Files
ledgerdock/frontend/src/App.tsx

954 lines
34 KiB
TypeScript

/**
* Main application layout and orchestration for document and settings workspaces.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { JSX } from 'react';
import { LogOut, User } from 'lucide-react';
import ActionModal from './components/ActionModal';
import DocumentGrid from './components/DocumentGrid';
import LoginScreen from './components/LoginScreen';
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,
downloadBlobFile,
deleteDocument,
exportContentsMarkdown,
getCurrentAuthSession,
getAppSettings,
listDocuments,
listPaths,
listProcessingLogs,
listTags,
listTypes,
loginWithPassword,
logoutCurrentSession,
resetAppSettings,
searchDocuments,
trashDocument,
updateAppSettings,
uploadDocuments,
} from './lib/api';
import type { AppSettings, AppSettingsUpdate, AuthUser, DmsDocument, ProcessingLogEntry } from './types';
type AppScreen = 'documents' | 'settings';
type DocumentView = 'active' | 'trash';
type AuthPhase = 'checking' | 'unauthenticated' | 'authenticated';
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 [authPhase, setAuthPhase] = useState<AuthPhase>('checking');
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
const [authError, setAuthError] = useState<string | null>(null);
const [isAuthenticating, setIsAuthenticating] = useState<boolean>(false);
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 isAdmin = authUser?.role === 'admin';
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);
}
}, []);
/**
* Clears workspace state when authentication context changes or session is revoked.
*/
const resetApplicationState = useCallback((): void => {
setScreen('documents');
setDocumentView('active');
setDocuments([]);
setTotalDocuments(0);
setCurrentPage(1);
setSearchText('');
setActiveSearchQuery('');
setSelectedDocumentId(null);
setSelectedDocumentIds([]);
setExportPathInput('');
setTagFilter('');
setTypeFilter('');
setPathFilter('');
setProcessedFrom('');
setProcessedTo('');
setKnownTags([]);
setKnownPaths([]);
setKnownTypes([]);
setAppSettings(null);
setSettingsSaveAction(null);
setProcessingLogs([]);
setProcessingLogError(null);
setError(null);
}, []);
/**
* Exchanges submitted credentials for a server-issued session and activates the app shell.
*/
const handleLogin = useCallback(async (username: string, password: string): Promise<void> => {
setIsAuthenticating(true);
setAuthError(null);
try {
const payload = await loginWithPassword(username, password);
setAuthUser(payload.user);
setAuthPhase('authenticated');
setError(null);
} catch (caughtError) {
const message = caughtError instanceof Error ? caughtError.message : 'Login failed';
setAuthError(message);
setAuthUser(null);
setAuthPhase('unauthenticated');
resetApplicationState();
} finally {
setIsAuthenticating(false);
}
}, [resetApplicationState]);
/**
* Revokes current session server-side when possible and always clears local auth state.
*/
const handleLogout = useCallback(async (): Promise<void> => {
setError(null);
try {
await logoutCurrentSession();
} catch {}
setAuthUser(null);
setAuthError(null);
setAuthPhase('unauthenticated');
resetApplicationState();
}, [resetApplicationState]);
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> => {
if (!isAdmin) {
setAppSettings(null);
return;
}
setError(null);
try {
const payload = await getAppSettings();
setAppSettings(payload);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to load settings');
}
}, [isAdmin]);
const loadProcessingTimeline = useCallback(async (options?: { silent?: boolean }): Promise<void> => {
if (!isAdmin) {
setProcessingLogs([]);
setProcessingLogError(null);
return;
}
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);
}
}
}, [isAdmin]);
useEffect(() => {
const resolveSession = async (): Promise<void> => {
try {
const sessionPayload = await getCurrentAuthSession();
setAuthUser(sessionPayload.user);
setAuthError(null);
setAuthPhase('authenticated');
} catch {
setAuthUser(null);
setAuthPhase('unauthenticated');
resetApplicationState();
}
};
void resolveSession();
}, [resetApplicationState]);
useEffect(() => {
if (authPhase !== 'authenticated') {
return;
}
const bootstrap = async (): Promise<void> => {
try {
if (isAdmin) {
await Promise.all([loadDocuments(), loadCatalogs(), loadSettings(), loadProcessingTimeline()]);
return;
}
await Promise.all([loadDocuments(), loadCatalogs()]);
setAppSettings(null);
setProcessingLogs([]);
setProcessingLogError(null);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to initialize application');
}
};
void bootstrap();
}, [authPhase, isAdmin, loadCatalogs, loadDocuments, loadProcessingTimeline, loadSettings]);
useEffect(() => {
setSelectedDocumentIds([]);
setCurrentPage(1);
}, [documentView, pageSize]);
useEffect(() => {
if (!isAdmin && screen === 'settings') {
setScreen('documents');
}
}, [isAdmin, screen]);
useEffect(() => {
if (authPhase !== 'authenticated') {
return;
}
if (screen !== 'documents') {
return;
}
void loadDocuments();
}, [authPhase, loadDocuments, screen]);
useEffect(() => {
if (authPhase !== 'authenticated') {
return;
}
if (screen !== 'documents') {
return;
}
const pollInterval = window.setInterval(() => {
void loadDocuments({ silent: true });
}, 3000);
return () => window.clearInterval(pollInterval);
}, [authPhase, loadDocuments, screen]);
useEffect(() => {
if (authPhase !== 'authenticated' || !isAdmin) {
return;
}
if (screen !== 'documents') {
return;
}
void loadProcessingTimeline();
const pollInterval = window.setInterval(() => {
void loadProcessingTimeline({ silent: true });
}, 1500);
return () => window.clearInterval(pollInterval);
}, [authPhase, isAdmin, 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',
});
}
if (isAdmin) {
await Promise.all([loadDocuments(), loadCatalogs(), loadProcessingTimeline()]);
} else {
await Promise.all([loadDocuments(), loadCatalogs()]);
}
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Upload failed');
} finally {
setIsUploading(false);
}
}, [appSettings, isAdmin, 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,
});
downloadBlobFile(result.blob, result.filename);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to export selected markdown files');
} finally {
setIsRunningBulkAction(false);
}
}, [documentView, 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,
});
downloadBlobFile(result.blob, result.filename);
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Failed to export path markdown files');
} finally {
setIsRunningBulkAction(false);
}
}, [documentView, 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);
}, []);
if (authPhase === 'checking') {
return (
<main className="auth-shell">
<section className="auth-card">
<h1>LedgerDock</h1>
<p>Checking current session...</p>
</section>
</main>
);
}
if (authPhase !== 'authenticated') {
return <LoginScreen error={authError} isSubmitting={isAuthenticating} onSubmit={handleLogin} />;
}
return (
<main className="app-shell">
<header className="topbar">
<div className="topbar-inner">
<div className="topbar-brand">
<h1>LedgerDock</h1>
<p>Document command deck for OCR, routing intelligence, and controlled metadata ops.</p>
<p className="topbar-auth-status">
<User className="topbar-user-icon" aria-hidden="true" />
You are currently signed in as <span className="topbar-current-username">{authUser?.username}</span>
</p>
</div>
<div className="topbar-controls">
<div className="topbar-primary-row">
<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>
{isAdmin && (
<button
type="button"
className={screen === 'settings' ? 'active-view-button' : 'secondary-action'}
onClick={() => setScreen('settings')}
>
Settings
</button>
)}
</div>
<button
type="button"
className="secondary-action topbar-icon-action"
onClick={() => void handleLogout()}
aria-label="Sign out"
>
<LogOut className="topbar-signout-icon" aria-hidden="true" />
</button>
</div>
{screen === 'documents' && (
<div className="topbar-document-group">
<UploadSurface onUploadRequested={handleUpload} isUploading={isUploading} variant="inline" />
</div>
)}
{screen === 'settings' && isAdmin && (
<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>
</div>
</header>
{error && <p className="error-banner">{error}</p>}
{screen === 'settings' && isAdmin && (
<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-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>
<div className="document-pagination-strip">
<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>
<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>
{isAdmin && processingLogError && <p className="error-banner">{processingLogError}</p>}
{isAdmin && (
<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>
);
}