954 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|