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

File diff suppressed because one or more lines are too long

99
frontend/dist/assets/index-C7ZkpjP3.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -13,8 +13,8 @@
<link rel="manifest" href="/manifest.json" />
<title>Vite App</title>
<script type="module" crossorigin src="/assets/index-DAILjWej.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DxdJ27yt.css">
<script type="module" crossorigin src="/assets/index-C7ZkpjP3.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CEZrmp6h.css">
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -22,9 +22,13 @@
"@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.21",
"axios": "^1.9.0",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"pdfjs-dist": "^3.11.174",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.2",
"react-router-dom": "^7.6.0",
"tailwindcss": "^4.1.8",
"web-vitals": "^2.1.4"
@ -2693,6 +2697,14 @@
"node": ">= 6"
}
},
"node_modules/cross-fetch": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz",
"integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==",
"dependencies": {
"node-fetch": "^2.6.12"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@ -3353,6 +3365,14 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@ -3367,6 +3387,52 @@
"node": ">= 6"
}
},
"node_modules/i18next": {
"version": "25.2.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.2.1.tgz",
"integrity": "sha512-+UoXK5wh+VlE1Zy5p6MjcvctHXAhRwQKCxiJD8noKZzIXmnAX8gdHX5fLPA3MEVxEN4vbZkQFy8N0LyD9tUqPw==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"dependencies": {
"@babel/runtime": "^7.27.1"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.1.0.tgz",
"integrity": "sha512-mHZxNx1Lq09xt5kCauZ/4bsXOEA2pfpwSoU11/QTJB+pD94iONFwp+ohqi///PwiFvjFOxe1akYCdHyFo1ng5Q==",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-http-backend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz",
"integrity": "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==",
"dependencies": {
"cross-fetch": "4.0.0"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@ -4036,7 +4102,6 @@
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
@ -4056,22 +4121,19 @@
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause",
"optional": true
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
@ -4711,6 +4773,31 @@
"react": ">= 16.8 || 18.0.0"
}
},
"node_modules/react-i18next": {
"version": "15.5.2",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.2.tgz",
"integrity": "sha512-ePODyXgmZQAOYTbZXQn5rRsSBu3Gszo69jxW6aKmlSgxKAI1fOhDwSu6bT4EKHciWPKQ7v7lPrjeiadR6Gi+1A==",
"dependencies": {
"@babel/runtime": "^7.25.0",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz",
@ -5430,7 +5517,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@ -5678,6 +5765,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/web-vitals": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz",

View File

@ -18,9 +18,13 @@
"@testing-library/user-event": "^13.5.0",
"autoprefixer": "^10.4.21",
"axios": "^1.9.0",
"i18next": "^25.2.1",
"i18next-browser-languagedetector": "^8.1.0",
"i18next-http-backend": "^3.0.2",
"pdfjs-dist": "^3.11.174",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.2",
"react-router-dom": "^7.6.0",
"tailwindcss": "^4.1.8",
"web-vitals": "^2.1.4"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
{}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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');

Some files were not shown because too many files have changed in this diff Show More