diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 261b8dbc7..e4fcb2884 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -996,7 +996,6 @@ }, "submit": "Change" }, - "removePages": { "tags": "Remove pages,delete pages", "title": "Remove Pages", @@ -1113,7 +1112,88 @@ }, "help": "Please read this documentation on how to use this for other languages and/or use not in docker", "credit": "This service uses qpdf and Tesseract for OCR.", - "submit": "Process PDF with OCR" + "submit": "Process PDF with OCR", + "operation": { + "submit": "Process OCR and Review" + }, + "results": { + "title": "OCR Results" + }, + "languagePicker": { + "additionalLanguages": "Looking for additional languages?", + "viewSetupGuide": "View setup guide →" + }, + "settings": { + "title": "Settings", + "ocrMode": { + "label": "OCR Mode", + "auto": "Auto (skip text layers)", + "force": "Force (re-OCR all, replace text)", + "strict": "Strict (abort if text found)" + }, + "languages": { + "label": "Languages", + "placeholder": "Select languages" + }, + "compatibilityMode": { + "label": "Compatibility Mode" + }, + "advancedOptions": { + "label": "Processing Options", + "sidecar": "Create a text file", + "deskew": "Deskew pages", + "clean": "Clean input file", + "cleanFinal": "Clean final output" + } + }, + "tooltip": { + "header": { + "title": "OCR Settings Overview" + }, + "mode": { + "title": "OCR Mode", + "text": "Optical Character Recognition (OCR) helps you turn scanned or screenshotted pages into text you can search, copy, or highlight.", + "bullet1": "Auto skips pages that already contain text layers.", + "bullet2": "Force re-OCRs every page and replaces all the text.", + "bullet3": "Strict halts if any selectable text is found." + }, + "languages": { + "title": "Languages", + "text": "Improve OCR accuracy by specifying the expected languages. Choose one or more languages to guide detection." + }, + "output": { + "title": "Output", + "text": "Decide how you want the text output formatted:", + "bullet1": "Searchable PDF embeds text behind the original image.", + "bullet2": "HOCR XML returns a structured machine-readable file.", + "bullet3": "Plain-text sidecar creates a separate .txt file with raw content." + }, + "advanced": { + "header": { + "title": "Advanced OCR Processing" + }, + "compatibility": { + "title": "Compatibility Mode", + "text": "Uses OCR 'sandwich PDF' mode: results in larger files, but more reliable with certain languages and older PDF software. By default we use hOCR for smaller, modern PDFs." + }, + "sidecar": { + "title": "Create Text File", + "text": "Generates a separate .txt file alongside the PDF containing all extracted text content for easy access and processing." + }, + "deskew": { + "title": "Deskew Pages", + "text": "Automatically corrects skewed or tilted pages to improve OCR accuracy. Useful for scanned documents that weren't perfectly aligned." + }, + "clean": { + "title": "Clean Input File", + "text": "Preprocesses the input by removing noise, enhancing contrast, and optimising the image for better OCR recognition before processing." + }, + "cleanFinal": { + "title": "Clean Final Output", + "text": "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size." + } + } + } }, "extractImages": { "tags": "picture,photo,save,archive,zip,capture,grab", diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index 5836dc33f..a0a7d1795 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -1,11 +1,14 @@ -import React, { useState, useCallback, useRef, useEffect } from 'react'; -import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core'; +import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; +import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core'; import { useTranslation } from 'react-i18next'; -import CloseIcon from '@mui/icons-material/Close'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; + import styles from './PageEditor.module.css'; import { useFileContext } from '../../contexts/FileContext'; @@ -15,7 +18,7 @@ interface FileItem { pageCount: number; thumbnail: string | null; size: number; - splitBefore?: boolean; + modifiedAt?: number | string | Date; } interface FileThumbnailProps { @@ -29,6 +32,7 @@ interface FileThumbnailProps { onViewFile: (fileId: string) => void; onSetStatus: (status: string) => void; onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void; + onDownloadFile?: (fileId: string) => void; toolMode?: boolean; isSupported?: boolean; } @@ -36,41 +40,98 @@ interface FileThumbnailProps { const FileThumbnail = ({ file, index, - totalFiles, selectedFiles, - selectionMode, onToggleFile, onDeleteFile, onViewFile, onSetStatus, onReorderFiles, - toolMode = false, + onDownloadFile, isSupported = true, }: FileThumbnailProps) => { const { t } = useTranslation(); - const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); - - // Drag and drop state + const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext(); + + // ---- Drag state ---- const [isDragging, setIsDragging] = useState(false); const dragElementRef = useRef(null); + const [actionsWidth, setActionsWidth] = useState(undefined); + const [showActions, setShowActions] = useState(false); - // Find the actual File object that corresponds to this FileItem - const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size); + // Resolve the actual File object for pin/unpin operations + const actualFile = useMemo(() => { + return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); + }, [activeFiles, file.name, file.size]); + const isPinned = actualFile ? isFilePinned(actualFile) : false; - const formatFileSize = (bytes: number) => { + const downloadSelectedFile = useCallback(() => { + // Prefer parent-provided handler if available + if (typeof onDownloadFile === 'function') { + onDownloadFile(file.id); + return; + } + + // Fallback: attempt to download using the File object if provided + const maybeFile = (file as unknown as { file?: File }).file; + if (maybeFile instanceof File) { + const link = document.createElement('a'); + link.href = URL.createObjectURL(maybeFile); + link.download = maybeFile.name || file.name || 'download'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(link.href); + return; + } + + // If we can't find a way to download, surface a status message + onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item'); + }, [file, onDownloadFile, onSetStatus, t]); + const handleRef = useRef(null); + + // ---- Selection ---- + const isSelected = selectedFiles.includes(file.id); + + // ---- Meta formatting ---- + const prettySize = useMemo(() => { + const bytes = file.size ?? 0; if (bytes === 0) return '0 B'; const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; - }; + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; + }, [file.size]); - // Setup drag and drop using @atlaskit/pragmatic-drag-and-drop + const extUpper = useMemo(() => { + const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); + return (m?.[1] || '').toUpperCase(); + }, [file.name]); + + const pageLabel = useMemo( + () => + file.pageCount > 0 + ? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}` + : '', + [file.pageCount] + ); + + const dateLabel = useMemo(() => { + const d = + file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback + if (Number.isNaN(d.getTime())) return ''; + return new Intl.DateTimeFormat(undefined, { + month: 'short', + day: '2-digit', + year: 'numeric', + }).format(d); + }, [file.modifiedAt]); + + // ---- Drag & drop wiring ---- const fileElementRef = useCallback((element: HTMLDivElement | null) => { if (!element) return; - + dragElementRef.current = element; - + const dragCleanup = draggable({ element, getInitialData: () => ({ @@ -86,7 +147,7 @@ const FileThumbnail = ({ setIsDragging(false); } }); - + const dropCleanup = dropTargetForElements({ element, getData: () => ({ @@ -111,69 +172,164 @@ const FileThumbnail = ({ dragCleanup(); dropCleanup(); }; - }, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]); + }, [file.id, file.name, selectedFiles, onReorderFiles]); + + // Update dropdown width on resize + useEffect(() => { + const update = () => { + if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth); + }; + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, []); + + // Close the actions dropdown when hovering outside this file card (and its dropdown) + useEffect(() => { + if (!showActions) return; + + const isInsideCard = (target: EventTarget | null) => { + const container = dragElementRef.current; + if (!container) return false; + return target instanceof Node && container.contains(target); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isInsideCard(e.target)) { + setShowActions(false); + } + }; + + const handleTouchStart = (e: TouchEvent) => { + // On touch devices, close if the touch target is outside the card + if (!isInsideCard(e.target)) { + setShowActions(false); + } + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('touchstart', handleTouchStart, { passive: true }); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('touchstart', handleTouchStart); + }; + }, [showActions]); + + // ---- Card interactions ---- + const handleCardClick = () => { + if (!isSupported) return; + onToggleFile(file.id); + }; + return (
-
e.stopPropagation()} - onDragStart={(e) => { - e.preventDefault(); + {/* Header bar */} +
+ {/* Logo/checkbox area */} +
+ {isSupported ? ( + onToggleFile(file.id)} + color="var(--checkbox-checked-bg)" + /> + ) : ( +
+ + {t('unsupported', 'Unsupported')} + +
+ )} +
+ + {/* Centered index */} +
+ {index + 1} +
+ + {/* Kebab menu */} + { e.stopPropagation(); + setShowActions((v) => !v); }} > - { - event.stopPropagation(); - if (isSupported) { - onToggleFile(file.id); + + +
+ + {/* Actions overlay */} + {showActions && ( +
e.stopPropagation()} + > + + + + +
+ +
+ )} {/* File content area */}
@@ -204,161 +360,35 @@ const FileThumbnail = ({ img.style.display = 'none'; }} style={{ - maxWidth: '100%', - maxHeight: '100%', + maxWidth: '80%', + maxHeight: '80%', objectFit: 'contain', - borderRadius: 2, + borderRadius: 0, + background: '#ffffff', + border: '1px solid var(--border-default)', + display: 'block', + marginLeft: 'auto', + marginRight: 'auto', + alignSelf: 'start' }} /> )}
- {/* Page count badge - only show for PDFs */} - {file.pageCount > 0 && ( - - {file.pageCount} {file.pageCount === 1 ? 'page' : 'pages'} - + {/* Pin indicator (bottom-left) */} + {isPinned && ( + + + )} - {/* Unsupported badge */} - {!isSupported && ( - -{t("fileManager.unsupported", "Unsupported")} - - )} - - {/* File name overlay */} - - {file.name} - - - {/* Hover controls */} -
- - {actualFile && ( - - { - e.stopPropagation(); - if (isFilePinned(actualFile)) { - unpinFile(actualFile); - onSetStatus(`Unpinned ${file.name}`); - } else { - pinFile(actualFile); - onSetStatus(`Pinned ${file.name}`); - } - }} - > - {isFilePinned(actualFile) ? ( - - ) : ( - - )} - - - )} - - - { - e.stopPropagation(); - onDeleteFile(file.id); - onSetStatus(`Closed ${file.name}`); - }} - > - - - -
- - + {/* Drag handle (span wrapper so we can attach a ref reliably) */} + + +
- - {/* File info */} -
- - {file.name} - - - {formatFileSize(file.size)} - -
-
); }; -export default FileThumbnail; \ No newline at end of file +export default React.memo(FileThumbnail); diff --git a/frontend/src/components/pageEditor/PageEditor.module.css b/frontend/src/components/pageEditor/PageEditor.module.css index b172aff23..373c9e218 100644 --- a/frontend/src/components/pageEditor/PageEditor.module.css +++ b/frontend/src/components/pageEditor/PageEditor.module.css @@ -57,11 +57,29 @@ 0%, 100% { opacity: 1; } - 50% { - opacity: 0.5; - } + .actionRow:hover { background: var(--hover-bg); } + .actionDanger { color: var(--text-brand-accent); } + .actionsDivider { height: 1px; background: var(--border-default); margin: 4px 0; } + +.pinIndicator { + position: absolute; + bottom: 4px; + left: 4px; + z-index: 1; + color: rgba(0, 0, 0, 0.35); /* match drag handle color */ } -.pulse { - animation: pulse 1s infinite; -} \ No newline at end of file +.unsupportedPill { + margin-left: 1.75rem; + background: #6B7280; + color: white; + padding: 4px 8px; + border-radius: 12px; + font-size: 10px; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + min-width: 80px; + height: 20px; +} diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index 229c3d362..9c41b35e0 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -27,7 +27,7 @@ const createViewOptions = (switchingTo: ModeType | null) => [ ) : ( )} - Read + Viewer
), value: "viewer", diff --git a/frontend/src/components/tooltips/useAdvancedOCRTips.ts b/frontend/src/components/tooltips/useAdvancedOCRTips.ts new file mode 100644 index 000000000..e1b4532c1 --- /dev/null +++ b/frontend/src/components/tooltips/useAdvancedOCRTips.ts @@ -0,0 +1,34 @@ +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const useAdvancedOCRTips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("ocr.tooltip.advanced.header.title", "Advanced OCR Processing"), + }, + tips: [ + { + title: t("ocr.tooltip.advanced.compatibility.title", "Compatibility Mode"), + description: t("ocr.tooltip.advanced.compatibility.text", "Uses OCR 'sandwich PDF' mode: results in larger files, but more reliable with certain languages and older PDF software. By default we use hOCR for smaller, modern PDFs.") + }, + { + title: t("ocr.tooltip.advanced.sidecar.title", "Create Text File"), + description: t("ocr.tooltip.advanced.sidecar.text", "Generates a separate .txt file alongside the PDF containing all extracted text content for easy access and processing.") + }, + { + title: t("ocr.tooltip.advanced.deskew.title", "Deskew Pages"), + description: t("ocr.tooltip.advanced.deskew.text", "Automatically corrects skewed or tilted pages to improve OCR accuracy. Useful for scanned documents that weren't perfectly aligned.") + }, + { + title: t("ocr.tooltip.advanced.clean.title", "Clean Input File"), + description: t("ocr.tooltip.advanced.clean.text", "Preprocesses the input by removing noise, enhancing contrast, and optimising the image for better OCR recognition before processing.") + }, + { + title: t("ocr.tooltip.advanced.cleanFinal.title", "Clean Final Output"), + description: t("ocr.tooltip.advanced.cleanFinal.text", "Post-processes the final PDF by removing OCR artefacts and optimising the text layer for better readability and smaller file size.") + } + ] + }; +}; diff --git a/frontend/src/hooks/useRainbowTheme.ts b/frontend/src/hooks/useRainbowTheme.ts index b16ed1228..449b07c61 100644 --- a/frontend/src/hooks/useRainbowTheme.ts +++ b/frontend/src/hooks/useRainbowTheme.ts @@ -161,8 +161,8 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb } lastToggleTime.current = currentTime; - // Easter egg: Activate rainbow mode after 6 rapid toggles - if (toggleCount.current >= 6) { + // Easter egg: Activate rainbow mode after 10 rapid toggles + if (toggleCount.current >= 10) { setThemeMode('rainbow'); console.log('🌈 RAINBOW MODE ACTIVATED! 🌈 You found the secret easter egg!'); console.log('🌈 Button will be disabled for 3 seconds, then click once to exit!'); diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index a8efa179e..6643ca580 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -162,10 +162,22 @@ --landing-drop-inner-paper-bg: #BBDEFB; --landing-drop-inner-paper-border: #90CAF9; + /* selected file header colors */ + --header-selected-bg: #1E88E5; /* light mode selected header matches dark */ + --header-selected-fg: #FFFFFF; + --file-card-bg: #FFFFFF; /* file card background (light/dark paired) */ + /* shadows */ --drop-shadow-color: rgba(0, 0, 0, 0.08); --drop-shadow-color-strong: rgba(0, 0, 0, 0.04); --drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04)); + + /* Light mode card hover and selection */ + --header-hover-bg: #3B4B6E; /* same family as selected, a touch muted for hover */ + --card-selected-border: #3FAFFF; /* slightly more blue than dark mode header */ + --checkbox-border: #2F83BF; + --checkbox-checked-bg: #3FAFFF; + --checkbox-tick: #FFFFFF; } [data-mantine-color-scheme="dark"] { @@ -272,6 +284,12 @@ --landing-drop-inner-paper-bg: #2A3441; --landing-drop-inner-paper-border: #3A4451; + /* selected file header colors for dark */ + --header-selected-bg: #1E88E5; + --header-selected-fg: #FFFFFF; + /* file card background (dark) */ + --file-card-bg: #1F2329; + /* shadows */ --drop-shadow-color: rgba(255, 255, 255, 0.08); --drop-shadow-color-strong: rgba(255, 255, 255, 0.04); diff --git a/frontend/src/tools/OCR.tsx b/frontend/src/tools/OCR.tsx index 52db3b0de..e2b56770e 100644 --- a/frontend/src/tools/OCR.tsx +++ b/frontend/src/tools/OCR.tsx @@ -13,15 +13,16 @@ import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters"; import { useOCROperation } from "../hooks/tools/ocr/useOCROperation"; import { BaseToolProps, ToolComponent } from "../types/tool"; import { useOCRTips } from "../components/tooltips/useOCRTips"; +import { useAdvancedOCRTips } from "../components/tooltips/useAdvancedOCRTips"; const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const { t } = useTranslation(); - const { actions } = useNavigationActions(); const { selectedFiles } = useFileSelection(); const ocrParams = useOCRParameters(); const ocrOperation = useOCROperation(); const ocrTips = useOCRTips(); + const advancedOCRTips = useAdvancedOCRTips(); // Step expansion state management const [expandedStep, setExpandedStep] = useState<"files" | "settings" | "advanced" | null>("files"); @@ -82,7 +83,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }, steps: [ { - title: "Settings", + title: t("ocr.settings.title", "Settings"), isCollapsed: !hasFiles || settingsCollapsed, onCollapsedClick: hasResults ? handleSettingsReset @@ -108,6 +109,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { if (!hasFiles) return; // Only allow if files are selected setExpandedStep(expandedStep === "advanced" ? null : "advanced"); }, + tooltip: advancedOCRTips, content: (