React translations

This commit is contained in:
Reece
2025-05-29 17:26:32 +01:00
parent 5f862f55d9
commit 09f05ac8d0
106 changed files with 128926 additions and 196 deletions

View File

@@ -1,9 +1,10 @@
import React, { useState, useEffect } from "react";
import { Card, Group, Text, Stack, Image, Badge, Button, Box, Flex, ThemeIcon } from "@mantine/core";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { GlobalWorkerOptions } from "pdfjs-dist";
import { GlobalWorkerOptions, getDocument } from "pdfjs-dist";
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
export interface FileWithUrl extends File {
@@ -63,6 +64,7 @@ interface FileCardProps {
}
function FileCard({ file, onRemove, onDoubleClick }: FileCardProps) {
const { t } = useTranslation();
const thumb = usePdfThumbnail(file);
return (
@@ -120,7 +122,7 @@ function FileCard({ file, onRemove, onDoubleClick }: FileCardProps) {
onClick={onRemove}
mt={4}
>
Remove
{t("delete", "Remove")}
</Button>
</Stack>
</Card>
@@ -142,6 +144,7 @@ const FileManager: React.FC<FileManagerProps> = ({
setPdfFile,
setCurrentView,
}) => {
const { t } = useTranslation();
const handleDrop = (uploadedFiles: File[]) => {
setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...uploadedFiles] : uploadedFiles));
};
@@ -171,13 +174,13 @@ const FileManager: React.FC<FileManagerProps> = ({
>
<Group justify="center" gap="xl" style={{ pointerEvents: "none" }}>
<Text size="md">
Drag PDF files here or click to select
{t("fileChooser.dragAndDropPDF", "Drag PDF files here or click to select")}
</Text>
</Group>
</Dropzone>
{files.length === 0 ? (
<Text c="dimmed" ta="center">
No files uploaded yet.
{t("noFileSelected", "No files uploaded yet.")}
</Text>
) : (
<Box>

View File

@@ -0,0 +1,71 @@
/* Language selector grid responsive layout */
.languageGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0;
}
.languageItem {
border-right: 2px solid var(--mantine-color-gray-3);
}
.languageItem:nth-child(4n) {
border-right: none;
}
/* Responsive breakpoints */
@media (max-width: 600px) {
.languageGrid {
grid-template-columns: repeat(2, 1fr);
}
.languageItem:nth-child(4n) {
border-right: 2px solid var(--mantine-color-gray-3);
}
.languageItem:nth-child(2n) {
border-right: none;
}
}
@media (min-width: 601px) and (max-width: 900px) {
.languageGrid {
grid-template-columns: repeat(3, 1fr);
}
.languageItem:nth-child(4n) {
border-right: 2px solid var(--mantine-color-gray-3);
}
.languageItem:nth-child(3n) {
border-right: none;
}
}
/* Dark theme support */
[data-mantine-color-scheme="dark"] .languageItem {
border-right-color: var(--mantine-color-dark-4);
}
[data-mantine-color-scheme="dark"] .languageItem:nth-child(4n) {
border-right: none;
}
[data-mantine-color-scheme="dark"] .languageItem:nth-child(2n) {
border-right-color: var(--mantine-color-dark-4);
}
[data-mantine-color-scheme="dark"] .languageItem:nth-child(3n) {
border-right-color: var(--mantine-color-dark-4);
}
/* Responsive text visibility */
.languageText {
display: none;
}
@media (min-width: 768px) {
.languageText {
display: inline;
}
}

View File

@@ -0,0 +1,125 @@
import React, { useState } from 'react';
import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../i18n';
import LanguageIcon from '@mui/icons-material/Language';
import styles from './LanguageSelector.module.css';
const LanguageSelector: React.FC = () => {
const { i18n } = useTranslation();
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, setOpened] = useState(false);
const languageOptions = Object.entries(supportedLanguages)
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
label: name,
}));
const handleLanguageChange = (value: string) => {
i18n.changeLanguage(value);
setOpened(false);
};
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages['en-GB'];
return (
<Menu
opened={opened}
onChange={setOpened}
width={600}
position="bottom-start"
offset={8}
>
<Menu.Target>
<Button
variant="subtle"
size="sm"
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
styles={{
root: {
border: 'none',
color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7],
'&:hover': {
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
}
},
label: {
fontSize: '12px',
fontWeight: 500,
}
}}
>
<span className={styles.languageText}>
{currentLanguage}
</span>
</Button>
</Menu.Target>
<Menu.Dropdown
style={{
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]}`,
}}
>
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option) => (
<div
key={option.value}
className={styles.languageItem}
>
<Button
variant="subtle"
size="sm"
fullWidth
onClick={() => handleLanguageChange(option.value)}
styles={{
root: {
borderRadius: '4px',
minHeight: '32px',
padding: '4px 8px',
justifyContent: 'flex-start',
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]
),
'&: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]
),
}
},
label: {
fontSize: '13px',
fontWeight: option.value === i18n.language ? 600 : 400,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
}}
>
{option.label}
</Button>
</div>
))}
</div>
</ScrollArea>
</Menu.Dropdown>
</Menu>
);
};
export default LanguageSelector;

View File

@@ -2,6 +2,7 @@ import React, { useState } from "react";
import {
Paper, Button, Group, Text, Stack, Center, Checkbox, ScrollArea, Box, Tooltip, ActionIcon, Notification
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import AddIcon from "@mui/icons-material/Add";
@@ -28,6 +29,7 @@ const PageEditor: React.FC<PageEditorProps> = ({
downloadUrl,
setDownloadUrl,
}) => {
const { t } = useTranslation();
const [selectedPages, setSelectedPages] = useState<number[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -61,20 +63,20 @@ const PageEditor: React.FC<PageEditorProps> = ({
};
// Example action handlers (replace with real API calls)
const handleRotateLeft = () => setStatus("Rotated left: " + selectedPages.join(", "));
const handleRotateRight = () => setStatus("Rotated right: " + selectedPages.join(", "));
const handleDelete = () => setStatus("Deleted: " + selectedPages.join(", "));
const handleMoveLeft = () => setStatus("Moved left: " + selectedPages.join(", "));
const handleMoveRight = () => setStatus("Moved right: " + selectedPages.join(", "));
const handleSplit = () => setStatus("Split at: " + selectedPages.join(", "));
const handleInsertPageBreak = () => setStatus("Inserted page break at: " + selectedPages.join(", "));
const handleAddFile = () => setStatus("Add file not implemented in demo");
const handleRotateLeft = () => setStatus(t("pageEditor.rotatedLeft", "Rotated left: ") + selectedPages.join(", "));
const handleRotateRight = () => setStatus(t("pageEditor.rotatedRight", "Rotated right: ") + selectedPages.join(", "));
const handleDelete = () => setStatus(t("pageEditor.deleted", "Deleted: ") + selectedPages.join(", "));
const handleMoveLeft = () => setStatus(t("pageEditor.movedLeft", "Moved left: ") + selectedPages.join(", "));
const handleMoveRight = () => setStatus(t("pageEditor.movedRight", "Moved right: ") + selectedPages.join(", "));
const handleSplit = () => setStatus(t("pageEditor.splitAt", "Split at: ") + selectedPages.join(", "));
const handleInsertPageBreak = () => setStatus(t("pageEditor.insertedPageBreak", "Inserted page break at: ") + selectedPages.join(", "));
const handleAddFile = () => setStatus(t("pageEditor.addFileNotImplemented", "Add file not implemented in demo"));
if (!file) {
return (
<Paper shadow="xs" radius="md" p="md">
<Center>
<Text color="dimmed">No PDF loaded. Please upload a PDF to edit.</Text>
<Text color="dimmed">{t("pageEditor.noPdfLoaded", "No PDF loaded. Please upload a PDF to edit.")}</Text>
</Center>
</Paper>
);
@@ -85,14 +87,14 @@ const PageEditor: React.FC<PageEditorProps> = ({
<Group align="flex-start" gap="lg">
{/* Sidebar */}
<Stack w={180} gap="xs">
<Text fw={600} size="lg">PDF Multitool</Text>
<Button onClick={selectAll} fullWidth variant="light">Select All</Button>
<Button onClick={deselectAll} fullWidth variant="light">Deselect All</Button>
<Button onClick={handleUndo} leftSection={<UndoIcon fontSize="small" />} fullWidth disabled={undoStack.length === 0}>Undo</Button>
<Button onClick={handleRedo} leftSection={<RedoIcon fontSize="small" />} fullWidth disabled={redoStack.length === 0}>Redo</Button>
<Button onClick={handleAddFile} leftSection={<AddIcon fontSize="small" />} fullWidth>Add File</Button>
<Button onClick={handleInsertPageBreak} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>Insert Page Break</Button>
<Button onClick={handleSplit} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>Split</Button>
<Text fw={600} size="lg">{t("pageEditor.title", "PDF Multitool")}</Text>
<Button onClick={selectAll} fullWidth variant="light">{t("multiTool.selectAll", "Select All")}</Button>
<Button onClick={deselectAll} fullWidth variant="light">{t("multiTool.deselectAll", "Deselect All")}</Button>
<Button onClick={handleUndo} leftSection={<UndoIcon fontSize="small" />} fullWidth disabled={undoStack.length === 0}>{t("multiTool.undo", "Undo")}</Button>
<Button onClick={handleRedo} leftSection={<RedoIcon fontSize="small" />} fullWidth disabled={redoStack.length === 0}>{t("multiTool.redo", "Redo")}</Button>
<Button onClick={handleAddFile} leftSection={<AddIcon fontSize="small" />} fullWidth>{t("multiTool.addFile", "Add File")}</Button>
<Button onClick={handleInsertPageBreak} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>{t("multiTool.insertPageBreak", "Insert Page Break")}</Button>
<Button onClick={handleSplit} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>{t("multiTool.split", "Split")}</Button>
<Button
component="a"
href={downloadUrl || "#"}
@@ -103,7 +105,7 @@ const PageEditor: React.FC<PageEditorProps> = ({
variant="light"
disabled={!downloadUrl}
>
Download All
{t("multiTool.downloadAll", "Download All")}
</Button>
<Button
component="a"
@@ -115,7 +117,7 @@ const PageEditor: React.FC<PageEditorProps> = ({
variant="light"
disabled={!downloadUrl || selectedPages.length === 0}
>
Download Selected
{t("multiTool.downloadSelected", "Download Selected")}
</Button>
<Button
color="red"
@@ -123,34 +125,34 @@ const PageEditor: React.FC<PageEditorProps> = ({
onClick={() => setFile && setFile(null)}
fullWidth
>
Close PDF
{t("pageEditor.closePdf", "Close PDF")}
</Button>
</Stack>
{/* Main multitool area */}
<Box style={{ flex: 1 }}>
<Group mb="sm">
<Tooltip label="Rotate Left">
<Tooltip label={t("multiTool.rotateLeft", "Rotate Left")}>
<ActionIcon onClick={handleRotateLeft} disabled={selectedPages.length === 0} color="blue" variant="light">
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Right">
<Tooltip label={t("multiTool.rotateRight", "Rotate Right")}>
<ActionIcon onClick={handleRotateRight} disabled={selectedPages.length === 0} color="blue" variant="light">
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete">
<Tooltip label={t("delete", "Delete")}>
<ActionIcon onClick={handleDelete} disabled={selectedPages.length === 0} color="red" variant="light">
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Move Left">
<Tooltip label={t("multiTool.moveLeft", "Move Left")}>
<ActionIcon onClick={handleMoveLeft} disabled={selectedPages.length === 0} color="gray" variant="light">
<ArrowBackIosNewIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Move Right">
<Tooltip label={t("multiTool.moveRight", "Move Right")}>
<ActionIcon onClick={handleMoveRight} disabled={selectedPages.length === 0} color="gray" variant="light">
<ArrowForwardIosIcon />
</ActionIcon>
@@ -163,7 +165,7 @@ const PageEditor: React.FC<PageEditorProps> = ({
<Checkbox
checked={selectedPages.includes(page)}
onChange={() => togglePage(page)}
label={`Page ${page}`}
label={t("page", "Page") + ` ${page}`}
/>
<Box
w={60}

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { Box, Text, Stack, Button, TextInput, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
type Tool = {
icon: React.ReactNode;
@@ -17,6 +18,7 @@ interface ToolPickerProps {
}
const ToolPicker: React.FC<ToolPickerProps> = ({ selectedToolKey, onSelect, toolRegistry }) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const filteredTools = Object.entries(toolRegistry).filter(([_, { name }]) =>
@@ -26,7 +28,7 @@ const ToolPicker: React.FC<ToolPickerProps> = ({ selectedToolKey, onSelect, tool
return (
<Box >
<TextInput
placeholder="Search tools..."
placeholder={t("toolPicker.searchPlaceholder", "Search tools...")}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
mb="md"
@@ -35,7 +37,7 @@ const ToolPicker: React.FC<ToolPickerProps> = ({ selectedToolKey, onSelect, tool
<Stack align="flex-start">
{filteredTools.length === 0 ? (
<Text c="dimmed" size="sm">
No tools found
{t("toolPicker.noToolsFound", "No tools found")}
</Text>
) : (
filteredTools.map(([id, { icon, name }]) => (

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState, useRef } from "react";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme } from "@mantine/core";
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
import { useTranslation } from "react-i18next";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import FirstPageIcon from "@mui/icons-material/FirstPage";
@@ -25,6 +26,7 @@ const Viewer: React.FC<ViewerProps> = ({
sidebarsVisible,
setSidebarsVisible,
}) => {
const { t } = useTranslation();
const theme = useMantineTheme();
const [numPages, setNumPages] = useState<number>(0);
const [pageImages, setPageImages] = useState<string[]>([]);
@@ -176,13 +178,13 @@ const Viewer: React.FC<ViewerProps> = ({
{!pdfFile ? (
<Center style={{ flex: 1 }}>
<Stack align="center">
<Text c="dimmed">No PDF loaded. Click to upload a PDF.</Text>
<Text c="dimmed">{t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")}</Text>
<Button
component="label"
variant="outline"
color="blue"
>
Choose PDF
{t("viewer.choosePdf", "Choose PDF")}
<input
type="file"
accept="application/pdf"
@@ -209,7 +211,7 @@ const Viewer: React.FC<ViewerProps> = ({
>
<Stack gap="xl" align="center" >
{pageImages.length === 0 && (
<Text color="dimmed">No pages to display.</Text>
<Text color="dimmed">{t("viewer.noPagesToDisplay", "No pages to display.")}</Text>
)}
{dualPage
? Array.from({ length: Math.ceil(pageImages.length / 2) }).map((_, i) => (
@@ -372,7 +374,7 @@ const Viewer: React.FC<ViewerProps> = ({
radius="xl"
onClick={() => setDualPage(v => !v)}
style={{ minWidth: 36 }}
title={dualPage ? "Single Page View" : "Dual Page View"}
title={dualPage ? t("viewer.singlePageView", "Single Page View") : t("viewer.dualPageView", "Dual Page View")}
>
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button>
@@ -383,7 +385,7 @@ const Viewer: React.FC<ViewerProps> = ({
radius="xl"
onClick={() => setSidebarsVisible(!sidebarsVisible)}
style={{ minWidth: 36 }}
title={sidebarsVisible ? "Hide Sidebars" : "Show Sidebars"}
title={sidebarsVisible ? t("viewer.hideSidebars", "Hide Sidebars") : t("viewer.showSidebars", "Show Sidebars")}
>
<ViewSidebarIcon
fontSize="small"
@@ -401,7 +403,7 @@ const Viewer: React.FC<ViewerProps> = ({
radius="xl"
onClick={() => setZoom(z => Math.max(0.1, z - 0.1))}
style={{ minWidth: 32, padding: 0 }}
title="Zoom out"
title={t("viewer.zoomOut", "Zoom out")}
></Button>
<span style={{ minWidth: 40, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
<Button
@@ -411,7 +413,7 @@ const Viewer: React.FC<ViewerProps> = ({
radius="xl"
onClick={() => setZoom(z => Math.min(5, z + 0.1))}
style={{ minWidth: 32, padding: 0 }}
title="Zoom in"
title={t("viewer.zoomIn", "Zoom in")}
>+</Button>
</Group>
</Paper>

View File

@@ -0,0 +1,20 @@
// Re-export react-i18next hook with our custom types
export { useTranslation } from 'react-i18next';
// You can add custom hooks here later if needed
// For example, a hook that returns commonly used translations
import { useTranslation as useI18nTranslation } from 'react-i18next';
export const useCommonTranslations = () => {
const { t } = useI18nTranslation();
return {
submit: t('genericSubmit'),
selectPdf: t('pdfPrompt'),
selectPdfs: t('multiPdfPrompt'),
selectImages: t('imgPrompt'),
loading: t('loading', 'Loading...'), // fallback if not found
error: t('error._value', 'Error'),
success: t('success', 'Success'),
};
};

87
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,87 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
// Define supported languages (based on your existing translations)
export const supportedLanguages = {
'en-GB': 'English (UK)',
'en-US': 'English (US)',
'ar-AR': 'العربية',
'az-AZ': 'Azərbaycan Dili',
'bg-BG': 'Български',
'ca-CA': 'Català',
'cs-CZ': 'Česky',
'da-DK': 'Dansk',
'de-DE': 'Deutsch',
'el-GR': 'Ελληνικά',
'es-ES': 'Español',
'eu-ES': 'Euskara',
'fa-IR': 'فارسی',
'fr-FR': 'Français',
'ga-IE': 'Gaeilge',
'hi-IN': 'हिंदी',
'hr-HR': 'Hrvatski',
'hu-HU': 'Magyar',
'id-ID': 'Bahasa Indonesia',
'it-IT': 'Italiano',
'ja-JP': '日本語',
'ko-KR': '한국어',
'ml-ML': 'മലയാളം',
'nl-NL': 'Nederlands',
'no-NB': 'Norsk',
'pl-PL': 'Polski',
'pt-BR': 'Português (Brasil)',
'pt-PT': 'Português',
'ro-RO': 'Română',
'ru-RU': 'Русский',
'sk-SK': 'Slovensky',
'sl-SI': 'Slovenščina',
'sr-LATN-RS': 'Srpski',
'sv-SE': 'Svenska',
'th-TH': 'ไทย',
'tr-TR': 'Türkçe',
'uk-UA': 'Українська',
'vi-VN': 'Tiếng Việt',
'zh-BO': 'བོད་ཡིག',
'zh-CN': '简体中文',
'zh-TW': '繁體中文',
};
// RTL languages (based on your existing language.direction property)
export const rtlLanguages = ['ar-AR', 'fa-IR'];
i18n
.use(Backend)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: 'en-GB',
debug: process.env.NODE_ENV === 'development',
interpolation: {
escapeValue: false, // React already escapes values
},
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
},
detection: {
order: ['localStorage', 'navigator', 'htmlTag'],
caches: ['localStorage'],
},
react: {
useSuspense: false, // Set to false to avoid suspense issues with SSR
},
});
// Set document direction based on language
i18n.on('languageChanged', (lng) => {
const isRTL = rtlLanguages.includes(lng);
document.documentElement.dir = isRTL ? 'rtl' : 'ltr';
document.documentElement.lang = lng;
});
export default i18n;

View File

@@ -5,6 +5,7 @@ import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/c
import { BrowserRouter } from 'react-router-dom';
import './index.css';
import App from './App';
import './i18n'; // Initialize i18next
const container = document.getElementById('root');

View File

@@ -1,4 +1,5 @@
import React, { useState, useCallback, useEffect } from "react";
import { useTranslation } from 'react-i18next';
import { useSearchParams } from "react-router-dom";
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
import ContentCutIcon from "@mui/icons-material/ContentCut";
@@ -15,6 +16,7 @@ import CompressPdfPanel from "../tools/Compress";
import MergePdfPanel from "../tools/Merge";
import PageEditor from "../components/PageEditor";
import Viewer from "../components/Viewer";
import LanguageSelector from "../components/LanguageSelector";
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
@@ -29,10 +31,11 @@ type ToolRegistry = {
[key: string]: ToolRegistryEntry;
};
const toolRegistry: ToolRegistry = {
split: { icon: <ContentCutIcon />, name: "Split PDF", component: SplitPdfPanel, view: "viewer" },
compress: { icon: <ZoomInMapIcon />, name: "Compress PDF", component: CompressPdfPanel, view: "viewer" },
merge: { icon: <AddToPhotosIcon />, name: "Merge PDFs", component: MergePdfPanel, view: "fileManager" },
// Base tool registry without translations
const baseToolRegistry = {
split: { icon: <ContentCutIcon />, component: SplitPdfPanel, view: "viewer" },
compress: { icon: <ZoomInMapIcon />, component: CompressPdfPanel, view: "viewer" },
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "fileManager" },
};
const VIEW_OPTIONS = [
@@ -152,10 +155,18 @@ const TOOL_PARAMS = {
};
export default function HomePage() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const theme = useMantineTheme();
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
// Create translated tool registry
const toolRegistry: ToolRegistry = {
split: { ...baseToolRegistry.split, name: t("home.split.title", "Split PDF") },
compress: { ...baseToolRegistry.compress, name: t("home.compressPdfs.title", "Compress PDF") },
merge: { ...baseToolRegistry.merge, name: t("home.merge.title", "Merge PDFs") },
};
// Core app state
const [selectedToolKey, setSelectedToolKey] = useState<string>(searchParams.get("tool") || "split");
const [currentView, setCurrentView] = useState<string>(searchParams.get("view") || "viewer");
@@ -286,6 +297,9 @@ export default function HomePage() {
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "auto",
display: "flex",
gap: 12,
alignItems: "center",
}}
>
<Button
@@ -296,6 +310,7 @@ export default function HomePage() {
>
{colorScheme === "dark" ? <LightModeIcon /> : <DarkModeIcon />}
</Button>
<LanguageSelector />
</div>
<div
style={{
@@ -395,7 +410,7 @@ export default function HomePage() {
style={{ position: "fixed", top: 16, right: 16, zIndex: 200 }}
onClick={() => setSidebarsVisible((v) => !v)}
>
{sidebarsVisible ? "Hide Sidebars" : "Show Sidebars"}
{t("sidebar.toggle", sidebarsVisible ? "Hide Sidebars" : "Show Sidebars")}
</Button>
</Group>
);

View File

@@ -1,5 +1,6 @@
import React, { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Stack, Slider, Group, Text, Button, Checkbox, TextInput, Paper } from "@mantine/core";
export interface CompressProps {
@@ -13,6 +14,7 @@ const CompressPdfPanel: React.FC<CompressProps> = ({
setDownloadUrl,
setLoading,
}) => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
@@ -63,9 +65,9 @@ const CompressPdfPanel: React.FC<CompressProps> = ({
return (
<Stack>
<Text fw={500} mb={4}>Select files to compress:</Text>
<Text fw={500} mb={4}>{t("multiPdfDropPrompt", "Select files to compress:")}</Text>
<Stack gap={4}>
{files.length === 0 && <Text c="dimmed" size="sm">No files loaded.</Text>}
{files.length === 0 && <Text c="dimmed" size="sm">{t("noFileSelected")}</Text>}
{files.map((file, idx) => (
<Checkbox
key={file.name + idx}
@@ -76,7 +78,7 @@ const CompressPdfPanel: React.FC<CompressProps> = ({
))}
</Stack>
<Stack gap={4} mb={14}>
<Text size="sm" style={{ minWidth: 140 }}>Compression Level</Text>
<Text size="sm" style={{ minWidth: 140 }}>{t("compress.selectText.2", "Compression Level")}</Text>
<Slider
min={1}
max={9}
@@ -92,23 +94,23 @@ const CompressPdfPanel: React.FC<CompressProps> = ({
/>
</Stack>
<Checkbox
label="Convert images to grayscale"
label={t("compress.grayscale.label", "Convert images to grayscale")}
checked={grayscale}
onChange={e => setGrayscale(e.currentTarget.checked)}
/>
<Checkbox
label="Remove PDF metadata"
label={t("removeMetadata.submit", "Remove PDF metadata")}
checked={removeMetadata}
onChange={e => setRemoveMetadata(e.currentTarget.checked)}
/>
<Checkbox
label="Aggressive compression (may reduce quality)"
label={t("compress.selectText.1.1", "Aggressive compression (may reduce quality)")}
checked={aggressive}
onChange={e => setAggressive(e.currentTarget.checked)}
/>
<TextInput
label="Expected output size (e.g. 2MB, 500KB)"
placeholder="Optional"
label={t("compress.selectText.5", "Expected output size")}
placeholder={t("compress.selectText.5", "e.g. 25MB, 10.8MB, 25KB")}
value={expectedSize}
onChange={e => setExpectedSize(e.currentTarget.value)}
/>
@@ -119,7 +121,7 @@ const CompressPdfPanel: React.FC<CompressProps> = ({
fullWidth
mt="md"
>
Compress Selected PDF{selected.filter(Boolean).length > 1 ? "s" : ""}
{t("compress.submit", "Compress")} {t("pdfPrompt", "PDF")}{selected.filter(Boolean).length > 1 ? "s" : ""}
</Button>
</Stack>
);

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from "react";
import { Paper, Button, Checkbox, Stack, Text, Group, Loader, Alert } from "@mantine/core";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
export interface MergePdfPanelProps {
files: File[];
@@ -18,6 +19,7 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
params,
updateParams,
}) => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [selectedFiles, setSelectedFiles] = useState<boolean[]>([]);
const [downloadUrl, setLocalDownloadUrl] = useState<string | null>(null);
@@ -31,7 +33,7 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
const handleMerge = async () => {
const filesToMerge = files.filter((_, index) => selectedFiles[index]);
if (filesToMerge.length < 2) {
setErrorMessage("Please select at least two PDFs to merge.");
setErrorMessage(t("multiPdfPrompt")); // "Select PDFs (2+)"
return;
}
@@ -75,7 +77,7 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
return (
<Stack>
<Text fw={500} size="lg">Merge PDFs</Text>
<Text fw={500} size="lg">{t("merge.header")}</Text>
<Stack gap={4}>
{files.map((file, index) => (
<Group key={index} gap="xs">
@@ -89,7 +91,7 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
</Stack>
{selectedCount < 2 && (
<Text size="sm" c="red">
Please select at least two PDFs to merge.
{t("multiPdfPrompt")}
</Text>
)}
<Button
@@ -98,7 +100,7 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
disabled={selectedCount < 2 || isLoading}
mt="md"
>
Merge PDFs
{t("merge.submit")}
</Button>
{errorMessage && (
<Alert color="red" mt="sm">
@@ -114,11 +116,11 @@ const MergePdfPanel: React.FC<MergePdfPanelProps> = ({
variant="light"
mt="md"
>
Download Merged PDF
{t("downloadPdf")}
</Button>
)}
<Checkbox
label="Remove Duplicates"
label={t("merge.removeCertSign")}
checked={removeDuplicates}
onChange={() => updateParams({ removeDuplicates: !removeDuplicates })}
/>

View File

@@ -10,6 +10,7 @@ import {
Paper,
} from "@mantine/core";
import { useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import DownloadIcon from "@mui/icons-material/Download";
export interface SplitPdfPanelProps {
@@ -38,6 +39,7 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
params,
updateParams,
}) => {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [status, setStatus] = useState("");
@@ -61,7 +63,7 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) {
setStatus("Please upload a PDF first.");
setStatus(t("noFileSelected"));
return;
}
@@ -99,7 +101,7 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
return;
}
setStatus("Processing split...");
setStatus(t("loading"));
setIsLoading(true);
setErrorMessage(null);
@@ -108,13 +110,13 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
const blob = new Blob([response.data], { type: "application/zip" });
const url = window.URL.createObjectURL(blob);
setDownloadUrl(url);
setStatus("Download ready.");
setStatus(t("downloadComplete"));
} catch (error: any) {
console.error(error);
setErrorMessage(
error.response?.data || "An error occurred while splitting the PDF."
error.response?.data || t("error.pdfPassword", "An error occurred while splitting the PDF.")
);
setStatus("Split failed.");
setStatus(t("error._value", "Split failed."));
} finally {
setIsLoading(false);
}
@@ -124,21 +126,21 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
<form onSubmit={handleSubmit}>
<Stack gap="sm" mb={16}>
<Select
label="Split Mode"
label={t("split-by-size-or-count.type.label", "Split Mode")}
value={mode}
onChange={(v) => v && updateParams({ mode: v })}
data={[
{ value: "byPages", label: "Split by Pages (e.g. 1,3,5-10)" },
{ value: "bySections", label: "Split by Grid Sections" },
{ value: "bySizeOrCount", label: "Split by Size or Count" },
{ value: "byChapters", label: "Split by Chapters" },
{ value: "byPages", label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
{ value: "bySections", label: t("split-by-sections.title", "Split by Grid Sections") },
{ value: "bySizeOrCount", label: t("split-by-size-or-count.title", "Split by Size or Count") },
{ value: "byChapters", label: t("splitByChapters.title", "Split by Chapters") },
]}
/>
{mode === "byPages" && (
<TextInput
label="Pages"
placeholder="e.g. 1,3,5-10"
label={t("split.splitPages", "Pages")}
placeholder={t("pageSelectionPrompt", "e.g. 1,3,5-10")}
value={pages}
onChange={(e) => updateParams({ pages: e.target.value })}
/>
@@ -147,23 +149,25 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
{mode === "bySections" && (
<Stack gap="sm">
<TextInput
label="Horizontal Divisions"
label={t("split-by-sections.horizontal.label", "Horizontal Divisions")}
type="number"
min="0"
max="300"
value={hDiv}
onChange={(e) => updateParams({ hDiv: e.target.value })}
placeholder={t("split-by-sections.horizontal.placeholder", "Enter number of horizontal divisions")}
/>
<TextInput
label="Vertical Divisions"
label={t("split-by-sections.vertical.label", "Vertical Divisions")}
type="number"
min="0"
max="300"
value={vDiv}
onChange={(e) => updateParams({ vDiv: e.target.value })}
placeholder={t("split-by-sections.vertical.placeholder", "Enter number of vertical divisions")}
/>
<Checkbox
label="Merge sections into one PDF"
label={t("split-by-sections.merge", "Merge sections into one PDF")}
checked={merge}
onChange={(e) => updateParams({ merge: e.currentTarget.checked })}
/>
@@ -173,18 +177,18 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
{mode === "bySizeOrCount" && (
<Stack gap="sm">
<Select
label="Split Type"
label={t("split-by-size-or-count.type.label", "Split Type")}
value={splitType}
onChange={(v) => v && updateParams({ splitType: v })}
data={[
{ value: "size", label: "By Size" },
{ value: "pages", label: "By Page Count" },
{ value: "docs", label: "By Document Count" },
{ value: "size", label: t("split-by-size-or-count.type.size", "By Size") },
{ value: "pages", label: t("split-by-size-or-count.type.pageCount", "By Page Count") },
{ value: "docs", label: t("split-by-size-or-count.type.docCount", "By Document Count") },
]}
/>
<TextInput
label="Split Value"
placeholder="e.g. 10MB or 5 pages"
label={t("split-by-size-or-count.value.label", "Split Value")}
placeholder={t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages")}
value={splitValue}
onChange={(e) => updateParams({ splitValue: e.target.value })}
/>
@@ -194,18 +198,18 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
{mode === "byChapters" && (
<Stack gap="sm">
<TextInput
label="Bookmark Level"
label={t("splitByChapters.bookmarkLevel", "Bookmark Level")}
type="number"
value={bookmarkLevel}
onChange={(e) => updateParams({ bookmarkLevel: e.target.value })}
/>
<Checkbox
label="Include Metadata"
label={t("splitByChapters.includeMetadata", "Include Metadata")}
checked={includeMetadata}
onChange={(e) => updateParams({ includeMetadata: e.currentTarget.checked })}
/>
<Checkbox
label="Allow Duplicate Bookmarks"
label={t("splitByChapters.allowDuplicates", "Allow Duplicate Bookmarks")}
checked={allowDuplicates}
onChange={(e) => updateParams({ allowDuplicates: e.currentTarget.checked })}
/>
@@ -213,18 +217,18 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
)}
<Button type="submit" loading={isLoading} fullWidth>
{isLoading ? "Processing..." : "Split PDF"}
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
</Button>
{status && <p className="text-xs text-gray-600">{status}</p>}
{errorMessage && (
<Notification color="red" title="Error" onClose={() => setErrorMessage(null)}>
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)}>
{errorMessage}
</Notification>
)}
{status === "Download ready." && downloadUrl && (
{status === t("downloadComplete") && downloadUrl && (
<Button
component="a"
href={downloadUrl}
@@ -233,7 +237,7 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
color="green"
fullWidth
>
Download Split PDF
{t("downloadPdf", "Download Split PDF")}
</Button>
)}
</Stack>