Initial commit
This commit is contained in:
795
frontend/src/App.tsx
Normal file
795
frontend/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user