diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 37c0e5355..eedcb7885 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1676,4 +1676,4 @@ "storageQuotaExceeded": "Storage quota exceeded. Please remove some files before uploading more.", "approximateSize": "Approximate size" } -} +} \ No newline at end of file diff --git a/frontend/src/components/shared/DropdownListWithFooter.tsx b/frontend/src/components/shared/DropdownListWithFooter.tsx new file mode 100644 index 000000000..368b2255e --- /dev/null +++ b/frontend/src/components/shared/DropdownListWithFooter.tsx @@ -0,0 +1,237 @@ +import React, { ReactNode, useState, useMemo } from 'react'; +import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } from '@mantine/core'; +import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore'; +import SearchIcon from '@mui/icons-material/Search'; + +export interface DropdownItem { + value: string; + name: string; + leftIcon?: ReactNode; + disabled?: boolean; +} + +export interface DropdownListWithFooterProps { + // Value and onChange - support both single and multi-select + value: string | string[]; + onChange: (value: string | string[]) => void; + + // Items and display + items: DropdownItem[]; + placeholder?: string; + disabled?: boolean; + + // Labels and headers + label?: string; + header?: ReactNode; + footer?: ReactNode; + + // Behavior + multiSelect?: boolean; + searchable?: boolean; + maxHeight?: number; + + // Styling + className?: string; + dropdownClassName?: string; + + // Popover props + position?: 'top' | 'bottom' | 'left' | 'right'; + withArrow?: boolean; + width?: 'target' | number; +} + +const DropdownListWithFooter: React.FC = ({ + value, + onChange, + items, + placeholder = 'Select option', + disabled = false, + label, + header, + footer, + multiSelect = false, + searchable = false, + maxHeight = 300, + className = '', + dropdownClassName = '', + position = 'bottom', + withArrow = false, + width = 'target' +}) => { + + const [searchTerm, setSearchTerm] = useState(''); + + const isMultiValue = Array.isArray(value); + const selectedValues = isMultiValue ? value : (value ? [value] : []); + + // Filter items based on search term + const filteredItems = useMemo(() => { + if (!searchable || !searchTerm.trim()) { + return items; + } + return items.filter(item => + item.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }, [items, searchTerm, searchable]); + + const handleItemClick = (itemValue: string) => { + if (multiSelect) { + const newSelection = selectedValues.includes(itemValue) + ? selectedValues.filter(v => v !== itemValue) + : [...selectedValues, itemValue]; + onChange(newSelection); + } else { + onChange(itemValue); + } + }; + + const getDisplayText = () => { + if (selectedValues.length === 0) { + return placeholder; + } else if (selectedValues.length === 1) { + const selectedItem = items.find(item => item.value === selectedValues[0]); + return selectedItem?.name || selectedValues[0]; + } else { + return `${selectedValues.length} selected`; + } + }; + + const handleSearchChange = (event: React.ChangeEvent) => { + setSearchTerm(event.currentTarget.value); + }; + + return ( + + {label && ( + + {label} + + )} + + searchable && setSearchTerm('')} + > + + + + {getDisplayText()} + + + + + + + + {header && ( + + {header} + + )} + + {searchable && ( + + } + size="sm" + style={{ width: '100%' }} + /> + + )} + + + {filteredItems.length === 0 ? ( + + + {searchable && searchTerm ? 'No results found' : 'No items available'} + + + ) : ( + filteredItems.map((item) => ( + !item.disabled && handleItemClick(item.value)} + style={{ + padding: '8px 12px', + cursor: item.disabled ? 'not-allowed' : 'pointer', + borderRadius: 'var(--mantine-radius-sm)', + opacity: item.disabled ? 0.5 : 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between' + }} + onMouseEnter={(e) => { + if (!item.disabled) { + e.currentTarget.style.backgroundColor = 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))'; + } + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + + {item.leftIcon && ( + + {item.leftIcon} + + )} + {item.name} + + + {multiSelect && ( + {}} // Handled by parent onClick + size="sm" + disabled={item.disabled} + /> + )} + + )) + )} + + + {footer && ( + + {footer} + + )} + + + + + ); +}; + +export default DropdownListWithFooter; \ No newline at end of file diff --git a/frontend/src/components/shared/LanguageSelector.module.css b/frontend/src/components/shared/LanguageSelector.module.css index 09010dc4a..431f43806 100644 --- a/frontend/src/components/shared/LanguageSelector.module.css +++ b/frontend/src/components/shared/LanguageSelector.module.css @@ -44,7 +44,7 @@ /* Dark theme support */ [data-mantine-color-scheme="dark"] .languageItem { - border-right-color: var(--mantine-color-dark-4); + border-right-color: var(--mantine-color-dark-3); } [data-mantine-color-scheme="dark"] .languageItem:nth-child(4n) { @@ -52,11 +52,11 @@ } [data-mantine-color-scheme="dark"] .languageItem:nth-child(2n) { - border-right-color: var(--mantine-color-dark-4); + border-right-color: var(--mantine-color-dark-3); } [data-mantine-color-scheme="dark"] .languageItem:nth-child(3n) { - border-right-color: var(--mantine-color-dark-4); + border-right-color: var(--mantine-color-dark-3); } /* Responsive text visibility */ diff --git a/frontend/src/components/shared/LanguageSelector.tsx b/frontend/src/components/shared/LanguageSelector.tsx index 83cecc6b0..bd6269b8e 100644 --- a/frontend/src/components/shared/LanguageSelector.tsx +++ b/frontend/src/components/shared/LanguageSelector.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core'; +import { Menu, Button, ScrollArea } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { supportedLanguages } from '../../i18n'; import LanguageIcon from '@mui/icons-material/Language'; @@ -7,8 +7,6 @@ import styles from './LanguageSelector.module.css'; const LanguageSelector = () => { const { i18n } = useTranslation(); - const theme = useMantineTheme(); - const { colorScheme } = useMantineColorScheme(); const [opened, setOpened] = useState(false); const [animationTriggered, setAnimationTriggered] = useState(false); const [isChanging, setIsChanging] = useState(false); @@ -102,10 +100,10 @@ const LanguageSelector = () => { styles={{ root: { border: 'none', - color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7], + color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))', transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)', '&:hover': { - backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], + backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', } }, label: { @@ -125,7 +123,8 @@ const LanguageSelector = () => { padding: '12px', borderRadius: '8px', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)', - border: colorScheme === 'dark' ? `1px solid ${theme.colors.dark[4]}` : `1px solid ${theme.colors.gray[3]}`, + backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))', + border: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))', }} > @@ -145,6 +144,7 @@ const LanguageSelector = () => { size="sm" fullWidth onClick={(event) => handleLanguageChange(option.value, event)} + data-selected={option.value === i18n.language} styles={{ root: { borderRadius: '4px', @@ -153,21 +153,17 @@ const LanguageSelector = () => { justifyContent: 'flex-start', position: 'relative', overflow: 'hidden', - backgroundColor: option.value === i18n.language ? ( - colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1] - ) : 'transparent', - color: option.value === i18n.language ? ( - colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7] - ) : ( - colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7] - ), + backgroundColor: option.value === i18n.language + ? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))' + : 'transparent', + color: option.value === i18n.language + ? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))' + : 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))', transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)', '&:hover': { - backgroundColor: option.value === i18n.language ? ( - colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2] - ) : ( - colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1] - ), + backgroundColor: option.value === i18n.language + ? 'light-dark(var(--mantine-color-blue-2), var(--mantine-color-blue-7))' + : 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))', transform: 'translateY(-1px)', boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', } @@ -197,7 +193,7 @@ const LanguageSelector = () => { width: 0, height: 0, borderRadius: '50%', - backgroundColor: theme.colors.blue[4], + backgroundColor: 'var(--mantine-color-blue-4)', opacity: 0.6, transform: 'translate(-50%, -50%)', animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)', diff --git a/frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx b/frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx new file mode 100644 index 000000000..3bd8c1569 --- /dev/null +++ b/frontend/src/components/tools/ocr/AdvancedOCRSettings.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import { Stack, Text, Checkbox } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { OCRParameters } from './OCRSettings'; + +export interface AdvancedOCRParameters { + advancedOptions: string[]; +} + +interface AdvancedOption { + value: string; + label: string; + isSpecial: boolean; +} + +interface AdvancedOCRSettingsProps { + advancedOptions: string[]; + ocrRenderType?: string; + onParameterChange: (key: keyof OCRParameters, value: any) => void; + disabled?: boolean; +} + +const AdvancedOCRSettings: React.FC = ({ + advancedOptions, + ocrRenderType = 'hocr', + onParameterChange, + disabled = false +}) => { + const { t } = useTranslation(); + + // Define the advanced options available + const advancedOptionsData: AdvancedOption[] = [ + { value: 'compatibilityMode', label: t('ocr.settings.compatibilityMode.label', 'Compatibility Mode'), isSpecial: true }, + { value: 'sidecar', label: t('ocr.settings.advancedOptions.sidecar', 'Create a text file'), isSpecial: false }, + { value: 'deskew', label: t('ocr.settings.advancedOptions.deskew', 'Deskew pages'), isSpecial: false }, + { value: 'clean', label: t('ocr.settings.advancedOptions.clean', 'Clean input file'), isSpecial: false }, + { value: 'cleanFinal', label: t('ocr.settings.advancedOptions.cleanFinal', 'Clean final output'), isSpecial: false }, + ]; + + // Handle individual checkbox changes + const handleCheckboxChange = (optionValue: string, checked: boolean) => { + const option = advancedOptionsData.find(opt => opt.value === optionValue); + + if (option?.isSpecial) { + // Handle special options (like compatibility mode) differently + if (optionValue === 'compatibilityMode') { + onParameterChange('ocrRenderType', checked ? 'sandwich' : 'hocr'); + } + } else { + // Handle regular advanced options + const newOptions = checked + ? [...advancedOptions, optionValue] + : advancedOptions.filter(option => option !== optionValue); + onParameterChange('additionalOptions', newOptions); + } + }; + + // Check if a special option is selected + const isSpecialOptionSelected = (optionValue: string) => { + if (optionValue === 'compatibilityMode') { + return ocrRenderType === 'sandwich'; + } + return false; + }; + + return ( + +
+ + {t('ocr.settings.advancedOptions.label', 'Processing Options')} + + + + {advancedOptionsData.map((option) => ( + handleCheckboxChange(option.value, event.currentTarget.checked)} + label={option.label} + disabled={disabled} + size="sm" + /> + ))} + +
+
+ ); +}; + +export default AdvancedOCRSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/ocr/LanguagePicker.module.css b/frontend/src/components/tools/ocr/LanguagePicker.module.css new file mode 100644 index 000000000..c44e75291 --- /dev/null +++ b/frontend/src/components/tools/ocr/LanguagePicker.module.css @@ -0,0 +1,126 @@ +/* Language Picker Component */ +.languagePicker { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; /* Center align items vertically */ + height: 32px; + border: 1px solid var(--border-default); + background-color: var(--mantine-color-white); /* Use Mantine color variable */ + color: var(--text-secondary); + border-radius: var(--radius-sm); + padding: 4px 8px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; +} + +/* Dark mode background */ +[data-mantine-color-scheme="dark"] .languagePicker { + background-color: var(--mantine-color-dark-6); /* Use Mantine dark color instead of hardcoded */ +} + +.languagePicker:hover { + border-color: var(--border-strong); + background-color: var(--mantine-color-gray-0); /* Light gray on hover for light mode */ +} + +/* Dark mode hover */ +[data-mantine-color-scheme="dark"] .languagePicker:hover { + background-color: var(--mantine-color-dark-5); /* Use Mantine color variable */ +} + +.languagePicker:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.languagePickerIcon { + font-size: 16px; + color: var(--text-muted); + margin-left: auto; + display: flex; + align-items: center; /* Center the icon vertically */ +} + +.languagePickerDropdown { + background-color: var(--mantine-color-white); /* Use Mantine color variable */ + border: 1px solid var(--border-default); + border-radius: var(--radius-sm); + padding: 4px; +} + +/* Dark mode dropdown background */ +[data-mantine-color-scheme="dark"] .languagePickerDropdown { + background-color: var(--mantine-color-dark-6); +} + +.languagePickerOption { + padding: 6px 10px; + cursor: pointer; + border-radius: var(--radius-xs); + font-size: 13px; + color: var(--text-primary); + transition: background-color 0.2s ease; +} + +.languagePickerOptionWithCheckbox { + display: flex; + align-items: center; + justify-content: space-between; +} + +.languagePickerCheckbox { + margin-left: auto; +} + +.languagePickerOption:hover { + background-color: var(--mantine-color-gray-0); /* Light gray on hover for light mode */ +} + +/* Dark mode option hover */ +[data-mantine-color-scheme="dark"] .languagePickerOption:hover { + background-color: var(--mantine-color-dark-5); +} + + + +/* Additional helper classes for the component */ +.languagePickerTarget { + width: 100%; +} + +.languagePickerContent { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; +} + +.languagePickerText { + flex: 1; + text-align: left; +} + +.languagePickerScrollArea { + max-height: 180px; + border-bottom: 1px solid var(--border-default); + padding-bottom: 8px; +} + +.languagePickerFooter { + padding: 8px; + text-align: center; + font-size: 12px; +} + +.languagePickerLink { + color: var(--mantine-color-blue-6); + text-decoration: underline; + cursor: pointer; +} + +/* Dark mode link */ +[data-mantine-color-scheme="dark"] .languagePickerLink { + color: var(--mantine-color-blue-4); +} \ No newline at end of file diff --git a/frontend/src/components/tools/ocr/LanguagePicker.tsx b/frontend/src/components/tools/ocr/LanguagePicker.tsx new file mode 100644 index 000000000..31f0fe301 --- /dev/null +++ b/frontend/src/components/tools/ocr/LanguagePicker.tsx @@ -0,0 +1,151 @@ +import React, { useState, useEffect } from 'react'; +import { Text, Loader } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { tempOcrLanguages, getAutoOcrLanguage } from '../../../utils/languageMapping'; +import DropdownListWithFooter, { DropdownItem } from '../../shared/DropdownListWithFooter'; + +export interface LanguageOption { + value: string; + label: string; +} + +export interface LanguagePickerProps { + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; + disabled?: boolean; + label?: string; + languagesEndpoint?: string; + autoFillFromBrowserLanguage?: boolean; +} + +const LanguagePicker: React.FC = ({ + value, + onChange, + placeholder = 'Select languages', + disabled = false, + label, + languagesEndpoint = '/api/v1/ui-data/ocr-pdf', + autoFillFromBrowserLanguage = true, +}) => { + const { t, i18n } = useTranslation(); + const [availableLanguages, setAvailableLanguages] = useState([]); + const [isLoadingLanguages, setIsLoadingLanguages] = useState(true); + const [hasAutoFilled, setHasAutoFilled] = useState(false); + + useEffect(() => { + // Fetch available languages from backend + const fetchLanguages = async () => { + try { + const response = await fetch(languagesEndpoint); + + + if (response.ok) { + const data: { languages: string[] } = await response.json(); + const languages = data.languages; + + + const languageOptions = languages.map(lang => { + // TODO: Use actual language translations when they become available + // For now, use temporary English translations + const translatedName = tempOcrLanguages.lang[lang as keyof typeof tempOcrLanguages.lang] || lang; + const displayName = translatedName; + + return { + value: lang, + name: displayName + }; + }); + + setAvailableLanguages(languageOptions); + } else { + console.error('[LanguagePicker] Response not OK:', response.status, response.statusText); + const errorText = await response.text(); + console.error('[LanguagePicker] Error response body:', errorText); + } + } catch (error) { + console.error('[LanguagePicker] Fetch failed with error:', error); + console.error('[LanguagePicker] Error details:', { + name: error instanceof Error ? error.name : 'Unknown', + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined + }); + } finally { + setIsLoadingLanguages(false); + } + }; + + fetchLanguages(); + }, [languagesEndpoint]); + + // Auto-fill OCR language based on browser language when languages are loaded + useEffect(() => { + const shouldAutoFillLanguage = autoFillFromBrowserLanguage && !isLoadingLanguages && availableLanguages.length > 0 && !hasAutoFilled && value.length === 0; + + if (shouldAutoFillLanguage) { + // Use the comprehensive language mapping from languageMapping.ts + const suggestedOcrLanguages = getAutoOcrLanguage(i18n.language); + + if (suggestedOcrLanguages.length > 0) { + // Find the first suggested language that's available in the backend + const matchingLanguage = availableLanguages.find(lang => + suggestedOcrLanguages.includes(lang.value) + ); + + if (matchingLanguage) { + onChange([matchingLanguage.value]); + } + } + + setHasAutoFilled(true); + } + }, [autoFillFromBrowserLanguage, isLoadingLanguages, availableLanguages, hasAutoFilled, value.length, i18n.language, onChange]); + + if (isLoadingLanguages) { + return ( +
+ + Loading available languages... +
+ ); + } + + const footer = ( + <> +
+ + {t('ocr.languagePicker.additionalLanguages', 'Looking for additional languages?')} + + window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')} + > + {t('ocr.languagePicker.viewSetupGuide', 'View setup guide →')} + +
+ + ); + + return ( + onChange(newValue as string[])} + items={availableLanguages} + placeholder={placeholder} + disabled={disabled} + label={label} + footer={footer} + multiSelect={true} + maxHeight={300} + searchable={true} + /> + ); +}; + +export default LanguagePicker; \ No newline at end of file diff --git a/frontend/src/components/tools/ocr/OCRSettings.tsx b/frontend/src/components/tools/ocr/OCRSettings.tsx new file mode 100644 index 000000000..588884889 --- /dev/null +++ b/frontend/src/components/tools/ocr/OCRSettings.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Stack, Select, Text, Divider } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import LanguagePicker from './LanguagePicker'; + +export interface OCRParameters { + languages: string[]; + ocrType: string; + ocrRenderType: string; + additionalOptions: string[]; +} + +interface OCRSettingsProps { + parameters: OCRParameters; + onParameterChange: (key: keyof OCRParameters, value: any) => void; + disabled?: boolean; +} + +const OCRSettings: React.FC = ({ + parameters, + onParameterChange, + disabled = false +}) => { + const { t } = useTranslation(); + + return ( + + +