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>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/ActionModal.tsx
Normal file
79
frontend/src/components/ActionModal.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Reusable modal for confirmations and multi-action prompts.
|
||||
*/
|
||||
interface ActionModalOption {
|
||||
key: string;
|
||||
label: string;
|
||||
tone?: 'neutral' | 'primary' | 'warning' | 'danger';
|
||||
}
|
||||
|
||||
interface ActionModalProps {
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
options: ActionModalOption[];
|
||||
onSelect: (key: string) => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a centered modal dialog with configurable action buttons.
|
||||
*/
|
||||
export default function ActionModal({
|
||||
isOpen,
|
||||
title,
|
||||
message,
|
||||
options,
|
||||
onSelect,
|
||||
onDismiss,
|
||||
}: ActionModalProps): JSX.Element | null {
|
||||
if (!isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-backdrop"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close dialog"
|
||||
onClick={onDismiss}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
onDismiss();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className="action-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="action-modal-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h3 id="action-modal-title">{title}</h3>
|
||||
<p>{message}</p>
|
||||
<div className="action-modal-buttons">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
className={
|
||||
option.tone === 'danger'
|
||||
? 'danger-action'
|
||||
: option.tone === 'warning'
|
||||
? 'warning-action'
|
||||
: option.tone === 'neutral'
|
||||
? 'secondary-action'
|
||||
: ''
|
||||
}
|
||||
onClick={() => onSelect(option.key)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
220
frontend/src/components/DocumentCard.tsx
Normal file
220
frontend/src/components/DocumentCard.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Card view for displaying document summary, preview, and metadata.
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { Download, FileText, Trash2 } from 'lucide-react';
|
||||
|
||||
import type { DmsDocument } from '../types';
|
||||
import { contentMarkdownUrl, downloadUrl, thumbnailUrl } from '../lib/api';
|
||||
|
||||
/**
|
||||
* Defines properties accepted by the document card component.
|
||||
*/
|
||||
interface DocumentCardProps {
|
||||
document: DmsDocument;
|
||||
isSelected: boolean;
|
||||
isChecked: boolean;
|
||||
isTrashView: boolean;
|
||||
onSelect: (document: DmsDocument) => void;
|
||||
onToggleChecked: (documentId: string, checked: boolean) => void;
|
||||
onTrashDocument: (documentId: string) => Promise<void>;
|
||||
onFilterPath: (path: string) => void;
|
||||
onFilterTag: (tag: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines visual processing status variants rendered in the card header indicator.
|
||||
*/
|
||||
type StatusTone = 'success' | 'progress' | 'failed';
|
||||
|
||||
/**
|
||||
* Resolves status tone and tooltip text from backend document status values.
|
||||
*/
|
||||
function statusPresentation(status: DmsDocument['status']): { tone: StatusTone; tooltip: string } {
|
||||
if (status === 'processed') {
|
||||
return { tone: 'success', tooltip: 'Processing status: success' };
|
||||
}
|
||||
if (status === 'queued') {
|
||||
return { tone: 'progress', tooltip: 'Processing status: in progress' };
|
||||
}
|
||||
if (status === 'error') {
|
||||
return { tone: 'failed', tooltip: 'Processing status: failed' };
|
||||
}
|
||||
if (status === 'unsupported') {
|
||||
return { tone: 'failed', tooltip: 'Processing status: failed (unsupported type)' };
|
||||
}
|
||||
return { tone: 'success', tooltip: 'Processing status: success (moved to trash)' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Limits logical-path length while preserving start and end context with middle ellipsis.
|
||||
*/
|
||||
function compactLogicalPath(path: string, maxChars = 180): string {
|
||||
const normalized = path.trim();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxChars) {
|
||||
return normalized;
|
||||
}
|
||||
const keepChars = Math.max(12, maxChars - 3);
|
||||
const headChars = Math.ceil(keepChars * 0.6);
|
||||
const tailChars = keepChars - headChars;
|
||||
return `${normalized.slice(0, headChars)}...${normalized.slice(-tailChars)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one document card with optional image preview and searchable metadata.
|
||||
*/
|
||||
export default function DocumentCard({
|
||||
document,
|
||||
isSelected,
|
||||
isChecked,
|
||||
isTrashView,
|
||||
onSelect,
|
||||
onToggleChecked,
|
||||
onTrashDocument,
|
||||
onFilterPath,
|
||||
onFilterTag,
|
||||
}: DocumentCardProps): JSX.Element {
|
||||
const [isTrashing, setIsTrashing] = useState<boolean>(false);
|
||||
const createdDate = new Date(document.created_at).toLocaleString();
|
||||
const status = statusPresentation(document.status);
|
||||
const compactPath = compactLogicalPath(document.logical_path, 180);
|
||||
const trashDisabled = isTrashView || document.status === 'trashed' || isTrashing;
|
||||
const trashTitle = trashDisabled ? 'Already in trash' : 'Move to trash';
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`document-card ${isSelected ? 'selected' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onSelect(document)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.currentTarget !== event.target) {
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onSelect(document);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<header className="document-card-header">
|
||||
<div
|
||||
className={`card-status-indicator ${status.tone}`}
|
||||
title={status.tooltip}
|
||||
aria-label={status.tooltip}
|
||||
/>
|
||||
<label className="card-checkbox card-checkbox-compact" onClick={(event) => event.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(event) => onToggleChecked(document.id, event.target.checked)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
aria-label={`Select ${document.original_filename}`}
|
||||
title="Select document"
|
||||
/>
|
||||
</label>
|
||||
</header>
|
||||
<div className="document-preview">
|
||||
{document.preview_available ? (
|
||||
<img src={thumbnailUrl(document.id)} alt={document.original_filename} loading="lazy" />
|
||||
) : (
|
||||
<div className="document-preview-fallback">{document.extension || 'file'}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="document-content document-card-body">
|
||||
<h3 title={`${document.logical_path}/${document.original_filename}`}>
|
||||
<span className="document-title-path">{compactPath}/</span>
|
||||
<span className="document-title-name">{document.original_filename}</span>
|
||||
</h3>
|
||||
<p className="document-date">{createdDate}</p>
|
||||
</div>
|
||||
<footer className="document-card-footer">
|
||||
<div className="card-footer-discovery">
|
||||
<button
|
||||
type="button"
|
||||
className="card-chip path-chip"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onFilterPath(document.logical_path);
|
||||
}}
|
||||
title={`Filter by path: ${document.logical_path}`}
|
||||
>
|
||||
{document.logical_path}
|
||||
</button>
|
||||
<div className="card-chip-row">
|
||||
{document.tags.slice(0, 4).map((tag) => (
|
||||
<button
|
||||
key={`${document.id}-${tag}`}
|
||||
type="button"
|
||||
className="card-chip"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onFilterTag(tag);
|
||||
}}
|
||||
title={`Filter by tag: ${tag}`}
|
||||
>
|
||||
#{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-action-row">
|
||||
<button
|
||||
type="button"
|
||||
className="card-icon-button"
|
||||
aria-label="Download original"
|
||||
title="Download original"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
window.open(downloadUrl(document.id), '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
>
|
||||
<Download aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="card-icon-button"
|
||||
aria-label="Export recognized text as markdown"
|
||||
title="Export recognized text as markdown"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
window.open(contentMarkdownUrl(document.id), '_blank', 'noopener,noreferrer');
|
||||
}}
|
||||
>
|
||||
<FileText aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="card-icon-button danger"
|
||||
aria-label={trashTitle}
|
||||
title={trashTitle}
|
||||
disabled={trashDisabled}
|
||||
onClick={async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (trashDisabled) {
|
||||
return;
|
||||
}
|
||||
setIsTrashing(true);
|
||||
try {
|
||||
await onTrashDocument(document.id);
|
||||
} catch {
|
||||
} finally {
|
||||
setIsTrashing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
54
frontend/src/components/DocumentGrid.tsx
Normal file
54
frontend/src/components/DocumentGrid.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Grid renderer for document collections.
|
||||
*/
|
||||
import type { DmsDocument } from '../types';
|
||||
import DocumentCard from './DocumentCard';
|
||||
|
||||
/**
|
||||
* Defines props for document grid rendering.
|
||||
*/
|
||||
interface DocumentGridProps {
|
||||
documents: DmsDocument[];
|
||||
selectedDocumentId: string | null;
|
||||
selectedDocumentIds: string[];
|
||||
isTrashView: boolean;
|
||||
onSelect: (document: DmsDocument) => void;
|
||||
onToggleChecked: (documentId: string, checked: boolean) => void;
|
||||
onTrashDocument: (documentId: string) => Promise<void>;
|
||||
onFilterPath: (path: string) => void;
|
||||
onFilterTag: (tag: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders cards in a responsive grid with selection state.
|
||||
*/
|
||||
export default function DocumentGrid({
|
||||
documents,
|
||||
selectedDocumentId,
|
||||
selectedDocumentIds,
|
||||
isTrashView,
|
||||
onSelect,
|
||||
onToggleChecked,
|
||||
onTrashDocument,
|
||||
onFilterPath,
|
||||
onFilterTag,
|
||||
}: DocumentGridProps): JSX.Element {
|
||||
return (
|
||||
<section className="document-grid">
|
||||
{documents.map((document) => (
|
||||
<DocumentCard
|
||||
key={document.id}
|
||||
document={document}
|
||||
onSelect={onSelect}
|
||||
isSelected={selectedDocumentId === document.id}
|
||||
isChecked={selectedDocumentIds.includes(document.id)}
|
||||
onToggleChecked={onToggleChecked}
|
||||
isTrashView={isTrashView}
|
||||
onTrashDocument={onTrashDocument}
|
||||
onFilterPath={onFilterPath}
|
||||
onFilterTag={onFilterTag}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
585
frontend/src/components/DocumentViewer.tsx
Normal file
585
frontend/src/components/DocumentViewer.tsx
Normal file
@@ -0,0 +1,585 @@
|
||||
/**
|
||||
* Embedded document viewer panel for preview, metadata updates, and lifecycle actions.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
contentMarkdownUrl,
|
||||
deleteDocument,
|
||||
getDocumentDetails,
|
||||
previewUrl,
|
||||
reprocessDocument,
|
||||
restoreDocument,
|
||||
trashDocument,
|
||||
updateDocumentMetadata,
|
||||
} from '../lib/api';
|
||||
import type { DmsDocument, DmsDocumentDetail } from '../types';
|
||||
import PathInput from './PathInput';
|
||||
import TagInput from './TagInput';
|
||||
|
||||
/**
|
||||
* Defines props for the selected document viewer panel.
|
||||
*/
|
||||
interface DocumentViewerProps {
|
||||
document: DmsDocument | null;
|
||||
isTrashView: boolean;
|
||||
existingTags: string[];
|
||||
existingPaths: string[];
|
||||
onDocumentUpdated: (document: DmsDocument) => void;
|
||||
onDocumentDeleted: (documentId: string) => void;
|
||||
requestConfirmation: (title: string, message: string, confirmLabel?: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders selected document preview with editable metadata and lifecycle controls.
|
||||
*/
|
||||
export default function DocumentViewer({
|
||||
document,
|
||||
isTrashView,
|
||||
existingTags,
|
||||
existingPaths,
|
||||
onDocumentUpdated,
|
||||
onDocumentDeleted,
|
||||
requestConfirmation,
|
||||
}: DocumentViewerProps): JSX.Element {
|
||||
const [documentDetail, setDocumentDetail] = useState<DmsDocumentDetail | null>(null);
|
||||
const [isLoadingDetails, setIsLoadingDetails] = useState<boolean>(false);
|
||||
const [originalFilename, setOriginalFilename] = useState<string>('');
|
||||
const [logicalPath, setLogicalPath] = useState<string>('');
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [isSaving, setIsSaving] = useState<boolean>(false);
|
||||
const [isReprocessing, setIsReprocessing] = useState<boolean>(false);
|
||||
const [isTrashing, setIsTrashing] = useState<boolean>(false);
|
||||
const [isRestoring, setIsRestoring] = useState<boolean>(false);
|
||||
const [isDeleting, setIsDeleting] = useState<boolean>(false);
|
||||
const [isMetadataDirty, setIsMetadataDirty] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Syncs editable metadata fields whenever selection changes.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!document) {
|
||||
setDocumentDetail(null);
|
||||
setIsMetadataDirty(false);
|
||||
return;
|
||||
}
|
||||
setOriginalFilename(document.original_filename);
|
||||
setLogicalPath(document.logical_path);
|
||||
setTags(document.tags);
|
||||
setIsMetadataDirty(false);
|
||||
setError(null);
|
||||
}, [document?.id]);
|
||||
|
||||
/**
|
||||
* Refreshes editable metadata from list updates only while form is clean.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!document || isMetadataDirty) {
|
||||
return;
|
||||
}
|
||||
setOriginalFilename(document.original_filename);
|
||||
setLogicalPath(document.logical_path);
|
||||
setTags(document.tags);
|
||||
}, [
|
||||
document?.id,
|
||||
document?.original_filename,
|
||||
document?.logical_path,
|
||||
document?.tags,
|
||||
isMetadataDirty,
|
||||
]);
|
||||
|
||||
/**
|
||||
* Loads full selected-document details for extracted text and metadata display.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const loadDocumentDetails = async (): Promise<void> => {
|
||||
setIsLoadingDetails(true);
|
||||
try {
|
||||
const payload = await getDocumentDetails(document.id);
|
||||
if (!cancelled) {
|
||||
setDocumentDetail(payload);
|
||||
}
|
||||
} catch (caughtError) {
|
||||
if (!cancelled) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to load document details');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingDetails(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadDocumentDetails();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [document?.id]);
|
||||
|
||||
/**
|
||||
* Resolves whether selected document should render as an image element in preview.
|
||||
*/
|
||||
const isImageDocument = useMemo(() => {
|
||||
if (!document) {
|
||||
return false;
|
||||
}
|
||||
return document.mime_type.startsWith('image/');
|
||||
}, [document]);
|
||||
|
||||
/**
|
||||
* Extracts provider/transcription errors from document metadata for user visibility.
|
||||
*/
|
||||
const transcriptionError = useMemo(() => {
|
||||
const value = documentDetail?.metadata_json?.transcription_error;
|
||||
return typeof value === 'string' ? value : '';
|
||||
}, [documentDetail]);
|
||||
|
||||
/**
|
||||
* Extracts routing errors from metadata to surface classification issues.
|
||||
*/
|
||||
const routingError = useMemo(() => {
|
||||
const value = documentDetail?.metadata_json?.routing_error;
|
||||
return typeof value === 'string' ? value : '';
|
||||
}, [documentDetail]);
|
||||
|
||||
/**
|
||||
* Builds a compact routing status summary for user visibility.
|
||||
*/
|
||||
const routingSummary = useMemo(() => {
|
||||
const value = documentDetail?.metadata_json?.routing;
|
||||
if (!value || typeof value !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const routing = value as Record<string, unknown>;
|
||||
const confidence = typeof routing.confidence === 'number' ? routing.confidence : null;
|
||||
const similarity = typeof routing.neighbor_similarity === 'number' ? routing.neighbor_similarity : null;
|
||||
const confidenceThreshold =
|
||||
typeof routing.auto_apply_confidence_threshold === 'number'
|
||||
? routing.auto_apply_confidence_threshold
|
||||
: null;
|
||||
const autoApplied = typeof routing.auto_applied === 'boolean' ? routing.auto_applied : null;
|
||||
const autoAppliedPath = typeof routing.auto_applied_path === 'boolean' ? routing.auto_applied_path : null;
|
||||
const autoAppliedTags = typeof routing.auto_applied_tags === 'boolean' ? routing.auto_applied_tags : null;
|
||||
const blockedReasonsRaw = routing.auto_apply_blocked_reasons;
|
||||
const blockedReasons =
|
||||
Array.isArray(blockedReasonsRaw) && blockedReasonsRaw.length > 0
|
||||
? blockedReasonsRaw
|
||||
.map((reason) => String(reason))
|
||||
.map((reason) => {
|
||||
if (reason === 'missing_chosen_path') {
|
||||
return 'no chosen path';
|
||||
}
|
||||
if (reason === 'confidence_below_threshold') {
|
||||
return 'confidence below threshold';
|
||||
}
|
||||
if (reason === 'neighbor_similarity_below_threshold') {
|
||||
return 'neighbor similarity below threshold';
|
||||
}
|
||||
return reason;
|
||||
})
|
||||
: [];
|
||||
|
||||
const parts: string[] = [];
|
||||
if (autoApplied !== null) {
|
||||
parts.push(`Auto Applied: ${autoApplied ? 'yes' : 'no'}`);
|
||||
}
|
||||
if (autoApplied) {
|
||||
const appliedTargets: string[] = [];
|
||||
if (autoAppliedPath) {
|
||||
appliedTargets.push('path');
|
||||
}
|
||||
if (autoAppliedTags) {
|
||||
appliedTargets.push('tags');
|
||||
}
|
||||
if (appliedTargets.length > 0) {
|
||||
parts.push(`Applied: ${appliedTargets.join(' + ')}`);
|
||||
}
|
||||
}
|
||||
if (confidence !== null) {
|
||||
if (confidenceThreshold !== null) {
|
||||
parts.push(`Confidence: ${confidence.toFixed(2)} / ${confidenceThreshold.toFixed(2)}`);
|
||||
} else {
|
||||
parts.push(`Confidence: ${confidence.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
if (similarity !== null) {
|
||||
parts.push(`Neighbor Similarity (info): ${similarity.toFixed(2)}`);
|
||||
}
|
||||
if (autoApplied === false && blockedReasons.length > 0) {
|
||||
parts.push(`Blocked: ${blockedReasons.join(', ')}`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
}, [documentDetail]);
|
||||
|
||||
/**
|
||||
* Resolves whether routing already auto-applied path and tags.
|
||||
*/
|
||||
const routingAutoApplyState = useMemo(() => {
|
||||
const value = documentDetail?.metadata_json?.routing;
|
||||
if (!value || typeof value !== 'object') {
|
||||
return {
|
||||
autoAppliedPath: false,
|
||||
autoAppliedTags: false,
|
||||
};
|
||||
}
|
||||
|
||||
const routing = value as Record<string, unknown>;
|
||||
return {
|
||||
autoAppliedPath: routing.auto_applied_path === true,
|
||||
autoAppliedTags: routing.auto_applied_tags === true,
|
||||
};
|
||||
}, [documentDetail]);
|
||||
|
||||
/**
|
||||
* Resolves whether any routing suggestion still needs manual application.
|
||||
*/
|
||||
const hasPathSuggestion = Boolean(document?.suggested_path) && !routingAutoApplyState.autoAppliedPath;
|
||||
const hasTagSuggestions = (document?.suggested_tags.length ?? 0) > 0 && !routingAutoApplyState.autoAppliedTags;
|
||||
const canApplyAllSuggestions = hasPathSuggestion || hasTagSuggestions;
|
||||
|
||||
/**
|
||||
* Applies suggested path value to editable metadata field.
|
||||
*/
|
||||
const applySuggestedPath = (): void => {
|
||||
if (!hasPathSuggestion || !document?.suggested_path) {
|
||||
return;
|
||||
}
|
||||
setLogicalPath(document.suggested_path);
|
||||
setIsMetadataDirty(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies one suggested tag to editable metadata field.
|
||||
*/
|
||||
const applySuggestedTag = (tag: string): void => {
|
||||
if (!hasTagSuggestions || tags.includes(tag)) {
|
||||
return;
|
||||
}
|
||||
setTags([...tags, tag]);
|
||||
setIsMetadataDirty(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies all suggested routing values into editable metadata fields.
|
||||
*/
|
||||
const applyAllSuggestions = (): void => {
|
||||
if (hasPathSuggestion && document?.suggested_path) {
|
||||
setLogicalPath(document.suggested_path);
|
||||
}
|
||||
if (hasTagSuggestions && document?.suggested_tags.length) {
|
||||
const nextTags = [...tags];
|
||||
for (const tag of document.suggested_tags) {
|
||||
if (!nextTags.includes(tag)) {
|
||||
nextTags.push(tag);
|
||||
}
|
||||
}
|
||||
setTags(nextTags);
|
||||
}
|
||||
setIsMetadataDirty(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists metadata changes to backend.
|
||||
*/
|
||||
const handleSave = async (): Promise<void> => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await updateDocumentMetadata(document.id, {
|
||||
original_filename: originalFilename,
|
||||
logical_path: logicalPath,
|
||||
tags,
|
||||
});
|
||||
setOriginalFilename(updated.original_filename);
|
||||
setLogicalPath(updated.logical_path);
|
||||
setTags(updated.tags);
|
||||
setIsMetadataDirty(false);
|
||||
onDocumentUpdated(updated);
|
||||
const payload = await getDocumentDetails(document.id);
|
||||
setDocumentDetail(payload);
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to save metadata');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-runs extraction and routing logic for the currently selected document.
|
||||
*/
|
||||
const handleReprocess = async (): Promise<void> => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
setIsReprocessing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await reprocessDocument(document.id);
|
||||
onDocumentUpdated(updated);
|
||||
const payload = await getDocumentDetails(document.id);
|
||||
setDocumentDetail(payload);
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to reprocess document');
|
||||
} finally {
|
||||
setIsReprocessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves the selected document to trash state.
|
||||
*/
|
||||
const handleTrash = async (): Promise<void> => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
setIsTrashing(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await trashDocument(document.id);
|
||||
onDocumentUpdated(updated);
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to trash document');
|
||||
} finally {
|
||||
setIsTrashing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores the selected document from trash.
|
||||
*/
|
||||
const handleRestore = async (): Promise<void> => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
setIsRestoring(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await restoreDocument(document.id);
|
||||
onDocumentUpdated(updated);
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to restore document');
|
||||
} finally {
|
||||
setIsRestoring(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Permanently deletes the selected document and associated files.
|
||||
*/
|
||||
const handleDelete = async (): Promise<void> => {
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
const confirmed = await requestConfirmation(
|
||||
'Delete Document Permanently',
|
||||
'This removes the document record and stored file from the system.',
|
||||
'Delete Permanently',
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteDocument(document.id);
|
||||
onDocumentDeleted(document.id);
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to delete document');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!document) {
|
||||
return (
|
||||
<aside className="document-viewer empty">
|
||||
<h2>Document Details</h2>
|
||||
<p>Select a document to preview and manage metadata.</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const isTrashed = document.status === 'trashed' || isTrashView;
|
||||
const metadataDisabled = isTrashed || isSaving || isTrashing || isRestoring || isDeleting;
|
||||
|
||||
return (
|
||||
<aside className="document-viewer">
|
||||
<h2>{document.original_filename}</h2>
|
||||
<p className="small">Status: {document.status}</p>
|
||||
<div className="viewer-preview">
|
||||
{isImageDocument ? (
|
||||
<img src={previewUrl(document.id)} alt={document.original_filename} />
|
||||
) : (
|
||||
<iframe src={previewUrl(document.id)} title={document.original_filename} />
|
||||
)}
|
||||
</div>
|
||||
<label>
|
||||
File Name
|
||||
<input
|
||||
value={originalFilename}
|
||||
onChange={(event) => {
|
||||
setOriginalFilename(event.target.value);
|
||||
setIsMetadataDirty(true);
|
||||
}}
|
||||
disabled={metadataDisabled}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Destination Path
|
||||
<PathInput
|
||||
value={logicalPath}
|
||||
onChange={(value) => {
|
||||
setLogicalPath(value);
|
||||
setIsMetadataDirty(true);
|
||||
}}
|
||||
suggestions={existingPaths}
|
||||
disabled={metadataDisabled}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Tags
|
||||
<TagInput
|
||||
value={tags}
|
||||
onChange={(value) => {
|
||||
setTags(value);
|
||||
setIsMetadataDirty(true);
|
||||
}}
|
||||
suggestions={existingTags}
|
||||
disabled={metadataDisabled}
|
||||
/>
|
||||
</label>
|
||||
{(document.suggested_path || document.suggested_tags.length > 0 || routingSummary || routingError) && (
|
||||
<section className="routing-suggestions-panel">
|
||||
<div className="routing-suggestions-header">
|
||||
<h3>Routing Suggestions</h3>
|
||||
{canApplyAllSuggestions && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={applyAllSuggestions}
|
||||
disabled={metadataDisabled}
|
||||
>
|
||||
Apply All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{routingError && <p className="small error">{routingError}</p>}
|
||||
{routingSummary && <p className="small">{routingSummary}</p>}
|
||||
{hasPathSuggestion && document.suggested_path && (
|
||||
<div className="routing-suggestion-group">
|
||||
<p className="small">Suggested Path</p>
|
||||
<button
|
||||
type="button"
|
||||
className="routing-pill"
|
||||
onClick={applySuggestedPath}
|
||||
disabled={metadataDisabled}
|
||||
>
|
||||
{document.suggested_path}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hasTagSuggestions && document.suggested_tags.length > 0 && (
|
||||
<div className="routing-suggestion-group">
|
||||
<p className="small">Suggested Tags</p>
|
||||
<div className="routing-pill-row">
|
||||
{document.suggested_tags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className="routing-pill"
|
||||
onClick={() => applySuggestedTag(tag)}
|
||||
disabled={metadataDisabled}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<section className="extracted-text-panel">
|
||||
<h3>Extracted Text</h3>
|
||||
{transcriptionError && <p className="small error">{transcriptionError}</p>}
|
||||
{isLoadingDetails ? (
|
||||
<p className="small">Loading extracted text...</p>
|
||||
) : documentDetail?.extracted_text.trim() ? (
|
||||
<pre>{documentDetail.extracted_text}</pre>
|
||||
) : (
|
||||
<p className="small">No extracted text available for this document yet.</p>
|
||||
)}
|
||||
</section>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="viewer-actions">
|
||||
{!isTrashed && (
|
||||
<button type="button" onClick={handleSave} disabled={metadataDisabled}>
|
||||
{isSaving ? 'Saving...' : 'Save Metadata'}
|
||||
</button>
|
||||
)}
|
||||
{!isTrashed && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={handleReprocess}
|
||||
disabled={metadataDisabled || isReprocessing}
|
||||
title="Re-runs OCR/extraction, summary generation, routing suggestion, and indexing for this document."
|
||||
>
|
||||
{isReprocessing ? 'Reprocessing...' : 'Reprocess Document'}
|
||||
</button>
|
||||
)}
|
||||
{!isTrashed && (
|
||||
<button
|
||||
type="button"
|
||||
className="warning-action"
|
||||
onClick={handleTrash}
|
||||
disabled={metadataDisabled || isTrashing}
|
||||
>
|
||||
{isTrashing ? 'Trashing...' : 'Move To Trash'}
|
||||
</button>
|
||||
)}
|
||||
{isTrashed && (
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={handleRestore}
|
||||
disabled={isRestoring || isDeleting}
|
||||
>
|
||||
{isRestoring ? 'Restoring...' : 'Restore Document'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={() => window.open(contentMarkdownUrl(document.id), '_blank', 'noopener,noreferrer')}
|
||||
disabled={isDeleting}
|
||||
title="Downloads recognized/extracted content as markdown for this document."
|
||||
>
|
||||
Download Recognized MD
|
||||
</button>
|
||||
{isTrashed && (
|
||||
<button
|
||||
type="button"
|
||||
className="danger-action"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting || isRestoring}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Permanently'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="viewer-inline-help">
|
||||
Reprocess runs OCR/extraction, updates summary, refreshes routing suggestions, and re-indexes search.
|
||||
</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/PathInput.tsx
Normal file
73
frontend/src/components/PathInput.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Path editor with suggestion dropdown for scalable logical-path selection.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
/**
|
||||
* Defines properties for the reusable path input component.
|
||||
*/
|
||||
interface PathInputProps {
|
||||
value: string;
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (nextValue: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a text input with filtered clickable path suggestions.
|
||||
*/
|
||||
export default function PathInput({
|
||||
value,
|
||||
suggestions,
|
||||
placeholder = 'Destination path',
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: PathInputProps): JSX.Element {
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
/**
|
||||
* Calculates filtered suggestions based on current input value.
|
||||
*/
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return suggestions.slice(0, 20);
|
||||
}
|
||||
return suggestions.filter((candidate) => candidate.toLowerCase().includes(normalized)).slice(0, 20);
|
||||
}, [suggestions, value]);
|
||||
|
||||
return (
|
||||
<div className={`path-input ${disabled ? 'disabled' : ''}`}>
|
||||
<input
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => setIsFocused(false), 120);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{isFocused && filteredSuggestions.length > 0 && (
|
||||
<div className="path-suggestions" role="listbox" aria-label="Path suggestions">
|
||||
{filteredSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="path-suggestion-item"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onChange(suggestion);
|
||||
setIsFocused(false);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
203
frontend/src/components/ProcessingLogPanel.tsx
Normal file
203
frontend/src/components/ProcessingLogPanel.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Processing log timeline panel for upload, OCR, summarization, routing, and indexing events.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { ProcessingLogEntry } from '../types';
|
||||
|
||||
interface ProcessingLogPanelProps {
|
||||
entries: ProcessingLogEntry[];
|
||||
isLoading: boolean;
|
||||
isClearing: boolean;
|
||||
selectedDocumentId: string | null;
|
||||
isProcessingActive: boolean;
|
||||
typingAnimationEnabled: boolean;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders processing events in a terminal-style stream with optional typed headers.
|
||||
*/
|
||||
export default function ProcessingLogPanel({
|
||||
entries,
|
||||
isLoading,
|
||||
isClearing,
|
||||
selectedDocumentId,
|
||||
isProcessingActive,
|
||||
typingAnimationEnabled,
|
||||
onClear,
|
||||
}: ProcessingLogPanelProps): JSX.Element {
|
||||
const timeline = useMemo(() => [...entries].reverse(), [entries]);
|
||||
const [typedEntryIds, setTypedEntryIds] = useState<Set<number>>(() => new Set());
|
||||
const [typingEntryId, setTypingEntryId] = useState<number | null>(null);
|
||||
const [typingHeader, setTypingHeader] = useState<string>('');
|
||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(() => new Set());
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const formatTimestamp = (value: string): string => {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
};
|
||||
|
||||
const payloadText = (payload: Record<string, unknown>): string => {
|
||||
try {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
} catch (error) {
|
||||
return String(error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderHeader = (entry: ProcessingLogEntry): string => {
|
||||
const headerParts = [formatTimestamp(entry.created_at), entry.level.toUpperCase(), entry.stage];
|
||||
if (entry.document_filename) {
|
||||
headerParts.push(entry.document_filename);
|
||||
}
|
||||
if (selectedDocumentId !== null && selectedDocumentId === entry.document_id) {
|
||||
headerParts.push('selected-document');
|
||||
}
|
||||
return `[${headerParts.join(' | ')}] ${entry.event}`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const knownIds = new Set(typedEntryIds);
|
||||
if (typingEntryId !== null) {
|
||||
knownIds.add(typingEntryId);
|
||||
}
|
||||
const nextUntyped = timeline.find((entry) => !knownIds.has(entry.id));
|
||||
if (!nextUntyped) {
|
||||
return;
|
||||
}
|
||||
if (!typingAnimationEnabled) {
|
||||
setTypedEntryIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(nextUntyped.id);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (typingEntryId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fullHeader = renderHeader(nextUntyped);
|
||||
setTypingEntryId(nextUntyped.id);
|
||||
setTypingHeader('');
|
||||
let cursor = 0;
|
||||
timerRef.current = window.setInterval(() => {
|
||||
cursor += 1;
|
||||
setTypingHeader(fullHeader.slice(0, cursor));
|
||||
if (cursor >= fullHeader.length) {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setTypedEntryIds((current) => {
|
||||
const next = new Set(current);
|
||||
next.add(nextUntyped.id);
|
||||
return next;
|
||||
});
|
||||
setTypingEntryId(null);
|
||||
}
|
||||
}, 10);
|
||||
}, [timeline, typedEntryIds, typingAnimationEnabled, typingEntryId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current !== null) {
|
||||
window.clearInterval(timerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="processing-log-panel">
|
||||
<div className="panel-header">
|
||||
<h2>Processing Log</h2>
|
||||
<div className="processing-log-header-actions">
|
||||
<p>{isLoading ? 'Refreshing...' : `${entries.length} recent event(s)`}</p>
|
||||
<button type="button" className="secondary-action" onClick={onClear} disabled={isLoading || isClearing}>
|
||||
{isClearing ? 'Clearing...' : 'Clear All Logs'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="processing-log-terminal-wrap">
|
||||
<div className="processing-log-terminal">
|
||||
{timeline.length === 0 && <p className="terminal-empty">No processing events yet.</p>}
|
||||
{timeline.map((entry, index) => {
|
||||
const groupKey = `${entry.document_id ?? 'unbound'}:${entry.stage}`;
|
||||
const previousGroupKey = index > 0 ? `${timeline[index - 1].document_id ?? 'unbound'}:${timeline[index - 1].stage}` : null;
|
||||
const showSeparator = index > 0 && groupKey !== previousGroupKey;
|
||||
const isTyping = entry.id === typingEntryId;
|
||||
const isTyped = typedEntryIds.has(entry.id) || (!typingAnimationEnabled && !isTyping);
|
||||
const isExpanded = expandedIds.has(entry.id);
|
||||
const providerModel = [entry.provider_id, entry.model_name].filter(Boolean).join(' / ');
|
||||
const hasDetails =
|
||||
providerModel.length > 0 ||
|
||||
Object.keys(entry.payload_json).length > 0 ||
|
||||
Boolean(entry.prompt_text) ||
|
||||
Boolean(entry.response_text);
|
||||
return (
|
||||
<div key={entry.id}>
|
||||
{showSeparator && <div className="terminal-separator">------</div>}
|
||||
<div className="terminal-row-header">
|
||||
<span>{isTyping ? typingHeader : renderHeader(entry)}</span>
|
||||
{hasDetails && isTyped && (
|
||||
<button
|
||||
type="button"
|
||||
className="terminal-unfold-button"
|
||||
onClick={() =>
|
||||
setExpandedIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(entry.id)) {
|
||||
next.delete(entry.id);
|
||||
} else {
|
||||
next.add(entry.id);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
{isExpanded ? 'Fold' : 'Unfold'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && isTyped && (
|
||||
<div className="terminal-row-details">
|
||||
{providerModel && <div>provider/model: {providerModel}</div>}
|
||||
{Object.keys(entry.payload_json).length > 0 && (
|
||||
<>
|
||||
<div>payload:</div>
|
||||
<pre>{payloadText(entry.payload_json)}</pre>
|
||||
</>
|
||||
)}
|
||||
{entry.prompt_text && (
|
||||
<>
|
||||
<div>prompt:</div>
|
||||
<pre>{entry.prompt_text}</pre>
|
||||
</>
|
||||
)}
|
||||
{entry.response_text && (
|
||||
<>
|
||||
<div>response:</div>
|
||||
<pre>{entry.response_text}</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isProcessingActive && typingEntryId === null && (
|
||||
<div className="terminal-idle-prompt">
|
||||
<span className="terminal-caret">></span>
|
||||
<span className="terminal-caret-blink">_</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
107
frontend/src/components/SearchFiltersBar.tsx
Normal file
107
frontend/src/components/SearchFiltersBar.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Compact search and filter controls for document discovery.
|
||||
*/
|
||||
interface SearchFiltersBarProps {
|
||||
searchText: string;
|
||||
onSearchTextChange: (value: string) => void;
|
||||
onSearchSubmit: () => void;
|
||||
onReset: () => void;
|
||||
hasActiveSearch: boolean;
|
||||
knownTags: string[];
|
||||
knownPaths: string[];
|
||||
knownTypes: string[];
|
||||
tagFilter: string;
|
||||
onTagFilterChange: (value: string) => void;
|
||||
typeFilter: string;
|
||||
onTypeFilterChange: (value: string) => void;
|
||||
pathFilter: string;
|
||||
onPathFilterChange: (value: string) => void;
|
||||
processedFrom: string;
|
||||
onProcessedFromChange: (value: string) => void;
|
||||
processedTo: string;
|
||||
onProcessedToChange: (value: string) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders dense search, filter, and quick reset controls.
|
||||
*/
|
||||
export default function SearchFiltersBar({
|
||||
searchText,
|
||||
onSearchTextChange,
|
||||
onSearchSubmit,
|
||||
onReset,
|
||||
hasActiveSearch,
|
||||
knownTags,
|
||||
knownPaths,
|
||||
knownTypes,
|
||||
tagFilter,
|
||||
onTagFilterChange,
|
||||
typeFilter,
|
||||
onTypeFilterChange,
|
||||
pathFilter,
|
||||
onPathFilterChange,
|
||||
processedFrom,
|
||||
onProcessedFromChange,
|
||||
processedTo,
|
||||
onProcessedToChange,
|
||||
isLoading,
|
||||
}: SearchFiltersBarProps): JSX.Element {
|
||||
return (
|
||||
<div className="search-filters-bar">
|
||||
<input
|
||||
value={searchText}
|
||||
onChange={(event) => onSearchTextChange(event.target.value)}
|
||||
placeholder="Search across name, text, path, tags"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onSearchSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<select value={tagFilter} onChange={(event) => onTagFilterChange(event.target.value)}>
|
||||
<option value="">All Tags</option>
|
||||
{knownTags.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={typeFilter} onChange={(event) => onTypeFilterChange(event.target.value)}>
|
||||
<option value="">All Types</option>
|
||||
{knownTypes.map((typeValue) => (
|
||||
<option key={typeValue} value={typeValue}>
|
||||
{typeValue}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select value={pathFilter} onChange={(event) => onPathFilterChange(event.target.value)}>
|
||||
<option value="">All Paths</option>
|
||||
{knownPaths.map((path) => (
|
||||
<option key={path} value={path}>
|
||||
{path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={processedFrom}
|
||||
onChange={(event) => onProcessedFromChange(event.target.value)}
|
||||
title="Processed from"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={processedTo}
|
||||
onChange={(event) => onProcessedToChange(event.target.value)}
|
||||
title="Processed to"
|
||||
/>
|
||||
<button type="button" onClick={onSearchSubmit} disabled={isLoading}>
|
||||
Search
|
||||
</button>
|
||||
<button type="button" className="secondary-action" onClick={onReset} disabled={!hasActiveSearch || isLoading}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
721
frontend/src/components/SettingsScreen.tsx
Normal file
721
frontend/src/components/SettingsScreen.tsx
Normal file
@@ -0,0 +1,721 @@
|
||||
/**
|
||||
* Dedicated settings screen for providers, task model bindings, and catalog controls.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import PathInput from './PathInput';
|
||||
import TagInput from './TagInput';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
DisplaySettings,
|
||||
HandwritingStyleClusteringSettings,
|
||||
OcrTaskSettings,
|
||||
PredefinedPathEntry,
|
||||
PredefinedTagEntry,
|
||||
ProviderSettings,
|
||||
RoutingTaskSettings,
|
||||
SummaryTaskSettings,
|
||||
UploadDefaultsSettings,
|
||||
} from '../types';
|
||||
|
||||
interface EditableProvider extends ProviderSettings {
|
||||
row_id: string;
|
||||
api_key: string;
|
||||
clear_api_key: boolean;
|
||||
}
|
||||
|
||||
interface SettingsScreenProps {
|
||||
settings: AppSettings | null;
|
||||
isSaving: boolean;
|
||||
knownTags: string[];
|
||||
knownPaths: string[];
|
||||
onSave: (payload: AppSettingsUpdate) => Promise<void>;
|
||||
onRegisterSaveAction?: (action: (() => Promise<void>) | null) => void;
|
||||
}
|
||||
|
||||
function clampCardsPerPage(value: number): number {
|
||||
return Math.max(1, Math.min(200, value));
|
||||
}
|
||||
|
||||
function parseCardsPerPageInput(input: string, fallback: number): number {
|
||||
const parsed = Number.parseInt(input, 10);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return clampCardsPerPage(fallback);
|
||||
}
|
||||
return clampCardsPerPage(parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders compact human-oriented settings controls.
|
||||
*/
|
||||
export default function SettingsScreen({
|
||||
settings,
|
||||
isSaving,
|
||||
knownTags,
|
||||
knownPaths,
|
||||
onSave,
|
||||
onRegisterSaveAction,
|
||||
}: SettingsScreenProps): JSX.Element {
|
||||
const [providers, setProviders] = useState<EditableProvider[]>([]);
|
||||
const [ocrTask, setOcrTask] = useState<OcrTaskSettings | null>(null);
|
||||
const [summaryTask, setSummaryTask] = useState<SummaryTaskSettings | null>(null);
|
||||
const [routingTask, setRoutingTask] = useState<RoutingTaskSettings | null>(null);
|
||||
const [handwritingStyle, setHandwritingStyle] = useState<HandwritingStyleClusteringSettings | null>(null);
|
||||
const [predefinedPaths, setPredefinedPaths] = useState<PredefinedPathEntry[]>([]);
|
||||
const [predefinedTags, setPredefinedTags] = useState<PredefinedTagEntry[]>([]);
|
||||
const [newPredefinedPath, setNewPredefinedPath] = useState<string>('');
|
||||
const [newPredefinedTag, setNewPredefinedTag] = useState<string>('');
|
||||
const [uploadDefaults, setUploadDefaults] = useState<UploadDefaultsSettings | null>(null);
|
||||
const [displaySettings, setDisplaySettings] = useState<DisplaySettings | null>(null);
|
||||
const [cardsPerPageInput, setCardsPerPageInput] = useState<string>('12');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings) {
|
||||
return;
|
||||
}
|
||||
setProviders(
|
||||
settings.providers.map((provider) => ({
|
||||
...provider,
|
||||
row_id: `${provider.id}-${Math.random().toString(36).slice(2, 9)}`,
|
||||
api_key: '',
|
||||
clear_api_key: false,
|
||||
})),
|
||||
);
|
||||
setOcrTask(settings.tasks.ocr_handwriting);
|
||||
setSummaryTask(settings.tasks.summary_generation);
|
||||
setRoutingTask(settings.tasks.routing_classification);
|
||||
setHandwritingStyle(settings.handwriting_style_clustering);
|
||||
setPredefinedPaths(settings.predefined_paths);
|
||||
setPredefinedTags(settings.predefined_tags);
|
||||
setUploadDefaults(settings.upload_defaults);
|
||||
setDisplaySettings(settings.display);
|
||||
setCardsPerPageInput(String(settings.display.cards_per_page));
|
||||
setError(null);
|
||||
}, [settings]);
|
||||
|
||||
const fallbackProviderId = useMemo(() => providers[0]?.id ?? '', [providers]);
|
||||
|
||||
const addProvider = (): void => {
|
||||
const sequence = providers.length + 1;
|
||||
setProviders((current) => [
|
||||
...current,
|
||||
{
|
||||
row_id: `provider-row-${Date.now()}-${sequence}`,
|
||||
id: `provider-${sequence}`,
|
||||
label: `Provider ${sequence}`,
|
||||
provider_type: 'openai_compatible',
|
||||
base_url: 'http://localhost:11434/v1',
|
||||
timeout_seconds: 45,
|
||||
api_key_set: false,
|
||||
api_key_masked: '',
|
||||
api_key: '',
|
||||
clear_api_key: false,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeProvider = (rowId: string): void => {
|
||||
const target = providers.find((provider) => provider.row_id === rowId);
|
||||
if (!target || providers.length <= 1) {
|
||||
return;
|
||||
}
|
||||
const remaining = providers.filter((provider) => provider.row_id !== rowId);
|
||||
const fallback = remaining[0]?.id ?? '';
|
||||
setProviders(remaining);
|
||||
if (ocrTask?.provider_id === target.id && fallback) {
|
||||
setOcrTask({ ...ocrTask, provider_id: fallback });
|
||||
}
|
||||
if (summaryTask?.provider_id === target.id && fallback) {
|
||||
setSummaryTask({ ...summaryTask, provider_id: fallback });
|
||||
}
|
||||
if (routingTask?.provider_id === target.id && fallback) {
|
||||
setRoutingTask({ ...routingTask, provider_id: fallback });
|
||||
}
|
||||
};
|
||||
|
||||
const addPredefinedPath = (): void => {
|
||||
const value = newPredefinedPath.trim().replace(/^\/+|\/+$/g, '');
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (predefinedPaths.some((entry) => entry.value.toLowerCase() === value.toLowerCase())) {
|
||||
setNewPredefinedPath('');
|
||||
return;
|
||||
}
|
||||
setPredefinedPaths([...predefinedPaths, { value, global_shared: false }]);
|
||||
setNewPredefinedPath('');
|
||||
};
|
||||
|
||||
const addPredefinedTag = (): void => {
|
||||
const value = newPredefinedTag.trim();
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (predefinedTags.some((entry) => entry.value.toLowerCase() === value.toLowerCase())) {
|
||||
setNewPredefinedTag('');
|
||||
return;
|
||||
}
|
||||
setPredefinedTags([...predefinedTags, { value, global_shared: false }]);
|
||||
setNewPredefinedTag('');
|
||||
};
|
||||
|
||||
const handleSave = useCallback(async (): Promise<void> => {
|
||||
if (!ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
|
||||
setError('Settings are not fully loaded yet');
|
||||
return;
|
||||
}
|
||||
if (providers.length === 0) {
|
||||
setError('At least one provider is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
try {
|
||||
const resolvedCardsPerPage = parseCardsPerPageInput(cardsPerPageInput, displaySettings.cards_per_page);
|
||||
setDisplaySettings({ ...displaySettings, cards_per_page: resolvedCardsPerPage });
|
||||
setCardsPerPageInput(String(resolvedCardsPerPage));
|
||||
|
||||
await onSave({
|
||||
upload_defaults: {
|
||||
logical_path: uploadDefaults.logical_path.trim(),
|
||||
tags: uploadDefaults.tags,
|
||||
},
|
||||
display: {
|
||||
cards_per_page: resolvedCardsPerPage,
|
||||
log_typing_animation_enabled: displaySettings.log_typing_animation_enabled,
|
||||
},
|
||||
predefined_paths: predefinedPaths,
|
||||
predefined_tags: predefinedTags,
|
||||
handwriting_style_clustering: {
|
||||
enabled: handwritingStyle.enabled,
|
||||
embed_model: handwritingStyle.embed_model.trim(),
|
||||
neighbor_limit: handwritingStyle.neighbor_limit,
|
||||
match_min_similarity: handwritingStyle.match_min_similarity,
|
||||
bootstrap_match_min_similarity: handwritingStyle.bootstrap_match_min_similarity,
|
||||
bootstrap_sample_size: handwritingStyle.bootstrap_sample_size,
|
||||
image_max_side: handwritingStyle.image_max_side,
|
||||
},
|
||||
providers: providers.map((provider) => ({
|
||||
id: provider.id.trim(),
|
||||
label: provider.label.trim(),
|
||||
provider_type: provider.provider_type,
|
||||
base_url: provider.base_url.trim(),
|
||||
timeout_seconds: provider.timeout_seconds,
|
||||
api_key: provider.api_key.trim() || undefined,
|
||||
clear_api_key: provider.clear_api_key,
|
||||
})),
|
||||
tasks: {
|
||||
ocr_handwriting: {
|
||||
enabled: ocrTask.enabled,
|
||||
provider_id: ocrTask.provider_id,
|
||||
model: ocrTask.model.trim(),
|
||||
prompt: ocrTask.prompt,
|
||||
},
|
||||
summary_generation: {
|
||||
enabled: summaryTask.enabled,
|
||||
provider_id: summaryTask.provider_id,
|
||||
model: summaryTask.model.trim(),
|
||||
prompt: summaryTask.prompt,
|
||||
max_input_tokens: summaryTask.max_input_tokens,
|
||||
},
|
||||
routing_classification: {
|
||||
enabled: routingTask.enabled,
|
||||
provider_id: routingTask.provider_id,
|
||||
model: routingTask.model.trim(),
|
||||
prompt: routingTask.prompt,
|
||||
neighbor_count: routingTask.neighbor_count,
|
||||
neighbor_min_similarity: routingTask.neighbor_min_similarity,
|
||||
auto_apply_confidence_threshold: routingTask.auto_apply_confidence_threshold,
|
||||
auto_apply_neighbor_similarity_threshold: routingTask.auto_apply_neighbor_similarity_threshold,
|
||||
neighbor_path_override_enabled: routingTask.neighbor_path_override_enabled,
|
||||
neighbor_path_override_min_similarity: routingTask.neighbor_path_override_min_similarity,
|
||||
neighbor_path_override_min_gap: routingTask.neighbor_path_override_min_gap,
|
||||
neighbor_path_override_max_confidence: routingTask.neighbor_path_override_max_confidence,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to save settings');
|
||||
}
|
||||
}, [
|
||||
cardsPerPageInput,
|
||||
displaySettings,
|
||||
handwritingStyle,
|
||||
ocrTask,
|
||||
onSave,
|
||||
predefinedPaths,
|
||||
predefinedTags,
|
||||
providers,
|
||||
routingTask,
|
||||
summaryTask,
|
||||
uploadDefaults,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onRegisterSaveAction) {
|
||||
return;
|
||||
}
|
||||
if (!settings || !ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
|
||||
onRegisterSaveAction(null);
|
||||
return;
|
||||
}
|
||||
onRegisterSaveAction(() => handleSave());
|
||||
return () => onRegisterSaveAction(null);
|
||||
}, [displaySettings, handleSave, handwritingStyle, ocrTask, onRegisterSaveAction, routingTask, settings, summaryTask, uploadDefaults]);
|
||||
|
||||
if (!settings || !ocrTask || !summaryTask || !routingTask || !handwritingStyle || !uploadDefaults || !displaySettings) {
|
||||
return (
|
||||
<section className="settings-layout">
|
||||
<div className="settings-card">
|
||||
<h2>Settings</h2>
|
||||
<p>Loading settings...</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="settings-layout">
|
||||
{error && <p className="error-banner">{error}</p>}
|
||||
|
||||
<div className="settings-card settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3>Workspace</h3>
|
||||
<p className="small">Defaults and display behavior for document operations.</p>
|
||||
</div>
|
||||
<div className="settings-field-grid">
|
||||
<label className="settings-field settings-field-wide">
|
||||
Default Path
|
||||
<PathInput
|
||||
value={uploadDefaults.logical_path}
|
||||
onChange={(nextPath) => setUploadDefaults({ ...uploadDefaults, logical_path: nextPath })}
|
||||
suggestions={knownPaths}
|
||||
/>
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
Default Tags
|
||||
<TagInput
|
||||
value={uploadDefaults.tags}
|
||||
onChange={(nextTags) => setUploadDefaults({ ...uploadDefaults, tags: nextTags })}
|
||||
suggestions={knownTags}
|
||||
/>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Cards Per Page
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
value={cardsPerPageInput}
|
||||
onChange={(event) => setCardsPerPageInput(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-checkbox settings-checkbox-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={displaySettings.log_typing_animation_enabled}
|
||||
onChange={(event) =>
|
||||
setDisplaySettings({ ...displaySettings, log_typing_animation_enabled: event.target.checked })
|
||||
}
|
||||
/>
|
||||
Processing log typing animation enabled
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-card settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3>Catalog Presets</h3>
|
||||
<p className="small">Pre-register allowed paths and tags. Global-shared is irreversible.</p>
|
||||
</div>
|
||||
<div className="settings-catalog-grid">
|
||||
<section className="settings-catalog-card">
|
||||
<h4>Predefined Paths</h4>
|
||||
<div className="settings-catalog-add-row">
|
||||
<input
|
||||
placeholder="Add path"
|
||||
value={newPredefinedPath}
|
||||
onChange={(event) => setNewPredefinedPath(event.target.value)}
|
||||
/>
|
||||
<button type="button" className="secondary-action" onClick={addPredefinedPath}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-catalog-list">
|
||||
{predefinedPaths.map((entry) => (
|
||||
<div key={entry.value} className="settings-catalog-row">
|
||||
<span>{entry.value}</span>
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entry.global_shared}
|
||||
disabled={entry.global_shared}
|
||||
onChange={(event) =>
|
||||
setPredefinedPaths((current) =>
|
||||
current.map((item) =>
|
||||
item.value === entry.value
|
||||
? { ...item, global_shared: item.global_shared || event.target.checked }
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
Global
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={() => setPredefinedPaths((current) => current.filter((item) => item.value !== entry.value))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-catalog-card">
|
||||
<h4>Predefined Tags</h4>
|
||||
<div className="settings-catalog-add-row">
|
||||
<input
|
||||
placeholder="Add tag"
|
||||
value={newPredefinedTag}
|
||||
onChange={(event) => setNewPredefinedTag(event.target.value)}
|
||||
/>
|
||||
<button type="button" className="secondary-action" onClick={addPredefinedTag}>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-catalog-list">
|
||||
{predefinedTags.map((entry) => (
|
||||
<div key={entry.value} className="settings-catalog-row">
|
||||
<span>{entry.value}</span>
|
||||
<label className="inline-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={entry.global_shared}
|
||||
disabled={entry.global_shared}
|
||||
onChange={(event) =>
|
||||
setPredefinedTags((current) =>
|
||||
current.map((item) =>
|
||||
item.value === entry.value
|
||||
? { ...item, global_shared: item.global_shared || event.target.checked }
|
||||
: item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
Global
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={() => setPredefinedTags((current) => current.filter((item) => item.value !== entry.value))}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-card settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3>Providers</h3>
|
||||
<p className="small">Configure OpenAI-compatible model endpoints.</p>
|
||||
</div>
|
||||
<div className="provider-list">
|
||||
{providers.map((provider, index) => (
|
||||
<div key={provider.row_id} className="provider-grid">
|
||||
<div className="provider-header">
|
||||
<h4>{provider.label || `Provider ${index + 1}`}</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-action"
|
||||
onClick={() => removeProvider(provider.row_id)}
|
||||
disabled={providers.length <= 1 || isSaving}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<div className="settings-field-grid">
|
||||
<label className="settings-field">
|
||||
Provider ID
|
||||
<input
|
||||
value={provider.id}
|
||||
onChange={(event) =>
|
||||
setProviders((current) =>
|
||||
current.map((item) => (item.row_id === provider.row_id ? { ...item, id: event.target.value } : item)),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Label
|
||||
<input
|
||||
value={provider.label}
|
||||
onChange={(event) =>
|
||||
setProviders((current) =>
|
||||
current.map((item) =>
|
||||
item.row_id === provider.row_id ? { ...item, label: event.target.value } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Timeout Seconds
|
||||
<input
|
||||
type="number"
|
||||
value={provider.timeout_seconds}
|
||||
onChange={(event) => {
|
||||
const nextTimeout = Number.parseInt(event.target.value, 10);
|
||||
if (Number.isNaN(nextTimeout)) {
|
||||
return;
|
||||
}
|
||||
setProviders((current) =>
|
||||
current.map((item) =>
|
||||
item.row_id === provider.row_id ? { ...item, timeout_seconds: nextTimeout } : item,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
Base URL
|
||||
<input
|
||||
value={provider.base_url}
|
||||
onChange={(event) =>
|
||||
setProviders((current) =>
|
||||
current.map((item) =>
|
||||
item.row_id === provider.row_id ? { ...item, base_url: event.target.value } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
API Key
|
||||
<input
|
||||
type="password"
|
||||
placeholder={provider.api_key_set ? `Stored: ${provider.api_key_masked}` : 'Optional API key'}
|
||||
value={provider.api_key}
|
||||
onChange={(event) =>
|
||||
setProviders((current) =>
|
||||
current.map((item) =>
|
||||
item.row_id === provider.row_id ? { ...item, api_key: event.target.value } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="inline-checkbox settings-checkbox-field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={provider.clear_api_key}
|
||||
onChange={(event) =>
|
||||
setProviders((current) =>
|
||||
current.map((item) =>
|
||||
item.row_id === provider.row_id ? { ...item, clear_api_key: event.target.checked } : item,
|
||||
),
|
||||
)
|
||||
}
|
||||
/>
|
||||
Clear Stored API Key
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="settings-section-actions">
|
||||
<button type="button" className="secondary-action" onClick={addProvider} disabled={isSaving}>
|
||||
Add Provider
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="settings-card settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h3>Task Runtime</h3>
|
||||
<p className="small">Bind providers and tune OCR, summary, routing, and handwriting style behavior.</p>
|
||||
</div>
|
||||
|
||||
<div className="task-settings-block">
|
||||
<div className="task-block-header">
|
||||
<h4>OCR Handwriting</h4>
|
||||
<label className="inline-checkbox settings-toggle">
|
||||
<input type="checkbox" checked={ocrTask.enabled} onChange={(event) => setOcrTask({ ...ocrTask, enabled: event.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-field-grid">
|
||||
<label className="settings-field">
|
||||
Provider
|
||||
<select value={ocrTask.provider_id} onChange={(event) => setOcrTask({ ...ocrTask, provider_id: event.target.value || fallbackProviderId })}>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.row_id} value={provider.id}>
|
||||
{provider.label} ({provider.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Model
|
||||
<input value={ocrTask.model} onChange={(event) => setOcrTask({ ...ocrTask, model: event.target.value })} />
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
OCR Prompt
|
||||
<textarea value={ocrTask.prompt} onChange={(event) => setOcrTask({ ...ocrTask, prompt: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="task-settings-block">
|
||||
<div className="task-block-header">
|
||||
<h4>Summary Generation</h4>
|
||||
<label className="inline-checkbox settings-toggle">
|
||||
<input type="checkbox" checked={summaryTask.enabled} onChange={(event) => setSummaryTask({ ...summaryTask, enabled: event.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-field-grid">
|
||||
<label className="settings-field">
|
||||
Provider
|
||||
<select value={summaryTask.provider_id} onChange={(event) => setSummaryTask({ ...summaryTask, provider_id: event.target.value || fallbackProviderId })}>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.row_id} value={provider.id}>
|
||||
{provider.label} ({provider.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Model
|
||||
<input value={summaryTask.model} onChange={(event) => setSummaryTask({ ...summaryTask, model: event.target.value })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Max Input Tokens
|
||||
<input
|
||||
type="number"
|
||||
min={512}
|
||||
max={64000}
|
||||
value={summaryTask.max_input_tokens}
|
||||
onChange={(event) => {
|
||||
const nextValue = Number.parseInt(event.target.value, 10);
|
||||
if (!Number.isNaN(nextValue)) {
|
||||
setSummaryTask({ ...summaryTask, max_input_tokens: nextValue });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
Summary Prompt
|
||||
<textarea value={summaryTask.prompt} onChange={(event) => setSummaryTask({ ...summaryTask, prompt: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="task-settings-block">
|
||||
<div className="task-block-header">
|
||||
<h4>Routing Classification</h4>
|
||||
<label className="inline-checkbox settings-toggle">
|
||||
<input type="checkbox" checked={routingTask.enabled} onChange={(event) => setRoutingTask({ ...routingTask, enabled: event.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-field-grid">
|
||||
<label className="settings-field">
|
||||
Provider
|
||||
<select value={routingTask.provider_id} onChange={(event) => setRoutingTask({ ...routingTask, provider_id: event.target.value || fallbackProviderId })}>
|
||||
{providers.map((provider) => (
|
||||
<option key={provider.row_id} value={provider.id}>
|
||||
{provider.label} ({provider.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Model
|
||||
<input value={routingTask.model} onChange={(event) => setRoutingTask({ ...routingTask, model: event.target.value })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Neighbor Count
|
||||
<input type="number" value={routingTask.neighbor_count} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_count: Number.parseInt(event.target.value, 10) || routingTask.neighbor_count })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Min Neighbor Similarity
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_min_similarity} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_min_similarity: Number.parseFloat(event.target.value) || routingTask.neighbor_min_similarity })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Auto Apply Confidence
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.auto_apply_confidence_threshold} onChange={(event) => setRoutingTask({ ...routingTask, auto_apply_confidence_threshold: Number.parseFloat(event.target.value) || routingTask.auto_apply_confidence_threshold })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Auto Apply Neighbor Similarity
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.auto_apply_neighbor_similarity_threshold} onChange={(event) => setRoutingTask({ ...routingTask, auto_apply_neighbor_similarity_threshold: Number.parseFloat(event.target.value) || routingTask.auto_apply_neighbor_similarity_threshold })} />
|
||||
</label>
|
||||
<label className="inline-checkbox settings-checkbox-field">
|
||||
<input type="checkbox" checked={routingTask.neighbor_path_override_enabled} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_enabled: event.target.checked })} />
|
||||
Dominant neighbor path override enabled
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Override Min Similarity
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_min_similarity} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_min_similarity: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_min_similarity })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Override Min Gap
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_min_gap} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_min_gap: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_min_gap })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Override Max LLM Confidence
|
||||
<input type="number" step="0.01" min="0" max="1" value={routingTask.neighbor_path_override_max_confidence} onChange={(event) => setRoutingTask({ ...routingTask, neighbor_path_override_max_confidence: Number.parseFloat(event.target.value) || routingTask.neighbor_path_override_max_confidence })} />
|
||||
</label>
|
||||
<label className="settings-field settings-field-wide">
|
||||
Routing Prompt
|
||||
<textarea value={routingTask.prompt} onChange={(event) => setRoutingTask({ ...routingTask, prompt: event.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="task-settings-block">
|
||||
<div className="task-block-header">
|
||||
<h4>Handwriting Style Clustering</h4>
|
||||
<label className="inline-checkbox settings-toggle">
|
||||
<input type="checkbox" checked={handwritingStyle.enabled} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, enabled: event.target.checked })} />
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div className="settings-field-grid">
|
||||
<label className="settings-field settings-field-wide">
|
||||
Typesense Embedding Model Slug
|
||||
<input value={handwritingStyle.embed_model} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, embed_model: event.target.value })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Neighbor Limit
|
||||
<input type="number" min={1} max={32} value={handwritingStyle.neighbor_limit} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, neighbor_limit: Number.parseInt(event.target.value, 10) || handwritingStyle.neighbor_limit })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Match Min Similarity
|
||||
<input type="number" step="0.01" min="0" max="1" value={handwritingStyle.match_min_similarity} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, match_min_similarity: Number.parseFloat(event.target.value) || handwritingStyle.match_min_similarity })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Bootstrap Match Min Similarity
|
||||
<input type="number" step="0.01" min="0" max="1" value={handwritingStyle.bootstrap_match_min_similarity} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, bootstrap_match_min_similarity: Number.parseFloat(event.target.value) || handwritingStyle.bootstrap_match_min_similarity })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Bootstrap Sample Size
|
||||
<input type="number" min={1} max={30} value={handwritingStyle.bootstrap_sample_size} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, bootstrap_sample_size: Number.parseInt(event.target.value, 10) || handwritingStyle.bootstrap_sample_size })} />
|
||||
</label>
|
||||
<label className="settings-field">
|
||||
Max Image Side (px)
|
||||
<input type="number" min={256} max={4096} value={handwritingStyle.image_max_side} onChange={(event) => setHandwritingStyle({ ...handwritingStyle, image_max_side: Number.parseInt(event.target.value, 10) || handwritingStyle.image_max_side })} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
123
frontend/src/components/TagInput.tsx
Normal file
123
frontend/src/components/TagInput.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Tag editor with suggestion dropdown and keyboard-friendly chip interactions.
|
||||
*/
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Defines properties for the reusable tag input component.
|
||||
*/
|
||||
interface TagInputProps {
|
||||
value: string[];
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (tags: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a chip-based tag editor with inline suggestions.
|
||||
*/
|
||||
export default function TagInput({
|
||||
value,
|
||||
suggestions,
|
||||
placeholder = 'Add tag',
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: TagInputProps): JSX.Element {
|
||||
const [draft, setDraft] = useState<string>('');
|
||||
|
||||
/**
|
||||
* Calculates filtered suggestions based on current draft and selected tags.
|
||||
*/
|
||||
const filteredSuggestions = useMemo(() => {
|
||||
const normalized = draft.trim().toLowerCase();
|
||||
return suggestions
|
||||
.filter((candidate) => !value.includes(candidate))
|
||||
.filter((candidate) => (normalized ? candidate.toLowerCase().includes(normalized) : false))
|
||||
.slice(0, 8);
|
||||
}, [draft, suggestions, value]);
|
||||
|
||||
/**
|
||||
* Adds a tag to the selected value list when valid.
|
||||
*/
|
||||
const addTag = (tag: string): void => {
|
||||
const normalized = tag.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
if (value.includes(normalized)) {
|
||||
setDraft('');
|
||||
return;
|
||||
}
|
||||
onChange([...value, normalized]);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes one tag by value.
|
||||
*/
|
||||
const removeTag = (tag: string): void => {
|
||||
onChange(value.filter((candidate) => candidate !== tag));
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles keyboard interactions for quick tag editing.
|
||||
*/
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter' || event.key === ',') {
|
||||
event.preventDefault();
|
||||
addTag(draft);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Backspace' && draft.length === 0 && value.length > 0) {
|
||||
event.preventDefault();
|
||||
onChange(value.slice(0, -1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`tag-input ${disabled ? 'disabled' : ''}`}>
|
||||
<div className="tag-chip-row">
|
||||
{value.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
type="button"
|
||||
className="tag-chip"
|
||||
onClick={() => removeTag(tag)}
|
||||
disabled={disabled}
|
||||
title="Remove tag"
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={() => addTag(draft)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{filteredSuggestions.length > 0 && (
|
||||
<div className="tag-suggestions" role="listbox" aria-label="Tag suggestions">
|
||||
{filteredSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="tag-suggestion-item"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
addTag(suggestion);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
frontend/src/components/UploadSurface.tsx
Normal file
127
frontend/src/components/UploadSurface.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Upload surface that supports global drag-and-drop and file/folder picking.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ChangeEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Defines callback signature for queued file uploads.
|
||||
*/
|
||||
interface UploadSurfaceProps {
|
||||
onUploadRequested: (files: File[]) => Promise<void>;
|
||||
isUploading: boolean;
|
||||
variant?: 'panel' | 'inline';
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders upload actions and drag overlay for dropping documents anywhere.
|
||||
*/
|
||||
export default function UploadSurface({
|
||||
onUploadRequested,
|
||||
isUploading,
|
||||
variant = 'panel',
|
||||
}: UploadSurfaceProps): JSX.Element {
|
||||
const [isDragging, setIsDragging] = useState<boolean>(false);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
/**
|
||||
* Installs folder-selection attributes unsupported by default React typings.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (folderInputRef.current) {
|
||||
folderInputRef.current.setAttribute('webkitdirectory', '');
|
||||
folderInputRef.current.setAttribute('directory', '');
|
||||
folderInputRef.current.setAttribute('multiple', '');
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Registers global drag listeners so users can drop files anywhere in the app.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const onDragOver = (event: DragEvent): void => {
|
||||
event.preventDefault();
|
||||
setIsDragging(true);
|
||||
};
|
||||
const onDragLeave = (event: DragEvent): void => {
|
||||
event.preventDefault();
|
||||
if (!event.relatedTarget) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
};
|
||||
const onDrop = async (event: DragEvent): Promise<void> => {
|
||||
event.preventDefault();
|
||||
setIsDragging(false);
|
||||
const droppedFiles = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (droppedFiles.length > 0) {
|
||||
await onUploadRequested(droppedFiles);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('dragover', onDragOver);
|
||||
window.addEventListener('dragleave', onDragLeave);
|
||||
window.addEventListener('drop', onDrop);
|
||||
return () => {
|
||||
window.removeEventListener('dragover', onDragOver);
|
||||
window.removeEventListener('dragleave', onDragLeave);
|
||||
window.removeEventListener('drop', onDrop);
|
||||
};
|
||||
}, [onUploadRequested]);
|
||||
|
||||
/**
|
||||
* Provides helper text based on current upload activity.
|
||||
*/
|
||||
const statusLabel = useMemo(() => {
|
||||
if (isUploading) {
|
||||
return 'Uploading and scheduling processing...';
|
||||
}
|
||||
return 'Drop files anywhere or use file/folder upload.';
|
||||
}, [isUploading]);
|
||||
|
||||
/**
|
||||
* Handles manual file and folder input selection events.
|
||||
*/
|
||||
const handlePickedFiles = async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const pickedFiles = Array.from(event.target.files ?? []);
|
||||
if (pickedFiles.length > 0) {
|
||||
await onUploadRequested(pickedFiles);
|
||||
}
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
if (variant === 'inline') {
|
||||
return (
|
||||
<>
|
||||
{isDragging && <div className="drop-overlay">Drop to upload</div>}
|
||||
<div className="upload-actions upload-actions-inline">
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={isUploading}>
|
||||
Upload Files
|
||||
</button>
|
||||
<button type="button" onClick={() => folderInputRef.current?.click()} disabled={isUploading}>
|
||||
Upload Folder
|
||||
</button>
|
||||
</div>
|
||||
<input ref={fileInputRef} type="file" multiple hidden onChange={handlePickedFiles} />
|
||||
<input ref={folderInputRef} type="file" hidden onChange={handlePickedFiles} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="upload-surface">
|
||||
{isDragging && <div className="drop-overlay">Drop to upload</div>}
|
||||
<div className="upload-actions">
|
||||
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={isUploading}>
|
||||
Upload Files
|
||||
</button>
|
||||
<button type="button" onClick={() => folderInputRef.current?.click()} disabled={isUploading}>
|
||||
Upload Folder
|
||||
</button>
|
||||
</div>
|
||||
<p>{statusLabel}</p>
|
||||
<input ref={fileInputRef} type="file" multiple hidden onChange={handlePickedFiles} />
|
||||
<input ref={folderInputRef} type="file" hidden onChange={handlePickedFiles} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
119
frontend/src/design-foundation.css
Normal file
119
frontend/src/design-foundation.css
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Foundational compact tokens and primitives for the LedgerDock frontend.
|
||||
*/
|
||||
@import url('https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
|
||||
|
||||
:root {
|
||||
--font-display: 'Archivo', sans-serif;
|
||||
--font-body: 'IBM Plex Sans', sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', monospace;
|
||||
|
||||
--color-bg-0: #0b111b;
|
||||
--color-bg-1: #101827;
|
||||
--color-panel: #141e2f;
|
||||
--color-panel-strong: #1b273a;
|
||||
--color-panel-elevated: #1f2d44;
|
||||
--color-border: #2f3f5a;
|
||||
--color-border-strong: #46597a;
|
||||
--color-text: #e4ebf7;
|
||||
--color-text-muted: #9aa8c1;
|
||||
--color-accent: #3f8dff;
|
||||
--color-accent-strong: #2e70cf;
|
||||
--color-success: #3bb07f;
|
||||
--color-warning: #d89a42;
|
||||
--color-danger: #d56a6a;
|
||||
--color-focus: #79adff;
|
||||
|
||||
--radius-xs: 4px;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 10px;
|
||||
|
||||
--shadow-soft: 0 10px 24px rgba(0, 0, 0, 0.24);
|
||||
--shadow-strong: 0 16px 34px rgba(0, 0, 0, 0.34);
|
||||
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
|
||||
--transition-fast: 140ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.45;
|
||||
background:
|
||||
radial-gradient(circle at 15% -5%, rgba(63, 141, 255, 0.24), transparent 38%),
|
||||
radial-gradient(circle at 90% -15%, rgba(130, 166, 229, 0.15), transparent 35%),
|
||||
linear-gradient(180deg, var(--color-bg-0) 0%, var(--color-bg-1) 100%);
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.35;
|
||||
background-image:
|
||||
linear-gradient(rgba(139, 162, 196, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(139, 162, 196, 0.08) 1px, transparent 1px);
|
||||
background-size: 34px 34px;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[type='checkbox'] {
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
@keyframes rise-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-border {
|
||||
from {
|
||||
box-shadow: 0 0 0 0 rgba(121, 173, 255, 0.36);
|
||||
}
|
||||
to {
|
||||
box-shadow: 0 0 0 8px rgba(121, 173, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes terminal-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
411
frontend/src/lib/api.ts
Normal file
411
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* API client for backend DMS endpoints.
|
||||
*/
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
DocumentListResponse,
|
||||
DmsDocument,
|
||||
DmsDocumentDetail,
|
||||
ProcessingLogListResponse,
|
||||
SearchResponse,
|
||||
TypeListResponse,
|
||||
UploadResponse,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* Resolves backend base URL from environment with localhost fallback.
|
||||
*/
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:8000/api/v1';
|
||||
|
||||
/**
|
||||
* Encodes query parameters while skipping undefined and null values.
|
||||
*/
|
||||
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): string {
|
||||
const searchParams = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
searchParams.set(key, String(value));
|
||||
});
|
||||
const encoded = searchParams.toString();
|
||||
return encoded ? `?${encoded}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a filename from content-disposition headers with fallback support.
|
||||
*/
|
||||
function responseFilename(response: Response, fallback: string): string {
|
||||
const disposition = response.headers.get('content-disposition') ?? '';
|
||||
const match = disposition.match(/filename="?([^";]+)"?/i);
|
||||
if (!match || !match[1]) {
|
||||
return fallback;
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads documents from the backend list endpoint.
|
||||
*/
|
||||
export async function listDocuments(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
includeTrashed?: boolean;
|
||||
onlyTrashed?: boolean;
|
||||
pathPrefix?: string;
|
||||
pathFilter?: string;
|
||||
tagFilter?: string;
|
||||
typeFilter?: string;
|
||||
processedFrom?: string;
|
||||
processedTo?: string;
|
||||
}): Promise<DocumentListResponse> {
|
||||
const query = buildQuery({
|
||||
limit: options?.limit ?? 100,
|
||||
offset: options?.offset ?? 0,
|
||||
include_trashed: options?.includeTrashed,
|
||||
only_trashed: options?.onlyTrashed,
|
||||
path_prefix: options?.pathPrefix,
|
||||
path_filter: options?.pathFilter,
|
||||
tag_filter: options?.tagFilter,
|
||||
type_filter: options?.typeFilter,
|
||||
processed_from: options?.processedFrom,
|
||||
processed_to: options?.processedTo,
|
||||
});
|
||||
const response = await fetch(`${API_BASE}/documents${query}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load documents');
|
||||
}
|
||||
return response.json() as Promise<DocumentListResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes free-text search against backend search endpoint.
|
||||
*/
|
||||
export async function searchDocuments(
|
||||
queryText: string,
|
||||
options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
includeTrashed?: boolean;
|
||||
onlyTrashed?: boolean;
|
||||
pathFilter?: string;
|
||||
tagFilter?: string;
|
||||
typeFilter?: string;
|
||||
processedFrom?: string;
|
||||
processedTo?: string;
|
||||
},
|
||||
): Promise<SearchResponse> {
|
||||
const query = buildQuery({
|
||||
query: queryText,
|
||||
limit: options?.limit ?? 100,
|
||||
offset: options?.offset ?? 0,
|
||||
include_trashed: options?.includeTrashed,
|
||||
only_trashed: options?.onlyTrashed,
|
||||
path_filter: options?.pathFilter,
|
||||
tag_filter: options?.tagFilter,
|
||||
type_filter: options?.typeFilter,
|
||||
processed_from: options?.processedFrom,
|
||||
processed_to: options?.processedTo,
|
||||
});
|
||||
const response = await fetch(`${API_BASE}/search${query}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Search failed');
|
||||
}
|
||||
return response.json() as Promise<SearchResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads processing logs for recent upload, OCR, summarization, routing, and indexing steps.
|
||||
*/
|
||||
export async function listProcessingLogs(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
documentId?: string;
|
||||
}): Promise<ProcessingLogListResponse> {
|
||||
const query = buildQuery({
|
||||
limit: options?.limit ?? 120,
|
||||
offset: options?.offset ?? 0,
|
||||
document_id: options?.documentId,
|
||||
});
|
||||
const response = await fetch(`${API_BASE}/processing/logs${query}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load processing logs');
|
||||
}
|
||||
return response.json() as Promise<ProcessingLogListResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims persisted processing logs while keeping recent document sessions.
|
||||
*/
|
||||
export async function trimProcessingLogs(options?: {
|
||||
keepDocumentSessions?: number;
|
||||
keepUnboundEntries?: number;
|
||||
}): Promise<{ deleted_document_entries: number; deleted_unbound_entries: number }> {
|
||||
const query = buildQuery({
|
||||
keep_document_sessions: options?.keepDocumentSessions ?? 2,
|
||||
keep_unbound_entries: options?.keepUnboundEntries ?? 80,
|
||||
});
|
||||
const response = await fetch(`${API_BASE}/processing/logs/trim${query}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to trim processing logs');
|
||||
}
|
||||
return response.json() as Promise<{ deleted_document_entries: number; deleted_unbound_entries: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all persisted processing logs.
|
||||
*/
|
||||
export async function clearProcessingLogs(): Promise<{ deleted_entries: number }> {
|
||||
const response = await fetch(`${API_BASE}/processing/logs/clear`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to clear processing logs');
|
||||
}
|
||||
return response.json() as Promise<{ deleted_entries: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns existing tags for suggestion UIs.
|
||||
*/
|
||||
export async function listTags(includeTrashed = false): Promise<string[]> {
|
||||
const query = buildQuery({ include_trashed: includeTrashed });
|
||||
const response = await fetch(`${API_BASE}/documents/tags${query}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load tags');
|
||||
}
|
||||
const payload = (await response.json()) as { tags: string[] };
|
||||
return payload.tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns existing logical paths for suggestion UIs.
|
||||
*/
|
||||
export async function listPaths(includeTrashed = false): Promise<string[]> {
|
||||
const query = buildQuery({ include_trashed: includeTrashed });
|
||||
const response = await fetch(`${API_BASE}/documents/paths${query}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load paths');
|
||||
}
|
||||
const payload = (await response.json()) as { paths: string[] };
|
||||
return payload.paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns distinct type values from extension, MIME, and image text categories.
|
||||
*/
|
||||
export async function listTypes(includeTrashed = false): Promise<string[]> {
|
||||
const query = buildQuery({ include_trashed: includeTrashed });
|
||||
const response = await fetch(`${API_BASE}/documents/types${query}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load document types');
|
||||
}
|
||||
const payload = (await response.json()) as TypeListResponse;
|
||||
return payload.types;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads files with optional logical path and tags.
|
||||
*/
|
||||
export async function uploadDocuments(
|
||||
files: File[],
|
||||
options: {
|
||||
logicalPath: string;
|
||||
tags: string;
|
||||
conflictMode: 'ask' | 'replace' | 'duplicate';
|
||||
},
|
||||
): Promise<UploadResponse> {
|
||||
const formData = new FormData();
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file, file.name);
|
||||
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath ?? file.name;
|
||||
formData.append('relative_paths', relativePath);
|
||||
});
|
||||
formData.append('logical_path', options.logicalPath);
|
||||
formData.append('tags', options.tags);
|
||||
formData.append('conflict_mode', options.conflictMode);
|
||||
|
||||
const response = await fetch(`${API_BASE}/documents/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
return response.json() as Promise<UploadResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates document metadata and optionally trains routing suggestions.
|
||||
*/
|
||||
export async function updateDocumentMetadata(
|
||||
documentId: string,
|
||||
payload: { original_filename?: string; logical_path?: string; tags?: string[] },
|
||||
): Promise<DmsDocument> {
|
||||
const response = await fetch(`${API_BASE}/documents/${documentId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update document metadata');
|
||||
}
|
||||
return response.json() as Promise<DmsDocument>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a document to trash state without removing stored files.
|
||||
*/
|
||||
export async function trashDocument(documentId: string): Promise<DmsDocument> {
|
||||
const response = await fetch(`${API_BASE}/documents/${documentId}/trash`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to trash document');
|
||||
}
|
||||
return response.json() as Promise<DmsDocument>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a document from trash to active state.
|
||||
*/
|
||||
export async function restoreDocument(documentId: string): Promise<DmsDocument> {
|
||||
const response = await fetch(`${API_BASE}/documents/${documentId}/restore`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to restore document');
|
||||
}
|
||||
return response.json() as Promise<DmsDocument>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently deletes a document record and associated stored files.
|
||||
*/
|
||||
export async function deleteDocument(documentId: string): Promise<{ deleted_documents: number; deleted_files: number }> {
|
||||
const response = await fetch(`${API_BASE}/documents/${documentId}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete document');
|
||||
}
|
||||
return response.json() as Promise<{ deleted_documents: number; deleted_files: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads full details for one document, including extracted text content.
|
||||
*/
|
||||
export async function getDocumentDetails(documentId: string): Promise<DmsDocumentDetail> {
|
||||
const response = await fetch(`${API_BASE}/documents/${documentId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load document details');
|
||||
}
|
||||
return response.json() as Promise<DmsDocumentDetail>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enqueues one document for extraction and classification processing.
|
||||
*/
|
||||
export async function reprocessDocument(documentId: string): Promise<DmsDocument> {
|
||||
const response = await fetch(`${API_BASE}/documents/${documentId}/reprocess`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to reprocess document');
|
||||
}
|
||||
return response.json() as Promise<DmsDocument>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds preview URL for a specific document.
|
||||
*/
|
||||
export function previewUrl(documentId: string): string {
|
||||
return `${API_BASE}/documents/${documentId}/preview`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds thumbnail URL for dashboard card rendering.
|
||||
*/
|
||||
export function thumbnailUrl(documentId: string): string {
|
||||
return `${API_BASE}/documents/${documentId}/thumbnail`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds download URL for a specific document.
|
||||
*/
|
||||
export function downloadUrl(documentId: string): string {
|
||||
return `${API_BASE}/documents/${documentId}/download`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds direct markdown-content download URL for one document.
|
||||
*/
|
||||
export function contentMarkdownUrl(documentId: string): string {
|
||||
return `${API_BASE}/documents/${documentId}/content-md`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exports extracted content markdown files for selected documents or path filters.
|
||||
*/
|
||||
export async function exportContentsMarkdown(payload: {
|
||||
document_ids?: string[];
|
||||
path_prefix?: string;
|
||||
include_trashed?: boolean;
|
||||
only_trashed?: boolean;
|
||||
}): Promise<{ blob: Blob; filename: string }> {
|
||||
const response = await fetch(`${API_BASE}/documents/content-md/export`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to export markdown contents');
|
||||
}
|
||||
const blob = await response.blob();
|
||||
return {
|
||||
blob,
|
||||
filename: responseFilename(response, 'document-contents-md.zip'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves persisted application settings from backend.
|
||||
*/
|
||||
export async function getAppSettings(): Promise<AppSettings> {
|
||||
const response = await fetch(`${API_BASE}/settings`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load application settings');
|
||||
}
|
||||
return response.json() as Promise<AppSettings>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates provider and task settings for OpenAI-compatible model execution.
|
||||
*/
|
||||
export async function updateAppSettings(payload: AppSettingsUpdate): Promise<AppSettings> {
|
||||
const response = await fetch(`${API_BASE}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update settings');
|
||||
}
|
||||
return response.json() as Promise<AppSettings>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets persisted provider and task settings to backend defaults.
|
||||
*/
|
||||
export async function resetAppSettings(): Promise<AppSettings> {
|
||||
const response = await fetch(`${API_BASE}/settings/reset`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to reset settings');
|
||||
}
|
||||
return response.json() as Promise<AppSettings>;
|
||||
}
|
||||
18
frontend/src/main.tsx
Normal file
18
frontend/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Frontend application bootstrap for React rendering.
|
||||
*/
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import App from './App';
|
||||
import './design-foundation.css';
|
||||
import './styles.css';
|
||||
|
||||
/**
|
||||
* Mounts the root React application into the document.
|
||||
*/
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
1244
frontend/src/styles.css
Normal file
1244
frontend/src/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
292
frontend/src/types.ts
Normal file
292
frontend/src/types.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Shared TypeScript API contracts used by frontend components.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Enumerates backend document lifecycle states.
|
||||
*/
|
||||
export type DocumentStatus = 'queued' | 'processed' | 'unsupported' | 'error' | 'trashed';
|
||||
|
||||
/**
|
||||
* Represents one document row returned by backend APIs.
|
||||
*/
|
||||
export interface DmsDocument {
|
||||
id: string;
|
||||
original_filename: string;
|
||||
source_relative_path: string;
|
||||
mime_type: string;
|
||||
extension: string;
|
||||
size_bytes: number;
|
||||
sha256: string;
|
||||
logical_path: string;
|
||||
suggested_path: string | null;
|
||||
image_text_type: string | null;
|
||||
handwriting_style_id: string | null;
|
||||
tags: string[];
|
||||
suggested_tags: string[];
|
||||
status: DocumentStatus;
|
||||
preview_available: boolean;
|
||||
is_archive_member: boolean;
|
||||
archived_member_path: string | null;
|
||||
parent_document_id: string | null;
|
||||
replaces_document_id: string | null;
|
||||
created_at: string;
|
||||
processed_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents full document detail payload including extracted text and metadata.
|
||||
*/
|
||||
export interface DmsDocumentDetail extends DmsDocument {
|
||||
extracted_text: string;
|
||||
metadata_json: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents paginated document list payload.
|
||||
*/
|
||||
export interface DocumentListResponse {
|
||||
total: number;
|
||||
items: DmsDocument[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents search result payload.
|
||||
*/
|
||||
export interface SearchResponse {
|
||||
total: number;
|
||||
items: DmsDocument[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents distinct document type values available for filter controls.
|
||||
*/
|
||||
export interface TypeListResponse {
|
||||
types: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents one processing pipeline event entry returned by the backend.
|
||||
*/
|
||||
export interface ProcessingLogEntry {
|
||||
id: number;
|
||||
created_at: string;
|
||||
level: string;
|
||||
stage: string;
|
||||
event: string;
|
||||
document_id: string | null;
|
||||
document_filename: string | null;
|
||||
provider_id: string | null;
|
||||
model_name: string | null;
|
||||
prompt_text: string | null;
|
||||
response_text: string | null;
|
||||
payload_json: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents paginated processing log response payload.
|
||||
*/
|
||||
export interface ProcessingLogListResponse {
|
||||
total: number;
|
||||
items: ProcessingLogEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents upload conflict information.
|
||||
*/
|
||||
export interface UploadConflict {
|
||||
original_filename: string;
|
||||
sha256: string;
|
||||
existing_document_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents upload response payload.
|
||||
*/
|
||||
export interface UploadResponse {
|
||||
uploaded: DmsDocument[];
|
||||
conflicts: UploadConflict[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents one model provider binding served by the backend.
|
||||
*/
|
||||
export interface ProviderSettings {
|
||||
id: string;
|
||||
label: string;
|
||||
provider_type: string;
|
||||
base_url: string;
|
||||
timeout_seconds: number;
|
||||
api_key_set: boolean;
|
||||
api_key_masked: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents OCR task settings served by the backend.
|
||||
*/
|
||||
export interface OcrTaskSettings {
|
||||
enabled: boolean;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents summarization task settings served by the backend.
|
||||
*/
|
||||
export interface SummaryTaskSettings {
|
||||
enabled: boolean;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
max_input_tokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents routing task settings served by the backend.
|
||||
*/
|
||||
export interface RoutingTaskSettings {
|
||||
enabled: boolean;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
neighbor_count: number;
|
||||
neighbor_min_similarity: number;
|
||||
auto_apply_confidence_threshold: number;
|
||||
auto_apply_neighbor_similarity_threshold: number;
|
||||
neighbor_path_override_enabled: boolean;
|
||||
neighbor_path_override_min_similarity: number;
|
||||
neighbor_path_override_min_gap: number;
|
||||
neighbor_path_override_max_confidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents default upload destination and tags.
|
||||
*/
|
||||
export interface UploadDefaultsSettings {
|
||||
logical_path: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents display preferences for document listings.
|
||||
*/
|
||||
export interface DisplaySettings {
|
||||
cards_per_page: number;
|
||||
log_typing_animation_enabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents one predefined logical path and discoverability scope.
|
||||
*/
|
||||
export interface PredefinedPathEntry {
|
||||
value: string;
|
||||
global_shared: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents one predefined tag and discoverability scope.
|
||||
*/
|
||||
export interface PredefinedTagEntry {
|
||||
value: string;
|
||||
global_shared: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents handwriting-style clustering settings for Typesense image embeddings.
|
||||
*/
|
||||
export interface HandwritingStyleClusteringSettings {
|
||||
enabled: boolean;
|
||||
embed_model: string;
|
||||
neighbor_limit: number;
|
||||
match_min_similarity: number;
|
||||
bootstrap_match_min_similarity: number;
|
||||
bootstrap_sample_size: number;
|
||||
image_max_side: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents all task-level settings served by the backend.
|
||||
*/
|
||||
export interface TaskSettings {
|
||||
ocr_handwriting: OcrTaskSettings;
|
||||
summary_generation: SummaryTaskSettings;
|
||||
routing_classification: RoutingTaskSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents runtime settings served by the backend.
|
||||
*/
|
||||
export interface AppSettings {
|
||||
upload_defaults: UploadDefaultsSettings;
|
||||
display: DisplaySettings;
|
||||
handwriting_style_clustering: HandwritingStyleClusteringSettings;
|
||||
predefined_paths: PredefinedPathEntry[];
|
||||
predefined_tags: PredefinedTagEntry[];
|
||||
providers: ProviderSettings[];
|
||||
tasks: TaskSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents provider settings update input payload.
|
||||
*/
|
||||
export interface ProviderSettingsUpdate {
|
||||
id: string;
|
||||
label: string;
|
||||
provider_type: string;
|
||||
base_url: string;
|
||||
timeout_seconds: number;
|
||||
api_key?: string;
|
||||
clear_api_key?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents task settings update input payload.
|
||||
*/
|
||||
export interface TaskSettingsUpdate {
|
||||
ocr_handwriting?: Partial<OcrTaskSettings>;
|
||||
summary_generation?: Partial<SummaryTaskSettings>;
|
||||
routing_classification?: Partial<RoutingTaskSettings>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents upload defaults update input payload.
|
||||
*/
|
||||
export interface UploadDefaultsSettingsUpdate {
|
||||
logical_path?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents display settings update input payload.
|
||||
*/
|
||||
export interface DisplaySettingsUpdate {
|
||||
cards_per_page?: number;
|
||||
log_typing_animation_enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents handwriting-style clustering settings update payload.
|
||||
*/
|
||||
export interface HandwritingStyleClusteringSettingsUpdate {
|
||||
enabled?: boolean;
|
||||
embed_model?: string;
|
||||
neighbor_limit?: number;
|
||||
match_min_similarity?: number;
|
||||
bootstrap_match_min_similarity?: number;
|
||||
bootstrap_sample_size?: number;
|
||||
image_max_side?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents app settings update payload sent to backend.
|
||||
*/
|
||||
export interface AppSettingsUpdate {
|
||||
upload_defaults?: UploadDefaultsSettingsUpdate;
|
||||
display?: DisplaySettingsUpdate;
|
||||
handwriting_style_clustering?: HandwritingStyleClusteringSettingsUpdate;
|
||||
predefined_paths?: PredefinedPathEntry[];
|
||||
predefined_tags?: PredefinedTagEntry[];
|
||||
providers?: ProviderSettingsUpdate[];
|
||||
tasks?: TaskSettingsUpdate;
|
||||
}
|
||||
Reference in New Issue
Block a user