Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7912fec5ec | ||
|
|
06b4c147bd | ||
|
|
25154e4dbe | ||
|
|
989eea9e24 | ||
|
|
510e1c38eb | ||
|
|
ec05c5c049 | ||
|
|
d86a13cc89 | ||
|
|
85dedf4b28 | ||
|
|
0fa53185f2 |
@@ -192,6 +192,11 @@ return useToolOperation({
|
||||
- **Preview System**: Tool results can be previewed without polluting file context (Split tool example)
|
||||
- **Performance**: Web Worker thumbnails, IndexedDB persistence, background processing
|
||||
|
||||
## Translation Rules
|
||||
|
||||
- **CRITICAL**: Always update translations in `en-GB` only, never `en-US`
|
||||
- Translation files are located in `frontend/public/locales/`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
|
||||
|
||||
@@ -115,45 +115,45 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
|
||||
@@ -1916,6 +1916,7 @@ fileManager.storageError=Storage error occurred
|
||||
fileManager.storageLow=Storage is running low. Consider removing old files.
|
||||
fileManager.uploadError=Failed to upload some files.
|
||||
fileManager.supportMessage=Powered by browser database storage for unlimited capacity
|
||||
fileManager.loadingFiles=Loading files...
|
||||
|
||||
# Page Editor
|
||||
pageEditor.title=Page Editor
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -255,6 +255,22 @@
|
||||
"cacheInputs": {
|
||||
"name": "Save form inputs",
|
||||
"help": "Enable to store previously used inputs for future runs"
|
||||
},
|
||||
"hotkeys": {
|
||||
"title": "Keyboard Shortcuts",
|
||||
"description": "Hover a tool to see its shortcut or customise it below. Click \"Change shortcut\" and press a new key combination. Press Esc to cancel.",
|
||||
"errorModifier": {
|
||||
"mac": "Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.",
|
||||
"windows": "Include Ctrl, Alt, or another modifier in your shortcut."
|
||||
},
|
||||
"errorConflict": "Shortcut already used by {{tool}}.",
|
||||
"none": "Not assigned",
|
||||
"customBadge": "Custom",
|
||||
"defaultLabel": "Default: {{shortcut}}",
|
||||
"capturing": "Press keys… (Esc to cancel)",
|
||||
"change": "Change shortcut",
|
||||
"reset": "Reset",
|
||||
"shortcut": "Shortcut"
|
||||
}
|
||||
},
|
||||
"changeCreds": {
|
||||
@@ -868,6 +884,7 @@
|
||||
"rotate": {
|
||||
"title": "Rotate PDF",
|
||||
"submit": "Apply Rotation",
|
||||
"selectRotation": "Select Rotation Angle (Clockwise)",
|
||||
"error": {
|
||||
"failed": "An error occurred while rotating the PDF."
|
||||
},
|
||||
@@ -3062,7 +3079,12 @@
|
||||
"panMode": "Pan Mode",
|
||||
"rotateLeft": "Rotate Left",
|
||||
"rotateRight": "Rotate Right",
|
||||
"toggleSidebar": "Toggle Sidebar"
|
||||
"toggleSidebar": "Toggle Sidebar",
|
||||
"exportSelected": "Export Selected Pages",
|
||||
"toggleAnnotations": "Toggle Annotations Visibility",
|
||||
"annotationMode": "Toggle Annotation Mode",
|
||||
"draw": "Draw",
|
||||
"save": "Save"
|
||||
},
|
||||
"search": {
|
||||
"title": "Search PDF",
|
||||
@@ -3131,6 +3153,9 @@
|
||||
"addFiles": "Add Files",
|
||||
"dragFilesInOrClick": "Drag files in or click \"Add Files\" to browse"
|
||||
},
|
||||
"fileEditor": {
|
||||
"addFiles": "Add Files"
|
||||
},
|
||||
"fileManager": {
|
||||
"title": "Upload PDF Files",
|
||||
"subtitle": "Add files to your storage for easy access across tools",
|
||||
@@ -3166,7 +3191,6 @@
|
||||
"googleDriveShort": "Drive",
|
||||
"myFiles": "My Files",
|
||||
"noRecentFiles": "No recent files found",
|
||||
"dropFilesHint": "Drop files here to upload",
|
||||
"googleDriveNotAvailable": "Google Drive integration not available",
|
||||
"openFiles": "Open Files",
|
||||
"openFile": "Open File",
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"save": "Guardar",
|
||||
"saveToBrowser": "Guardar en el navegador",
|
||||
"close": "Cerrar",
|
||||
"filesSelected": "archivos seleccionados",
|
||||
"filesSelected": "{{count}} archivos seleccionados",
|
||||
"noFavourites": "No se agregaron favoritos",
|
||||
"downloadComplete": "Descarga completada",
|
||||
"bored": "¿Aburrido de esperar?",
|
||||
|
||||
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
+10
-7
@@ -4,6 +4,7 @@ import { FileContextProvider } from "./contexts/FileContext";
|
||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||
import { FilesModalProvider } from "./contexts/FilesModalContext";
|
||||
import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "./contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "./contexts/SidebarContext";
|
||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||
import HomePage from "./pages/HomePage";
|
||||
@@ -44,15 +45,17 @@ export default function App() {
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<HomePage />
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</SignatureProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
|
||||
@@ -20,7 +20,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile } = useFileManager();
|
||||
const { loadRecentFiles, handleRemoveFile, loading } = useFileManager();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
@@ -123,7 +123,6 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onDrop={handleNewFileUpload}
|
||||
onDragEnter={() => setIsDragging(true)}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
accept={{}}
|
||||
multiple={true}
|
||||
activateOnClick={false}
|
||||
style={{
|
||||
@@ -147,6 +146,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight={modalHeight}
|
||||
refreshRecentFiles={refreshRecentFiles}
|
||||
isLoading={loading}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
</FileManagerProvider>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
import styles from './FileEditor.module.css';
|
||||
|
||||
interface AddFileCardProps {
|
||||
onFileSelect: (files: File[]) => void;
|
||||
accept?: string;
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const AddFileCard = ({
|
||||
onFileSelect,
|
||||
accept,
|
||||
multiple = true
|
||||
}: AddFileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
|
||||
const handleCardClick = () => {
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleNativeUploadClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleOpenFilesModal = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
onFileSelect(files);
|
||||
}
|
||||
// Reset input so same files can be selected again
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`${styles.addFileCard} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative cursor-pointer`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t('fileEditor.addFiles', 'Add files')}
|
||||
onClick={handleCardClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleCardClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Header bar - matches FileEditorThumbnail structure */}
|
||||
<div className={`${styles.header} ${styles.addFileHeader}`}>
|
||||
<div className={styles.logoMark}>
|
||||
<AddIcon sx={{ color: 'inherit', fontSize: '1.5rem' }} />
|
||||
</div>
|
||||
<div className={styles.headerIndex}>
|
||||
{t('fileEditor.addFiles', 'Add Files')}
|
||||
</div>
|
||||
<div className={styles.kebab} />
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className={styles.addFileContent}>
|
||||
{/* Stirling PDF Branding */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Add Files + Native Upload Buttons - styled like LandingPage */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.6rem',
|
||||
width: '100%',
|
||||
marginTop: '0.8rem',
|
||||
marginBottom: '0.8rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 58px - 0.6rem)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFileCard;
|
||||
@@ -304,4 +304,84 @@
|
||||
/* Light mode selected header stroke override */
|
||||
:global([data-mantine-color-scheme="light"]) .card[data-selected="true"] {
|
||||
outline-color: #3B4B6E;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Add File Card Styles
|
||||
========================= */
|
||||
|
||||
.addFileCard {
|
||||
background: var(--file-card-bg);
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: 0.0625rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s ease;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.addFileCard:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--color-blue-500);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.addFileCard:focus {
|
||||
outline: 2px solid var(--color-blue-500);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.addFileHeader {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-secondary);
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileHeader {
|
||||
background: var(--color-blue-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.addFileContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.addFileIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-subtle);
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileIcon {
|
||||
background: var(--color-blue-50);
|
||||
}
|
||||
|
||||
.addFileText {
|
||||
font-weight: 500;
|
||||
transition: color 0.18s ease;
|
||||
}
|
||||
|
||||
.addFileCard:hover .addFileText {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addFileSubtext {
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { zipFileService } from '../../services/zipFileService';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import AddFileCard from './AddFileCard';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
@@ -171,8 +172,8 @@ const FileEditor = ({
|
||||
|
||||
// Process all extracted files
|
||||
if (allExtractedFiles.length > 0) {
|
||||
// Add files to context (they will be processed automatically)
|
||||
await addFiles(allExtractedFiles);
|
||||
// Add files to context and select them automatically
|
||||
await addFiles(allExtractedFiles, { selectFiles: true });
|
||||
showStatus(`Added ${allExtractedFiles.length} files`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -405,6 +406,14 @@ const FileEditor = ({
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{/* Add File Card - only show when files exist */}
|
||||
{activeStirlingFileStubs.length > 0 && (
|
||||
<AddFileCard
|
||||
key="add-file-card"
|
||||
onFileSelect={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
|
||||
@@ -324,7 +324,7 @@ const FileEditorThumbnail = ({
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
|
||||
<Text size="lg" fw={700} className={`${styles.title} ph-no-capture `} lineClamp={2}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text
|
||||
@@ -350,6 +350,7 @@ const FileEditorThumbnail = ({
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Group, Text, Stack, useMantineColorScheme } from '@mantine/core';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import LocalIcon from '../shared/LocalIcon';
|
||||
import { BASE_PATH } from '../../constants/app';
|
||||
|
||||
const EmptyFilesState: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { onLocalFileClick } = useFileManagerContext();
|
||||
const [isUploadHover, setIsUploadHover] = useState(false);
|
||||
|
||||
const handleUploadClick = () => {
|
||||
onLocalFileClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2rem'
|
||||
}}
|
||||
>
|
||||
{/* Container */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
padding: '3rem 2rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5rem',
|
||||
minWidth: '20rem',
|
||||
maxWidth: '28rem',
|
||||
width: '100%'
|
||||
}}
|
||||
>
|
||||
{/* No Recent Files Message */}
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<Text c="dimmed" ta="center" size="lg">
|
||||
{t('fileManager.noRecentFiles', 'No recent files')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{/* Stirling PDF Logo */}
|
||||
<Group gap="xs" align="center">
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoWhiteText.svg` : `${BASE_PATH}/branding/StirlingPDFLogoGreyText.svg`}
|
||||
alt="Stirling PDF"
|
||||
style={{ height: '2.2rem', width: 'auto' }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Upload Button */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem'
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-file-manager)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: isUploadHover ? '2rem' : '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? '100%' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease, border-radius .5s ease'
|
||||
}}
|
||||
onClick={handleUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: '.8rem', textAlign: 'center' }}
|
||||
>
|
||||
{t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyFilesState;
|
||||
@@ -26,7 +26,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
<ScrollArea style={{ flex: 1 }} p="md">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||
<Text className='ph-no-capture' size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
|
||||
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
|
||||
{currentFile ? currentFile.name : ''}
|
||||
</Text>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Center, ScrollArea, Text, Stack } from '@mantine/core';
|
||||
import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileListItem from './FileListItem';
|
||||
import FileHistoryGroup from './FileHistoryGroup';
|
||||
import EmptyFilesState from './EmptyFilesState';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
|
||||
interface FileListAreaProps {
|
||||
@@ -29,6 +29,7 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
isLoading,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -43,15 +44,11 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
scrollbarSize={8}
|
||||
>
|
||||
<Stack gap={0}>
|
||||
{recentFiles.length === 0 ? (
|
||||
{recentFiles.length === 0 && !isLoading ? (
|
||||
<EmptyFilesState />
|
||||
) : recentFiles.length === 0 && isLoading ? (
|
||||
<Center style={{ height: '12.5rem' }}>
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
|
||||
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
|
||||
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
|
||||
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Text c="dimmed" ta="center">{t('fileManager.loadingFiles', 'Loading files...')}</Text>
|
||||
</Center>
|
||||
) : (
|
||||
filteredFiles.map((file, index) => {
|
||||
|
||||
@@ -93,7 +93,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Text size="sm" fw={500} className='ph-no-capture' truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
<Badge size="xs" variant="light" color={"blue"}>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
|
||||
@@ -9,7 +9,6 @@ const HiddenFileInput: React.FC = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={true}
|
||||
accept={["*/*"] as any}
|
||||
onChange={onFileInputChange}
|
||||
style={{ display: 'none' }}
|
||||
data-testid="file-input"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import React from 'react';
|
||||
import { HotkeyBinding } from '../../utils/hotkeys';
|
||||
import { useHotkeys } from '../../contexts/HotkeyContext';
|
||||
|
||||
interface HotkeyDisplayProps {
|
||||
binding: HotkeyBinding | null | undefined;
|
||||
size?: 'sm' | 'md';
|
||||
muted?: boolean;
|
||||
}
|
||||
|
||||
const baseKeyStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '0.375rem',
|
||||
background: 'var(--mantine-color-gray-1)',
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: '0.125rem 0.35rem',
|
||||
fontSize: '0.75rem',
|
||||
lineHeight: 1,
|
||||
fontFamily: 'var(--mantine-font-family-monospace, monospace)',
|
||||
minWidth: '1.35rem',
|
||||
color: 'var(--mantine-color-text)',
|
||||
};
|
||||
|
||||
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = 'sm', muted = false }) => {
|
||||
const { getDisplayParts } = useHotkeys();
|
||||
const parts = getDisplayParts(binding);
|
||||
|
||||
if (!binding || parts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyStyle = size === 'md'
|
||||
? { ...baseKeyStyle, fontSize: '0.85rem', padding: '0.2rem 0.5rem' }
|
||||
: baseKeyStyle;
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
color: muted ? 'var(--mantine-color-dimmed)' : 'inherit',
|
||||
fontWeight: muted ? 500 : 600,
|
||||
}}
|
||||
>
|
||||
{parts.map((part, index) => (
|
||||
<React.Fragment key={`${part}-${index}`}>
|
||||
<kbd style={keyStyle}>{part}</kbd>
|
||||
{index < parts.length - 1 && <span aria-hidden style={{ fontWeight: 400 }}>+</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeyDisplay;
|
||||
@@ -317,6 +317,7 @@ const FileThumbnail = ({
|
||||
>
|
||||
{file.thumbnail && (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
|
||||
@@ -371,6 +371,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
</div>
|
||||
) : thumbnailUrl ? (
|
||||
<img
|
||||
className="ph-no-capture"
|
||||
src={thumbnailUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
draggable={false}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
/* AppConfigModal styles */
|
||||
.modal-container {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
height: 37.5rem; /* 600px */
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 15rem; /* 240px */
|
||||
height: 37.5rem; /* 600px */
|
||||
border-top-left-radius: 0.75rem; /* 12px */
|
||||
border-bottom-left-radius: 0.75rem; /* 12px */
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile: compact icon-only navigation */
|
||||
@media (max-width: 1024px) {
|
||||
.modal-container {
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.modal-nav {
|
||||
width: 5rem; /* 80px - wider for larger icons */
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item.mobile {
|
||||
padding: 1rem;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
height: 100vh !important;
|
||||
max-height: none !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-nav-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
padding-bottom: 2rem;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-nav-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-nav-section {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.modal-nav-section-items {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-nav-item {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.625rem; /* 8px 10px */
|
||||
border-radius: 0.5rem; /* 8px */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem; /* 4px */
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
height: 37.5rem; /* 600px */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-content-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.modal-content-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.confirm-modal-buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import React from 'react';
|
||||
import { Modal, Button, Stack, Text, Code, ScrollArea, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||
import { useAppConfig } from '../../hooks/useAppConfig';
|
||||
import React, { useMemo, useState, useEffect } from 'react';
|
||||
import { Modal, Text, ActionIcon } from '@mantine/core';
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
import LocalIcon from './LocalIcon';
|
||||
import Overview from './config/configSections/Overview';
|
||||
import { createConfigNavSections } from './config/configNavSections';
|
||||
import { NavKey } from './config/types';
|
||||
import './AppConfigModal.css';
|
||||
|
||||
interface AppConfigModalProps {
|
||||
opened: boolean;
|
||||
@@ -8,131 +13,143 @@ interface AppConfigModalProps {
|
||||
}
|
||||
|
||||
const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const { config, loading, error, refetch } = useAppConfig();
|
||||
const [active, setActive] = useState<NavKey>('overview');
|
||||
const isMobile = useMediaQuery("(max-width: 1024px)");
|
||||
|
||||
const renderConfigSection = (title: string, data: any) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
useEffect(() => {
|
||||
const handler = (ev: Event) => {
|
||||
const detail = (ev as CustomEvent).detail as { key?: NavKey } | undefined;
|
||||
if (detail?.key) {
|
||||
setActive(detail.key);
|
||||
}
|
||||
};
|
||||
window.addEventListener('appConfig:navigate', handler as EventListener);
|
||||
return () => window.removeEventListener('appConfig:navigate', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack gap="xs" mb="md">
|
||||
<Text fw={600} size="md" c="blue">{title}</Text>
|
||||
<Stack gap="xs" pl="md">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Group key={key} wrap="nowrap" align="flex-start">
|
||||
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
|
||||
{key}:
|
||||
</Text>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Badge color={value ? 'green' : 'red'} size="sm">
|
||||
{value ? 'true' : 'false'}
|
||||
</Badge>
|
||||
) : typeof value === 'object' ? (
|
||||
<Code block>{JSON.stringify(value, null, 2)}</Code>
|
||||
) : (
|
||||
String(value) || 'null'
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
const colors = useMemo(() => ({
|
||||
navBg: 'var(--modal-nav-bg)',
|
||||
sectionTitle: 'var(--modal-nav-section-title)',
|
||||
navItem: 'var(--modal-nav-item)',
|
||||
navItemActive: 'var(--modal-nav-item-active)',
|
||||
navItemActiveBg: 'var(--modal-nav-item-active-bg)',
|
||||
contentBg: 'var(--modal-content-bg)',
|
||||
headerBorder: 'var(--modal-header-border)',
|
||||
}), []);
|
||||
|
||||
// Placeholder logout handler (not needed in open-source but keeps SaaS compatibility)
|
||||
const handleLogout = () => {
|
||||
// In SaaS this would sign out, in open-source it does nothing
|
||||
console.log('Logout placeholder for SaaS compatibility');
|
||||
};
|
||||
|
||||
const basicConfig = config ? {
|
||||
appName: config.appName,
|
||||
appNameNavbar: config.appNameNavbar,
|
||||
baseUrl: config.baseUrl,
|
||||
contextPath: config.contextPath,
|
||||
serverPort: config.serverPort,
|
||||
} : null;
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useMemo(() =>
|
||||
createConfigNavSections(
|
||||
Overview,
|
||||
handleLogout
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const securityConfig = config ? {
|
||||
enableLogin: config.enableLogin,
|
||||
} : null;
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.label;
|
||||
}
|
||||
return '';
|
||||
}, [configNavSections, active]);
|
||||
|
||||
const systemConfig = config ? {
|
||||
enableAlphaFunctionality: config.enableAlphaFunctionality,
|
||||
enableAnalytics: config.enableAnalytics,
|
||||
} : null;
|
||||
|
||||
const premiumConfig = config ? {
|
||||
premiumEnabled: config.premiumEnabled,
|
||||
premiumKey: config.premiumKey ? '***hidden***' : null,
|
||||
runningProOrHigher: config.runningProOrHigher,
|
||||
runningEE: config.runningEE,
|
||||
license: config.license,
|
||||
} : null;
|
||||
|
||||
const integrationConfig = config ? {
|
||||
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
const legalConfig = config ? {
|
||||
termsAndConditions: config.termsAndConditions,
|
||||
privacyPolicy: config.privacyPolicy,
|
||||
cookiePolicy: config.cookiePolicy,
|
||||
impressum: config.impressum,
|
||||
accessibilityStatement: config.accessibilityStatement,
|
||||
} : null;
|
||||
const activeComponent = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
const found = section.items.find(i => i.key === active);
|
||||
if (found) return found.component;
|
||||
}
|
||||
return null;
|
||||
}, [configNavSections, active]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="App Configuration (Testing)"
|
||||
size="lg"
|
||||
title={null}
|
||||
size={isMobile ? "100%" : 980}
|
||||
centered
|
||||
radius="lg"
|
||||
withCloseButton={false}
|
||||
style={{ zIndex: 1000 }}
|
||||
overlayProps={{ opacity: 0.35, blur: 2 }}
|
||||
padding={0}
|
||||
fullScreen={isMobile}
|
||||
>
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
This modal shows the current application configuration for testing purposes only.
|
||||
</Text>
|
||||
<Button size="xs" variant="light" onClick={refetch}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
<div className="modal-container">
|
||||
{/* Left navigation */}
|
||||
<div
|
||||
className={`modal-nav ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: colors.navBg,
|
||||
borderRight: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="modal-nav-scroll">
|
||||
{configNavSections.map(section => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
{!isMobile && (
|
||||
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
<div className="modal-nav-section-items">
|
||||
{section.items.map(item => {
|
||||
const isActive = active === item.key;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
return (
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => setActive(item.key)}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
{!isMobile && (
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">Loading configuration...</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<ScrollArea h={400}>
|
||||
<Stack gap="lg">
|
||||
{renderConfigSection('Basic Configuration', basicConfig)}
|
||||
{renderConfigSection('Security Configuration', securityConfig)}
|
||||
{renderConfigSection('System Configuration', systemConfig)}
|
||||
{renderConfigSection('Premium/Enterprise Configuration', premiumConfig)}
|
||||
{renderConfigSection('Integration Configuration', integrationConfig)}
|
||||
{renderConfigSection('Legal Configuration', legalConfig)}
|
||||
|
||||
{config.error && (
|
||||
<Alert color="yellow" title="Configuration Warning">
|
||||
{config.error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="md" c="blue">Raw Configuration</Text>
|
||||
<Code block style={{ fontSize: '11px' }}>
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</Code>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</Stack>
|
||||
{/* Right content */}
|
||||
<div className="modal-content">
|
||||
<div className="modal-content-scroll">
|
||||
{/* Sticky header with section title and small close button */}
|
||||
<div
|
||||
className="modal-header"
|
||||
style={{
|
||||
background: colors.contentBg,
|
||||
borderBottom: `1px solid ${colors.headerBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text fw={700} size="lg">{activeLabel}</Text>
|
||||
<ActionIcon variant="subtle" onClick={onClose} aria-label="Close">
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
{activeComponent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ interface FileUploadButtonProps {
|
||||
const FileUploadButton = ({
|
||||
file,
|
||||
onChange,
|
||||
accept = "*/*",
|
||||
accept,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
variant = "outline",
|
||||
|
||||
@@ -41,7 +41,6 @@ const LandingPage = () => {
|
||||
{/* White PDF Page Background */}
|
||||
<Dropzone
|
||||
onDrop={handleFileDrop}
|
||||
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
|
||||
multiple={true}
|
||||
className="w-4/5 flex items-center justify-center h-[95%]"
|
||||
style={{
|
||||
@@ -178,7 +177,6 @@ const LandingPage = () => {
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.zip"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
@@ -269,8 +269,9 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => {
|
||||
const isEnglishGB = option.value === 'en-GB'; // Currently only English GB has enough translations to use
|
||||
const isDisabled = !isEnglishGB;
|
||||
// Enable languages with >90% translation completion
|
||||
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
|
||||
const isDisabled = !enabledLanguages.includes(option.value);
|
||||
|
||||
return (
|
||||
<LanguageItem
|
||||
|
||||
@@ -8,7 +8,7 @@ interface NavigationWarningModalProps {
|
||||
}
|
||||
|
||||
const NavigationWarningModal = ({
|
||||
onApplyAndContinue,
|
||||
onApplyAndContinue: _onApplyAndContinue,
|
||||
onExportAndContinue
|
||||
}: NavigationWarningModalProps) => {
|
||||
|
||||
@@ -30,13 +30,6 @@ const NavigationWarningModal = ({
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const _handleApplyAndContinue = async () => {
|
||||
if (onApplyAndContinue) {
|
||||
await onApplyAndContinue();
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
confirmNavigation();
|
||||
};
|
||||
|
||||
const handleExportAndContinue = async () => {
|
||||
if (onExportAndContinue) {
|
||||
@@ -85,7 +78,7 @@ const NavigationWarningModal = ({
|
||||
</Button>
|
||||
|
||||
{/* TODO:: Add this back in when it works */}
|
||||
{/* {onApplyAndContinue && (
|
||||
{/* {_onApplyAndContinue && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ButtonConfig } from '../../types/sidebar';
|
||||
import './quickAccessBar/QuickAccessBar.css';
|
||||
import AllToolsNavButton from './AllToolsNavButton';
|
||||
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
|
||||
import AppConfigModal from './AppConfigModal';
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
@@ -217,7 +218,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<div className="spacer" />
|
||||
|
||||
{/* Config button at the bottom */}
|
||||
{/* {buttonConfigs
|
||||
{buttonConfigs
|
||||
.filter(config => config.id === 'config')
|
||||
.map(config => (
|
||||
<div key={config.id} className="flex flex-col items-center gap-1">
|
||||
@@ -237,14 +238,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
))} */}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <AppConfigModal
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/> */}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
|
||||
import { SearchInterface } from '../viewer/SearchInterface';
|
||||
import { ViewerContext } from '../../contexts/ViewerContext';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls';
|
||||
|
||||
import { parseSelection } from '../../utils/bulkselection/parseSelection';
|
||||
|
||||
@@ -293,6 +294,9 @@ export default function RightRail() {
|
||||
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Controls */}
|
||||
<ViewerAnnotationControls currentView={currentView} />
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { NavKey } from './types';
|
||||
import HotkeysSection from './configSections/HotkeysSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
label: string;
|
||||
icon: string;
|
||||
component: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface ConfigNavSection {
|
||||
title: string;
|
||||
items: ConfigNavItem[];
|
||||
}
|
||||
|
||||
export interface ConfigColors {
|
||||
navBg: string;
|
||||
sectionTitle: string;
|
||||
navItem: string;
|
||||
navItemActive: string;
|
||||
navItemActiveBg: string;
|
||||
contentBg: string;
|
||||
headerBorder: string;
|
||||
}
|
||||
|
||||
export const createConfigNavSections = (
|
||||
Overview: React.ComponentType<{ onLogoutClick: () => void }>,
|
||||
onLogoutClick: () => void
|
||||
): ConfigNavSection[] => {
|
||||
const sections: ConfigNavSection[] = [
|
||||
{
|
||||
title: 'Account',
|
||||
items: [
|
||||
{
|
||||
key: 'overview',
|
||||
label: 'Overview',
|
||||
icon: 'person-rounded',
|
||||
component: <Overview onLogoutClick={onLogoutClick} />
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Preferences',
|
||||
items: [
|
||||
{
|
||||
key: 'hotkeys',
|
||||
label: 'Keyboard Shortcuts',
|
||||
icon: 'keyboard-rounded',
|
||||
component: <HotkeysSection />
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToolWorkflow } from '../../../../contexts/ToolWorkflowContext';
|
||||
import { useHotkeys } from '../../../../contexts/HotkeyContext';
|
||||
import HotkeyDisplay from '../../../hotkeys/HotkeyDisplay';
|
||||
import { bindingEquals, eventToBinding } from '../../../../utils/hotkeys';
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
};
|
||||
|
||||
const rowHeaderStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '0.5rem',
|
||||
};
|
||||
|
||||
const HotkeysSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toolRegistry } = useToolWorkflow();
|
||||
const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys();
|
||||
const [editingTool, setEditingTool] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const tools = useMemo(() => Object.entries(toolRegistry), [toolRegistry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTool) {
|
||||
return;
|
||||
}
|
||||
pauseHotkeys();
|
||||
return () => {
|
||||
resumeHotkeys();
|
||||
};
|
||||
}, [editingTool, pauseHotkeys, resumeHotkeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editingTool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
setEditingTool(null);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const binding = eventToBinding(event as KeyboardEvent);
|
||||
if (!binding) {
|
||||
const osKey = isMac ? 'mac' : 'windows';
|
||||
const fallbackText = isMac
|
||||
? 'Include ⌘ (Command), ⌥ (Option), or another modifier in your shortcut.'
|
||||
: 'Include Ctrl, Alt, or another modifier in your shortcut.';
|
||||
setError(t(`settings.hotkeys.errorModifier.${osKey}`, fallbackText));
|
||||
return;
|
||||
}
|
||||
|
||||
const conflictEntry = Object.entries(hotkeys).find(([toolId, existing]) => (
|
||||
toolId !== editingTool && bindingEquals(existing, binding)
|
||||
));
|
||||
|
||||
if (conflictEntry) {
|
||||
const conflictTool = toolRegistry[conflictEntry[0]]?.name ?? conflictEntry[0];
|
||||
setError(t('settings.hotkeys.errorConflict', 'Shortcut already used by {{tool}}.', { tool: conflictTool }));
|
||||
return;
|
||||
}
|
||||
|
||||
updateHotkey(editingTool, binding);
|
||||
setEditingTool(null);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, true);
|
||||
};
|
||||
}, [editingTool, hotkeys, toolRegistry, updateHotkey, t]);
|
||||
|
||||
const handleStartCapture = (toolId: string) => {
|
||||
setEditingTool(toolId);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">Keyboard Shortcuts</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Customize keyboard shortcuts for quick tool access. Click "Change shortcut" and press a new key combination. Press Esc to cancel.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
{tools.map(([toolId, tool], index) => {
|
||||
const currentBinding = hotkeys[toolId];
|
||||
const defaultBinding = defaults[toolId];
|
||||
const isEditing = editingTool === toolId;
|
||||
const defaultParts = getDisplayParts(defaultBinding);
|
||||
const defaultLabel = defaultParts.length > 0
|
||||
? defaultParts.join(' + ')
|
||||
: t('settings.hotkeys.none', 'Not assigned');
|
||||
|
||||
return (
|
||||
<React.Fragment key={toolId}>
|
||||
<Box style={rowStyle} data-testid={`hotkey-row-${toolId}`}>
|
||||
<div style={rowHeaderStyle}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem', minWidth: 0 }}>
|
||||
<Text fw={600}>{tool.name}</Text>
|
||||
<Group gap="xs" wrap="wrap" align="center">
|
||||
<HotkeyDisplay binding={currentBinding} size="md" />
|
||||
{!bindingEquals(currentBinding, defaultBinding) && (
|
||||
<Badge variant="light" color="orange" radius="sm">
|
||||
{t('settings.hotkeys.customBadge', 'Custom')}
|
||||
</Badge>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('settings.hotkeys.defaultLabel', 'Default: {{shortcut}}', { shortcut: defaultLabel })}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="xs"
|
||||
variant={isEditing ? 'filled' : 'default'}
|
||||
color={isEditing ? 'blue' : undefined}
|
||||
onClick={() => handleStartCapture(toolId)}
|
||||
>
|
||||
{isEditing
|
||||
? t('settings.hotkeys.capturing', 'Press keys… (Esc to cancel)')
|
||||
: t('settings.hotkeys.change', 'Change shortcut')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
disabled={bindingEquals(currentBinding, defaultBinding)}
|
||||
onClick={() => resetHotkey(toolId)}
|
||||
>
|
||||
{t('settings.hotkeys.reset', 'Reset')}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
{isEditing && error && (
|
||||
<Alert color="red" radius="sm" variant="filled">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{index < tools.length - 1 && <Divider />}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default HotkeysSection;
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Stack, Text, Code, Group, Badge, Alert, Loader } from '@mantine/core';
|
||||
import { useAppConfig } from '../../../../hooks/useAppConfig';
|
||||
|
||||
const Overview: React.FC = () => {
|
||||
const { config, loading, error } = useAppConfig();
|
||||
|
||||
const renderConfigSection = (title: string, data: any) => {
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
|
||||
return (
|
||||
<Stack gap="xs" mb="md">
|
||||
<Text fw={600} size="md" c="blue">{title}</Text>
|
||||
<Stack gap="xs" pl="md">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<Group key={key} wrap="nowrap" align="flex-start">
|
||||
<Text size="sm" w={150} style={{ flexShrink: 0 }} c="dimmed">
|
||||
{key}:
|
||||
</Text>
|
||||
{typeof value === 'boolean' ? (
|
||||
<Badge color={value ? 'green' : 'red'} size="sm">
|
||||
{value ? 'true' : 'false'}
|
||||
</Badge>
|
||||
) : typeof value === 'object' ? (
|
||||
<Code block>{JSON.stringify(value, null, 2)}</Code>
|
||||
) : (
|
||||
String(value) || 'null'
|
||||
)}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const basicConfig = config ? {
|
||||
appName: config.appName,
|
||||
appNameNavbar: config.appNameNavbar,
|
||||
baseUrl: config.baseUrl,
|
||||
contextPath: config.contextPath,
|
||||
serverPort: config.serverPort,
|
||||
} : null;
|
||||
|
||||
const securityConfig = config ? {
|
||||
enableLogin: config.enableLogin,
|
||||
} : null;
|
||||
|
||||
const systemConfig = config ? {
|
||||
enableAlphaFunctionality: config.enableAlphaFunctionality,
|
||||
enableAnalytics: config.enableAnalytics,
|
||||
} : null;
|
||||
|
||||
const integrationConfig = config ? {
|
||||
GoogleDriveEnabled: config.GoogleDriveEnabled,
|
||||
SSOAutoLogin: config.SSOAutoLogin,
|
||||
} : null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Stack align="center" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">Loading configuration...</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert color="red" title="Error">
|
||||
{error}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Text fw={600} size="lg">Application Configuration</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Current application settings and configuration details.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{config && (
|
||||
<>
|
||||
{renderConfigSection('Basic Configuration', basicConfig)}
|
||||
{renderConfigSection('Security Configuration', securityConfig)}
|
||||
{renderConfigSection('System Configuration', systemConfig)}
|
||||
{renderConfigSection('Integration Configuration', integrationConfig)}
|
||||
|
||||
{config.error && (
|
||||
<Alert color="yellow" title="Configuration Warning">
|
||||
{config.error}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
@@ -0,0 +1,19 @@
|
||||
export type NavKey =
|
||||
| 'overview'
|
||||
| 'preferences'
|
||||
| 'notifications'
|
||||
| 'connections'
|
||||
| 'general'
|
||||
| 'people'
|
||||
| 'teams'
|
||||
| 'security'
|
||||
| 'identity'
|
||||
| 'plan'
|
||||
| 'payments'
|
||||
| 'requests'
|
||||
| 'developer'
|
||||
| 'api-keys'
|
||||
| 'hotkeys';
|
||||
|
||||
|
||||
// some of these are not used yet, but appear in figma designs
|
||||
@@ -19,7 +19,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
children
|
||||
}) => {
|
||||
if (!file) return null;
|
||||
|
||||
|
||||
const containerStyle = {
|
||||
position: 'relative' as const,
|
||||
cursor: onClick ? 'pointer' : 'default',
|
||||
@@ -36,6 +36,7 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Image
|
||||
className='ph-no-capture'
|
||||
src={thumbnail}
|
||||
alt={`Preview of ${file.name}`}
|
||||
fit="contain"
|
||||
@@ -49,11 +50,12 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
return (
|
||||
<Box style={containerStyle} onClick={onClick}>
|
||||
<Center style={{ width: '100%', height: '100%', backgroundColor: 'var(--mantine-color-gray-1)', borderRadius: '0.25rem' }}>
|
||||
<PictureAsPdfIcon
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
<PictureAsPdfIcon
|
||||
className='ph-no-capture'
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
color: 'var(--mantine-color-gray-6)'
|
||||
}}
|
||||
/>
|
||||
</Center>
|
||||
{children}
|
||||
@@ -61,4 +63,4 @@ const DocumentThumbnail: React.FC<DocumentThumbnailProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default DocumentThumbnail;
|
||||
export default DocumentThumbnail;
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
}
|
||||
|
||||
.right-rail-slot.visible {
|
||||
max-height: 18rem; /* increased to fit additional controls + divider */
|
||||
max-height: 40rem; /* increased to fit additional controls + divider */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -77,14 +77,14 @@
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 18rem;
|
||||
max-height: 40rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rightRailShrinkUp {
|
||||
0% {
|
||||
max-height: 18rem;
|
||||
max-height: 40rem;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '../LocalIcon';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
import { ViewerContext } from '../../../contexts/ViewerContext';
|
||||
import { useSignature } from '../../../contexts/SignatureContext';
|
||||
import { ColorSwatchButton, ColorPicker } from '../../annotation/shared/ColorPicker';
|
||||
import { useFileState, useFileContext } from '../../../contexts/FileContext';
|
||||
import { generateThumbnailWithMetadata } from '../../../utils/thumbnailUtils';
|
||||
import { createProcessedFile } from '../../../contexts/file/fileActions';
|
||||
import { createStirlingFile, createNewStirlingFileStub } from '../../../types/fileContext';
|
||||
|
||||
interface ViewerAnnotationControlsProps {
|
||||
currentView: string;
|
||||
}
|
||||
|
||||
export default function ViewerAnnotationControls({ currentView }: ViewerAnnotationControlsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
// Signature context for accessing drawing API
|
||||
const { signatureApiRef } = useSignature();
|
||||
|
||||
// File state for save functionality
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Turn off annotation mode when switching away from viewer
|
||||
useEffect(() => {
|
||||
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
|
||||
viewerContext.setAnnotationMode(false);
|
||||
}
|
||||
}, [currentView, viewerContext]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Annotation Visibility Toggle */}
|
||||
<Tooltip content={t('rightRail.toggleAnnotations', 'Toggle Annotations Visibility')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationsVisibility();
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || viewerContext?.isAnnotationMode}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Mode Toggle with Drawing Controls */}
|
||||
{viewerContext?.isAnnotationMode ? (
|
||||
// When active: Show color picker on hover
|
||||
<div
|
||||
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
|
||||
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
<Popover
|
||||
opened={isHoverColorPickerOpen}
|
||||
onClose={() => setIsHoverColorPickerOpen(false)}
|
||||
position="left"
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
|
||||
// Deactivate drawing tool when exiting annotation mode
|
||||
if (signatureApiRef?.current) {
|
||||
try {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
aria-label="Drawing mode active"
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '8rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
size={32}
|
||||
onClick={() => {
|
||||
setIsHoverColorPickerOpen(false); // Close hover picker
|
||||
setIsColorPickerOpen(true); // Open main color picker modal
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
// When inactive: Show "Draw" tooltip
|
||||
<Tooltip content={t('rightRail.draw', 'Draw')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
// Activate ink drawing tool when entering annotation mode
|
||||
if (signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Save PDF with Annotations */}
|
||||
<Tooltip content={t('rightRail.save', 'Save')} position="left" offset={12} arrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={async () => {
|
||||
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
|
||||
try {
|
||||
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
|
||||
if (pdfArrayBuffer) {
|
||||
// Create new File object with flattened annotations
|
||||
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
||||
|
||||
// Get the original file name or use a default
|
||||
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
|
||||
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
|
||||
|
||||
// Replace the current file in context with the saved version (exact same logic as Sign tool)
|
||||
if (activeFiles.length > 0) {
|
||||
// Generate thumbnail and metadata for the saved file
|
||||
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
|
||||
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
|
||||
|
||||
// Get current file info
|
||||
const currentFileIds = state.files.ids;
|
||||
if (currentFileIds.length > 0) {
|
||||
const currentFileId = currentFileIds[0];
|
||||
const currentRecord = selectors.getStirlingFileStub(currentFileId);
|
||||
|
||||
if (!currentRecord) {
|
||||
console.error('No file record found for:', currentFileId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create output stub and file (exact same as Sign tool)
|
||||
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
|
||||
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
|
||||
|
||||
// Replace the original file with the saved version
|
||||
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving PDF:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={(color) => {
|
||||
setSelectedColor(color);
|
||||
// Update drawing tool color if annotation mode is active
|
||||
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.updateDrawSettings(color, 2);
|
||||
} catch (error) {
|
||||
console.log('Unable to update drawing settings:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Choose Drawing Color"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* AddAttachmentsSettings - Shared settings component for both tool UI and automation
|
||||
*
|
||||
* Allows selecting files to attach to PDFs.
|
||||
*/
|
||||
|
||||
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddAttachmentsParameters } from "../../../hooks/tools/addAttachments/useAddAttachmentsParameters";
|
||||
import LocalIcon from "../../shared/LocalIcon";
|
||||
|
||||
interface AddAttachmentsSettingsProps {
|
||||
parameters: AddAttachmentsParameters;
|
||||
onParameterChange: <K extends keyof AddAttachmentsParameters>(key: K, value: AddAttachmentsParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = false }: AddAttachmentsSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
|
||||
</Text>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
// Append to existing attachments instead of replacing
|
||||
const newAttachments = [...(parameters.attachments || []), ...files];
|
||||
onParameterChange('attachments', newAttachments);
|
||||
// Reset the input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ display: 'none' }}
|
||||
id="attachments-input"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
color="blue"
|
||||
component="label"
|
||||
htmlFor="attachments-input"
|
||||
disabled={disabled}
|
||||
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
|
||||
>
|
||||
{parameters.attachments?.length > 0
|
||||
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
|
||||
: t("AddAttachmentsRequest.placeholder", "Choose files...")
|
||||
}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{parameters.attachments?.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({parameters.attachments.length})
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={300} type="scroll" offsetScrollbars styles={{ viewport: { overflowX: 'hidden' } }}>
|
||||
<Stack gap="xs">
|
||||
{parameters.attachments.map((file, index) => (
|
||||
<Group key={index} justify="space-between" p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 'var(--mantine-radius-sm)', alignItems: 'flex-start' }}>
|
||||
<Group gap="xs" style={{ flex: 1, minWidth: 0, alignItems: 'flex-start' }}>
|
||||
{/* Filename (two-line clamp, wraps, no icon on the left) */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.2,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2 as any,
|
||||
WebkitBoxOrient: 'vertical' as any,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => {
|
||||
const newAttachments = (parameters.attachments || []).filter((_, i) => i !== index);
|
||||
onParameterChange('attachments', newAttachments);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="14" height="14" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAttachmentsSettings;
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* AddPageNumbersAppearanceSettings - Customize Appearance step
|
||||
*/
|
||||
|
||||
import { Stack, Select, TextInput, NumberInput } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddPageNumbersParameters } from "./useAddPageNumbersParameters";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
|
||||
interface AddPageNumbersAppearanceSettingsProps {
|
||||
parameters: AddPageNumbersParameters;
|
||||
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddPageNumbersAppearanceSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: AddPageNumbersAppearanceSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Tooltip content={t('marginTooltip', 'Distance between the page number and the edge of the page.')}>
|
||||
<Select
|
||||
label={t('addPageNumbers.selectText.2', 'Margin')}
|
||||
value={parameters.customMargin}
|
||||
onChange={(v) => onParameterChange('customMargin', (v as any) || 'medium')}
|
||||
data={[
|
||||
{ value: 'small', label: t('sizes.small', 'Small') },
|
||||
{ value: 'medium', label: t('sizes.medium', 'Medium') },
|
||||
{ value: 'large', label: t('sizes.large', 'Large') },
|
||||
{ value: 'x-large', label: t('sizes.x-large', 'Extra Large') },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('fontSizeTooltip', 'Size of the page number text in points. Larger numbers create bigger text.')}>
|
||||
<NumberInput
|
||||
label={t('addPageNumbers.fontSize', 'Font Size')}
|
||||
value={parameters.fontSize}
|
||||
onChange={(v) => onParameterChange('fontSize', typeof v === 'number' ? v : 12)}
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('fontTypeTooltip', 'Font family for the page numbers. Choose based on your document style.')}>
|
||||
<Select
|
||||
label={t('addPageNumbers.fontName', 'Font Type')}
|
||||
value={parameters.fontType}
|
||||
onChange={(v) => onParameterChange('fontType', (v as any) || 'Times')}
|
||||
data={[
|
||||
{ value: 'Times', label: 'Times Roman' },
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Courier', label: 'Courier New' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('customTextTooltip', 'Optional custom format for page numbers. Use {n} as placeholder for the number. Example: "Page {n}" will show "Page 1", "Page 2", etc.')}>
|
||||
<TextInput
|
||||
label={t('addPageNumbers.selectText.6', 'Custom Text Format')}
|
||||
value={parameters.customText || ''}
|
||||
onChange={(e) => onParameterChange('customText', e.currentTarget.value)}
|
||||
placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPageNumbersAppearanceSettings;
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* AddPageNumbersAutomationSettings - Used for automation only
|
||||
*
|
||||
* Combines both position and appearance settings into a single view
|
||||
*/
|
||||
|
||||
import { Stack, Divider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddPageNumbersParameters } from "./useAddPageNumbersParameters";
|
||||
import AddPageNumbersPositionSettings from "./AddPageNumbersPositionSettings";
|
||||
import AddPageNumbersAppearanceSettings from "./AddPageNumbersAppearanceSettings";
|
||||
|
||||
interface AddPageNumbersAutomationSettingsProps {
|
||||
parameters: AddPageNumbersParameters;
|
||||
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddPageNumbersAutomationSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false
|
||||
}: AddPageNumbersAutomationSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Position & Pages Section */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600}>{t("addPageNumbers.positionAndPages", "Position & Pages")}</Text>
|
||||
<AddPageNumbersPositionSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
file={null}
|
||||
showQuickGrid={true}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Appearance Section */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={600}>{t("addPageNumbers.customize", "Customize Appearance")}</Text>
|
||||
<AddPageNumbersAppearanceSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPageNumbersAutomationSettings;
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* AddPageNumbersPositionSettings - Position & Pages step
|
||||
*/
|
||||
|
||||
import { Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddPageNumbersParameters } from "./useAddPageNumbersParameters";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
import PageNumberPreview from "./PageNumberPreview";
|
||||
|
||||
interface AddPageNumbersPositionSettingsProps {
|
||||
parameters: AddPageNumbersParameters;
|
||||
onParameterChange: <K extends keyof AddPageNumbersParameters>(key: K, value: AddPageNumbersParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
file?: File | null;
|
||||
showQuickGrid?: boolean;
|
||||
}
|
||||
|
||||
const AddPageNumbersPositionSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
file = null,
|
||||
showQuickGrid = true
|
||||
}: AddPageNumbersPositionSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Position Selection */}
|
||||
<Stack gap="md">
|
||||
<PageNumberPreview
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
file={file}
|
||||
showQuickGrid={showQuickGrid}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Pages & Starting Number Section */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
|
||||
|
||||
<Tooltip content={t('pageSelectionPrompt', 'Specify which pages to add numbers to. Examples: "1,3,5" for specific pages, "1-5" for ranges, "2n" for even pages, or leave blank for all pages.')}>
|
||||
<TextInput
|
||||
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
|
||||
value={parameters.pagesToNumber || ''}
|
||||
onChange={(e) => onParameterChange('pagesToNumber', e.currentTarget.value)}
|
||||
placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('startingNumberTooltip', 'The first number to display. Subsequent pages will increment from this number.')}>
|
||||
<NumberInput
|
||||
label={t('addPageNumbers.selectText.4', 'Starting Number')}
|
||||
value={parameters.startingNumber}
|
||||
onChange={(v) => onParameterChange('startingNumber', typeof v === 'number' ? v : 1)}
|
||||
min={1}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPageNumbersPositionSettings;
|
||||
@@ -200,7 +200,7 @@ export default function PageNumberPreview({ parameters, onParameterChange, file,
|
||||
<img
|
||||
src={pageThumbnail}
|
||||
alt="page preview"
|
||||
className={styles.pageThumbnail}
|
||||
className={`${styles.pageThumbnail} ph-no-capture`}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
@@ -238,4 +238,4 @@ export default function PageNumberPreview({ parameters, onParameterChange, file,
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* AddStampAutomationSettings - Used for automation only
|
||||
*
|
||||
* This component combines all stamp settings into a single step interface
|
||||
* for use in the automation system. It includes setup and formatting
|
||||
* settings in one unified component.
|
||||
*/
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
import { AddStampParameters } from "./useAddStampParameters";
|
||||
import StampSetupSettings from "./StampSetupSettings";
|
||||
import StampPositionFormattingSettings from "./StampPositionFormattingSettings";
|
||||
|
||||
interface AddStampAutomationSettingsProps {
|
||||
parameters: AddStampParameters;
|
||||
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const AddStampAutomationSettings = ({ parameters, onParameterChange, disabled = false }: AddStampAutomationSettingsProps) => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Stamp Setup (Type, Text/Image, Page Selection) */}
|
||||
<StampSetupSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Position and Formatting Settings */}
|
||||
{parameters.stampType && (
|
||||
<StampPositionFormattingSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
showPositionGrid={true}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddStampAutomationSettings;
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Group, Select, Stack, ColorInput, Button, Slider, Text, NumberInput } from "@mantine/core";
|
||||
import { AddStampParameters } from "./useAddStampParameters";
|
||||
import LocalIcon from "../../shared/LocalIcon";
|
||||
import styles from "./StampPreview.module.css";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
|
||||
interface StampPositionFormattingSettingsProps {
|
||||
parameters: AddStampParameters;
|
||||
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
showPositionGrid?: boolean; // When true, show the 9-position grid for automation
|
||||
}
|
||||
|
||||
const StampPositionFormattingSettings = ({ parameters, onParameterChange, disabled = false, showPositionGrid = false }: StampPositionFormattingSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md" justify="space-between">
|
||||
{/* Position Grid - shown in automation settings */}
|
||||
{showPositionGrid && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>{t('AddStampRequest.position', 'Stamp Position')}</Text>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '0.5rem',
|
||||
maxWidth: '200px'
|
||||
}}>
|
||||
{Array.from({ length: 9 }).map((_, i) => {
|
||||
const idx = (i + 1) as 1|2|3|4|5|6|7|8|9;
|
||||
const selected = parameters.position === idx;
|
||||
return (
|
||||
<Button
|
||||
key={idx}
|
||||
variant={selected ? 'filled' : 'outline'}
|
||||
onClick={() => {
|
||||
onParameterChange('position', idx);
|
||||
// Ensure we're using grid positioning, not custom overrides
|
||||
onParameterChange('overrideX', -1 as any);
|
||||
onParameterChange('overrideY', -1 as any);
|
||||
}}
|
||||
disabled={disabled}
|
||||
styles={{
|
||||
root: {
|
||||
height: '50px',
|
||||
padding: '0',
|
||||
}
|
||||
}}
|
||||
>
|
||||
{idx}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
{/* Icon pill buttons row */}
|
||||
<div className="flex justify-between gap-[0.5rem]">
|
||||
<Tooltip content={t('AddStampRequest.rotation', 'Rotation')} position="top">
|
||||
<Button
|
||||
variant={parameters._activePill === 'rotation' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => onParameterChange('_activePill', 'rotation')}
|
||||
>
|
||||
<LocalIcon icon="rotate-right-rounded" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('AddStampRequest.opacity', 'Opacity')} position="top">
|
||||
<Button
|
||||
variant={parameters._activePill === 'opacity' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => onParameterChange('_activePill', 'opacity')}
|
||||
>
|
||||
<LocalIcon icon="opacity" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={parameters.stampType === 'image' ? t('AddStampRequest.imageSize', 'Image Size') : t('AddStampRequest.fontSize', 'Font Size')} position="top">
|
||||
<Button
|
||||
variant={parameters._activePill === 'fontSize' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => onParameterChange('_activePill', 'fontSize')}
|
||||
>
|
||||
<LocalIcon icon="zoom-in-map-rounded" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Single slider bound to selected pill */}
|
||||
{parameters._activePill === 'fontSize' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>
|
||||
{parameters.stampType === 'image'
|
||||
? t('AddStampRequest.imageSize', 'Image Size')
|
||||
: t('AddStampRequest.fontSize', 'Font Size')
|
||||
}
|
||||
</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.fontSize}
|
||||
onChange={(v) => onParameterChange('fontSize', typeof v === 'number' ? v : 1)}
|
||||
min={1}
|
||||
max={400}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.fontSize}
|
||||
onChange={(v) => onParameterChange('fontSize', v as number)}
|
||||
min={1}
|
||||
max={400}
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{parameters._activePill === 'rotation' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>{t('AddStampRequest.rotation', 'Rotation')}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.rotation}
|
||||
onChange={(v) => onParameterChange('rotation', typeof v === 'number' ? v : 0)}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
hideControls
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.rotation}
|
||||
onChange={(v) => onParameterChange('rotation', v as number)}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
className={styles.sliderWide}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{parameters._activePill === 'opacity' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>{t('AddStampRequest.opacity', 'Opacity')}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={parameters.opacity}
|
||||
onChange={(v) => onParameterChange('opacity', typeof v === 'number' ? v : 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Slider
|
||||
value={parameters.opacity}
|
||||
onChange={(v) => onParameterChange('opacity', v as number)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{parameters.stampType !== 'image' && (
|
||||
<ColorInput
|
||||
label={t('AddStampRequest.customColor', 'Custom Text Color')}
|
||||
value={parameters.customColor}
|
||||
onChange={(value) => onParameterChange('customColor', value)}
|
||||
format="hex"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Margin selection for text stamps */}
|
||||
{parameters.stampType === 'text' && (
|
||||
<Select
|
||||
label={t('AddStampRequest.margin', 'Margin')}
|
||||
value={parameters.customMargin}
|
||||
onChange={(v) => onParameterChange('customMargin', (v as any) || 'medium')}
|
||||
data={[
|
||||
{ value: 'small', label: t('margin.small', 'Small') },
|
||||
{ value: 'medium', label: t('margin.medium', 'Medium') },
|
||||
{ value: 'large', label: t('margin.large', 'Large') },
|
||||
{ value: 'x-large', label: t('margin.xLarge', 'Extra Large') },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StampPositionFormattingSettings;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Stack, Textarea, TextInput, Select, Button, Text, Divider } from "@mantine/core";
|
||||
import { AddStampParameters } from "./useAddStampParameters";
|
||||
import ButtonSelector from "../../shared/ButtonSelector";
|
||||
import styles from "./StampPreview.module.css";
|
||||
import { getDefaultFontSizeForAlphabet } from "./StampPreviewUtils";
|
||||
|
||||
interface StampSetupSettingsProps {
|
||||
parameters: AddStampParameters;
|
||||
onParameterChange: <K extends keyof AddStampParameters>(key: K, value: AddStampParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const StampSetupSettings = ({ parameters, onParameterChange, disabled = false }: StampSetupSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('pageSelectionPrompt', 'Page Selection (e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)')}
|
||||
value={parameters.pageNumbers}
|
||||
onChange={(e) => onParameterChange('pageNumbers', e.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Divider/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
|
||||
<ButtonSelector
|
||||
value={parameters.stampType}
|
||||
onChange={(v: 'text' | 'image') => onParameterChange('stampType', v)}
|
||||
options={[
|
||||
{ value: 'text', label: t('watermark.type.1', 'Text') },
|
||||
{ value: 'image', label: t('watermark.type.2', 'Image') },
|
||||
]}
|
||||
disabled={disabled}
|
||||
buttonClassName={styles.modeToggleButton}
|
||||
textClassName={styles.modeToggleButtonText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{parameters.stampType === 'text' && (
|
||||
<>
|
||||
<Textarea
|
||||
label={t('AddStampRequest.stampText', 'Stamp Text')}
|
||||
value={parameters.stampText}
|
||||
onChange={(e) => onParameterChange('stampText', e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Select
|
||||
label={t('AddStampRequest.alphabet', 'Alphabet')}
|
||||
value={parameters.alphabet}
|
||||
onChange={(v) => {
|
||||
const nextAlphabet = (v as any) || 'roman';
|
||||
onParameterChange('alphabet', nextAlphabet);
|
||||
const nextDefault = getDefaultFontSizeForAlphabet(nextAlphabet);
|
||||
onParameterChange('fontSize', nextDefault);
|
||||
}}
|
||||
data={[
|
||||
{ value: 'roman', label: 'Roman' },
|
||||
{ value: 'arabic', label: 'العربية' },
|
||||
{ value: 'japanese', label: '日本語' },
|
||||
{ value: 'korean', label: '한국어' },
|
||||
{ value: 'chinese', label: '简体中文' },
|
||||
{ value: 'thai', label: 'ไทย' },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{parameters.stampType === 'image' && (
|
||||
<Stack gap="xs">
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) onParameterChange('stampImage', file);
|
||||
}}
|
||||
disabled={disabled}
|
||||
style={{ display: 'none' }}
|
||||
id="stamp-image-input"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
component="label"
|
||||
htmlFor="stamp-image-input"
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('chooseFile', 'Choose File')}
|
||||
</Button>
|
||||
{parameters.stampImage && (
|
||||
<Stack gap="xs">
|
||||
<img
|
||||
src={URL.createObjectURL(parameters.stampImage)}
|
||||
alt="Selected stamp image"
|
||||
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{parameters.stampImage.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default StampSetupSettings;
|
||||
@@ -35,7 +35,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
||||
|
||||
// Get tool info from registry
|
||||
const toolInfo = toolRegistry[tool.operation as keyof ToolRegistry];
|
||||
const SettingsComponent = toolInfo?.settingsComponent;
|
||||
const SettingsComponent = toolInfo?.automationSettings;
|
||||
|
||||
// Initialize parameters from tool (which should contain defaults from registry)
|
||||
useEffect(() => {
|
||||
@@ -109,7 +109,7 @@ export default function ToolConfigurationModal({ opened, tool, onSave, onCancel,
|
||||
{t('automate.config.description', 'Configure the settings for this tool. These settings will be applied when the automation runs.')}
|
||||
</Text>
|
||||
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
<div style={{ maxHeight: '60vh', overflowY: 'auto', overflowX: "hidden" }}>
|
||||
{renderToolSettings()}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,11 +34,14 @@ export default function ToolList({
|
||||
|
||||
const handleToolSelect = (index: number, newOperation: string) => {
|
||||
const defaultParams = getToolDefaultParameters(newOperation);
|
||||
const toolEntry = toolRegistry[newOperation];
|
||||
// If tool has no settingsComponent, it's automatically configured
|
||||
const isConfigured = !toolEntry?.automationSettings;
|
||||
|
||||
onToolUpdate(index, {
|
||||
operation: newOperation,
|
||||
name: getToolName(newOperation),
|
||||
configured: false,
|
||||
configured: isConfigured,
|
||||
parameters: defaultParams,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, ScrollArea } from '@mantine/core';
|
||||
import { ToolRegistryEntry } from '../../../data/toolsTaxonomy';
|
||||
import { ToolRegistryEntry, getToolSupportsAutomate } from '../../../data/toolsTaxonomy';
|
||||
import { useToolSections } from '../../../hooks/useToolSections';
|
||||
import { renderToolButtons } from '../shared/renderToolButtons';
|
||||
import ToolSearch from '../toolPicker/ToolSearch';
|
||||
@@ -28,9 +28,11 @@ export default function ToolSelector({
|
||||
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter out excluded tools (like 'automate' itself)
|
||||
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
|
||||
const baseFilteredTools = useMemo(() => {
|
||||
return Object.entries(toolRegistry).filter(([key]) => !excludeTools.includes(key));
|
||||
return Object.entries(toolRegistry).filter(([key, tool]) =>
|
||||
!excludeTools.includes(key) && getToolSupportsAutomate(tool)
|
||||
);
|
||||
}, [toolRegistry, excludeTools]);
|
||||
|
||||
// Apply search filter
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* CertSignAutomationSettings - Used for automation only
|
||||
*
|
||||
* This component combines all certificate signing settings into a single step interface
|
||||
* for use in the automation system. It includes sign mode, certificate format, certificate files,
|
||||
* and signature appearance settings in one unified component.
|
||||
*/
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
|
||||
import CertificateTypeSettings from "./CertificateTypeSettings";
|
||||
import CertificateFormatSettings from "./CertificateFormatSettings";
|
||||
import CertificateFilesSettings from "./CertificateFilesSettings";
|
||||
import SignatureAppearanceSettings from "./SignatureAppearanceSettings";
|
||||
|
||||
interface CertSignAutomationSettingsProps {
|
||||
parameters: CertSignParameters;
|
||||
onParameterChange: <K extends keyof CertSignParameters>(key: K, value: CertSignParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CertSignAutomationSettings = ({ parameters, onParameterChange, disabled = false }: CertSignAutomationSettingsProps) => {
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Sign Mode Selection (Manual vs Auto) */}
|
||||
<CertificateTypeSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Certificate Format - only show for Manual mode */}
|
||||
{parameters.signMode === 'MANUAL' && (
|
||||
<CertificateFormatSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Certificate Files - only show for Manual mode */}
|
||||
{parameters.signMode === 'MANUAL' && (
|
||||
<CertificateFilesSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Signature Appearance Settings */}
|
||||
<SignatureAppearanceSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertSignAutomationSettings;
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* CropAutomationSettings - Used for automation only
|
||||
*
|
||||
* Simplified crop settings for automation that doesn't require a file preview.
|
||||
* Allows users to manually enter crop coordinates and dimensions.
|
||||
*/
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
import { CropParameters } from "../../../hooks/tools/crop/useCropParameters";
|
||||
import { Rectangle } from "../../../utils/cropCoordinates";
|
||||
import CropCoordinateInputs from "./CropCoordinateInputs";
|
||||
|
||||
interface CropAutomationSettingsProps {
|
||||
parameters: CropParameters;
|
||||
onParameterChange: <K extends keyof CropParameters>(key: K, value: CropParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const CropAutomationSettings = ({ parameters, onParameterChange, disabled = false }: CropAutomationSettingsProps) => {
|
||||
// Handle coordinate changes
|
||||
const handleCoordinateChange = (field: keyof Rectangle, value: number | string) => {
|
||||
const numValue = typeof value === 'string' ? parseFloat(value) : value;
|
||||
if (isNaN(numValue)) return;
|
||||
|
||||
const newCropArea = { ...parameters.cropArea, [field]: numValue };
|
||||
onParameterChange('cropArea', newCropArea);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<CropCoordinateInputs
|
||||
cropArea={parameters.cropArea}
|
||||
onCoordinateChange={handleCoordinateChange}
|
||||
disabled={disabled}
|
||||
showAutomationInfo={true}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CropAutomationSettings;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Stack, Text, Group, NumberInput, Alert } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Rectangle, PDFBounds } from "../../../utils/cropCoordinates";
|
||||
|
||||
interface CropCoordinateInputsProps {
|
||||
cropArea: Rectangle;
|
||||
onCoordinateChange: (field: keyof Rectangle, value: number | string) => void;
|
||||
disabled?: boolean;
|
||||
pdfBounds?: PDFBounds;
|
||||
showAutomationInfo?: boolean;
|
||||
}
|
||||
|
||||
const CropCoordinateInputs = ({
|
||||
cropArea,
|
||||
onCoordinateChange,
|
||||
disabled = false,
|
||||
pdfBounds,
|
||||
showAutomationInfo = false
|
||||
}: CropCoordinateInputsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{showAutomationInfo && (
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="xs">
|
||||
{t("crop.automation.info", "Enter crop coordinates in PDF points. Origin (0,0) is at bottom-left. These values will be applied to all PDFs processed in this automation.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
{t("crop.coordinates.title", "Position and Size")}
|
||||
</Text>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.x", "X Position")}
|
||||
description={showAutomationInfo ? t("crop.coordinates.x.desc", "Left edge (points)") : undefined}
|
||||
value={Math.round(cropArea.x * 10) / 10}
|
||||
onChange={(value) => onCoordinateChange('x', value)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={pdfBounds?.actualWidth}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size={showAutomationInfo ? "sm" : "xs"}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.y", "Y Position")}
|
||||
description={showAutomationInfo ? t("crop.coordinates.y.desc", "Bottom edge (points)") : undefined}
|
||||
value={Math.round(cropArea.y * 10) / 10}
|
||||
onChange={(value) => onCoordinateChange('y', value)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={pdfBounds?.actualHeight}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size={showAutomationInfo ? "sm" : "xs"}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.width", "Width")}
|
||||
description={showAutomationInfo ? t("crop.coordinates.width.desc", "Crop width (points)") : undefined}
|
||||
value={Math.round(cropArea.width * 10) / 10}
|
||||
onChange={(value) => onCoordinateChange('width', value)}
|
||||
disabled={disabled}
|
||||
min={0.1}
|
||||
max={pdfBounds?.actualWidth}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size={showAutomationInfo ? "sm" : "xs"}
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.height", "Height")}
|
||||
description={showAutomationInfo ? t("crop.coordinates.height.desc", "Crop height (points)") : undefined}
|
||||
value={Math.round(cropArea.height * 10) / 10}
|
||||
onChange={(value) => onCoordinateChange('height', value)}
|
||||
disabled={disabled}
|
||||
min={0.1}
|
||||
max={pdfBounds?.actualHeight}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size={showAutomationInfo ? "sm" : "xs"}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{showAutomationInfo && (
|
||||
<Alert color="gray" variant="light">
|
||||
<Text size="xs">
|
||||
{t("crop.automation.reference", "Reference: A4 page is 595.28 × 841.89 points (210mm × 297mm). 1 inch = 72 points.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CropCoordinateInputs;
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { Stack, Text, Box, Group, NumberInput, ActionIcon, Center, Alert } from "@mantine/core";
|
||||
import { Stack, Text, Box, Group, ActionIcon, Center, Alert } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import RestartAltIcon from "@mui/icons-material/RestartAlt";
|
||||
import { CropParametersHook } from "../../../hooks/tools/crop/useCropParameters";
|
||||
import { useSelectedFiles } from "../../../contexts/file/fileHooks";
|
||||
import CropAreaSelector from "./CropAreaSelector";
|
||||
import CropCoordinateInputs from "./CropCoordinateInputs";
|
||||
import { DEFAULT_CROP_AREA } from "../../../constants/cropConstants";
|
||||
import { PAGE_SIZES } from "../../../constants/pageSizeConstants";
|
||||
import {
|
||||
@@ -190,71 +191,22 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => {
|
||||
</Stack>
|
||||
|
||||
{/* Manual Coordinate Input */}
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("crop.coordinates.title", "Position and Size")}
|
||||
</Text>
|
||||
<CropCoordinateInputs
|
||||
cropArea={cropArea}
|
||||
onCoordinateChange={handleCoordinateChange}
|
||||
disabled={disabled}
|
||||
pdfBounds={pdfBounds}
|
||||
showAutomationInfo={false}
|
||||
/>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.x", "X Position")}
|
||||
value={Math.round(cropArea.x * 10) / 10}
|
||||
onChange={(value) => handleCoordinateChange('x', value)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={pdfBounds.actualWidth}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size="xs"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.y", "Y Position")}
|
||||
value={Math.round(cropArea.y * 10) / 10}
|
||||
onChange={(value) => handleCoordinateChange('y', value)}
|
||||
disabled={disabled}
|
||||
min={0}
|
||||
max={pdfBounds.actualHeight}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size="xs"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.width", "Width")}
|
||||
value={Math.round(cropArea.width * 10) / 10}
|
||||
onChange={(value) => handleCoordinateChange('width', value)}
|
||||
disabled={disabled}
|
||||
min={0.1}
|
||||
max={pdfBounds.actualWidth}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size="xs"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("crop.coordinates.height", "Height")}
|
||||
value={Math.round(cropArea.height * 10) / 10}
|
||||
onChange={(value) => handleCoordinateChange('height', value)}
|
||||
disabled={disabled}
|
||||
min={0.1}
|
||||
max={pdfBounds.actualHeight}
|
||||
step={0.1}
|
||||
decimalScale={1}
|
||||
size="xs"
|
||||
/>
|
||||
</Group>
|
||||
|
||||
|
||||
{/* Validation Alert */}
|
||||
{!isCropValid && (
|
||||
<Alert color="red" variant="light">
|
||||
<Text size="xs">
|
||||
{t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
{/* Validation Alert */}
|
||||
{!isCropValid && (
|
||||
<Alert color="red" variant="light">
|
||||
<Text size="xs">
|
||||
{t("crop.error.invalidArea", "Crop area extends beyond PDF boundaries")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,16 +16,17 @@ const RemovePagesSettings = ({ parameters, onParameterChange, disabled = false }
|
||||
// Allow user to type naturally - don't normalize input in real-time
|
||||
onParameterChange('pageNumbers', value);
|
||||
};
|
||||
console.log('Current pageNumbers input:', parameters.pageNumbers, disabled);
|
||||
|
||||
// Check if current input is valid
|
||||
const isValid = validatePageNumbers(parameters.pageNumbers);
|
||||
const hasValue = parameters.pageNumbers.trim().length > 0;
|
||||
const isValid = validatePageNumbers(parameters.pageNumbers || '');
|
||||
const hasValue = (parameters?.pageNumbers?.trim().length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('removePages.pageNumbers.label', 'Pages to Remove')}
|
||||
value={parameters.pageNumbers}
|
||||
value={parameters.pageNumbers || ''}
|
||||
onChange={(event) => handlePageNumbersChange(event.currentTarget.value)}
|
||||
placeholder={t('removePages.pageNumbers.placeholder', 'e.g., 1,3,5-8,10')}
|
||||
disabled={disabled}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* RotateAutomationSettings - Used for automation only
|
||||
*
|
||||
* Simplified rotation settings for automation that allows selecting
|
||||
* one of four 90-degree rotation angles.
|
||||
*/
|
||||
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RotateParameters } from "../../../hooks/tools/rotate/useRotateParameters";
|
||||
import ButtonSelector from "../../shared/ButtonSelector";
|
||||
|
||||
interface RotateAutomationSettingsProps {
|
||||
parameters: RotateParameters;
|
||||
onParameterChange: <K extends keyof RotateParameters>(key: K, value: RotateParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RotateAutomationSettings = ({ parameters, onParameterChange, disabled = false }: RotateAutomationSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("rotate.selectRotation", "Select Rotation Angle (Clockwise)")}
|
||||
</Text>
|
||||
|
||||
<ButtonSelector
|
||||
value={parameters.angle}
|
||||
onChange={(value: number) => onParameterChange('angle', value)}
|
||||
options={[
|
||||
{ value: 0, label: "0°" },
|
||||
{ value: 90, label: "90°" },
|
||||
{ value: 180, label: "180°" },
|
||||
{ value: 270, label: "270°" },
|
||||
]}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default RotateAutomationSettings;
|
||||
@@ -62,6 +62,7 @@ const ResultsPreview = ({
|
||||
{/* File name at the top */}
|
||||
<Box mb="sm" style={{ minHeight: '3rem', display: 'flex', alignItems: 'flex-start' }}>
|
||||
<Text
|
||||
className='ph-no-capture'
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* SplitAutomationSettings - Used for automation only
|
||||
*
|
||||
* Combines split method selection and method-specific settings
|
||||
* into a single component for automation workflows.
|
||||
*/
|
||||
|
||||
import { Stack, Text, Select } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SplitParameters } from "../../../hooks/tools/split/useSplitParameters";
|
||||
import { METHOD_OPTIONS, SplitMethod } from "../../../constants/splitConstants";
|
||||
import SplitSettings from "./SplitSettings";
|
||||
|
||||
interface SplitAutomationSettingsProps {
|
||||
parameters: SplitParameters;
|
||||
onParameterChange: <K extends keyof SplitParameters>(key: K, value: SplitParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const SplitAutomationSettings = ({ parameters, onParameterChange, disabled = false }: SplitAutomationSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Convert METHOD_OPTIONS to Select data format
|
||||
const methodSelectOptions = METHOD_OPTIONS.map((option) => {
|
||||
const prefix = t(option.prefixKey, "Split");
|
||||
const name = t(option.nameKey, "Method");
|
||||
return {
|
||||
value: option.value,
|
||||
label: `${prefix} ${name}`,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* Method Selection */}
|
||||
<Select
|
||||
label={t("split.steps.chooseMethod", "Choose Method")}
|
||||
placeholder={t("split.selectMethod", "Select a split method")}
|
||||
value={parameters.method}
|
||||
onChange={(value) => onParameterChange('method', value as (SplitMethod | '') || '')}
|
||||
data={methodSelectOptions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Method-Specific Settings */}
|
||||
{parameters.method && (
|
||||
<>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("split.steps.settings", "Settings")}
|
||||
</Text>
|
||||
<SplitSettings
|
||||
parameters={parameters}
|
||||
onParameterChange={onParameterChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SplitAutomationSettings;
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip } from "../../shared/Tooltip";
|
||||
import { ToolRegistryEntry } from "../../../data/toolsTaxonomy";
|
||||
import { useToolNavigation } from "../../../hooks/useToolNavigation";
|
||||
import { handleUnlessSpecialClick } from "../../../utils/clickHandlers";
|
||||
import FitText from "../../shared/FitText";
|
||||
import { useHotkeys } from "../../../contexts/HotkeyContext";
|
||||
import HotkeyDisplay from "../../hotkeys/HotkeyDisplay";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: string;
|
||||
@@ -17,8 +20,11 @@ interface ToolButtonProps {
|
||||
}
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym }) => {
|
||||
const { t } = useTranslation();
|
||||
// Special case: read and multiTool are navigational tools that are always available
|
||||
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
const { hotkeys } = useHotkeys();
|
||||
const binding = hotkeys[id];
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
|
||||
const handleClick = (id: string) => {
|
||||
@@ -37,7 +43,17 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
|
||||
const tooltipContent = isUnavailable
|
||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||
: tool.description;
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span>{tool.description}</span>
|
||||
{binding && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.75rem' }}>
|
||||
<span style={{ color: 'var(--mantine-color-dimmed)', fontWeight: 500 }}>{t('settings.hotkeys.shortcut', 'Shortcut')}</span>
|
||||
<HotkeyDisplay binding={binding} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const buttonContent = (
|
||||
<>
|
||||
|
||||
@@ -29,16 +29,19 @@ const EmbedPdfViewerContent = ({
|
||||
const { colorScheme: _colorScheme } = useMantineColorScheme();
|
||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState } = useViewer();
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, isAnnotationMode, isAnnotationsVisible } = useViewer();
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const zoomState = getZoomState();
|
||||
const spreadState = getSpreadState();
|
||||
|
||||
// Check if we're in signature mode
|
||||
// Check if we're in signature mode OR viewer annotation mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignatureMode = selectedTool === 'sign';
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
|
||||
// Get signature context
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
|
||||
@@ -186,7 +189,7 @@ const EmbedPdfViewerContent = ({
|
||||
<LocalEmbedPDF
|
||||
file={effectiveFile.file}
|
||||
url={effectiveFile.url}
|
||||
enableSignature={isSignatureMode}
|
||||
enableAnnotations={shouldEnableAnnotations}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||
onSignatureAdded={() => {
|
||||
|
||||
@@ -42,13 +42,13 @@ import { ExportAPIBridge } from './ExportAPIBridge';
|
||||
interface LocalEmbedPDFProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
enableSignature?: boolean;
|
||||
enableAnnotations?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
|
||||
@@ -93,10 +93,10 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
|
||||
createPluginRegistration(SelectionPluginPackage),
|
||||
|
||||
// Register history plugin for undo/redo (recommended for annotations)
|
||||
...(enableSignature ? [createPluginRegistration(HistoryPluginPackage)] : []),
|
||||
...(enableAnnotations ? [createPluginRegistration(HistoryPluginPackage)] : []),
|
||||
|
||||
// Register annotation plugin (depends on InteractionManager, Selection, History)
|
||||
...(enableSignature ? [createPluginRegistration(AnnotationPluginPackage, {
|
||||
...(enableAnnotations ? [createPluginRegistration(AnnotationPluginPackage, {
|
||||
annotationAuthor: 'Digital Signature',
|
||||
autoCommit: true,
|
||||
deactivateToolAfterCreate: false,
|
||||
@@ -182,19 +182,22 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
|
||||
|
||||
// Wrap your UI with the <EmbedPDF> provider
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0
|
||||
<div
|
||||
className='ph-no-capture'
|
||||
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
}}>
|
||||
<EmbedPDF
|
||||
engine={engine}
|
||||
plugins={plugins}
|
||||
onInitialized={enableSignature ? async (registry) => {
|
||||
onInitialized={enableAnnotations ? async (registry) => {
|
||||
const annotationPlugin = registry.getPlugin('annotation');
|
||||
if (!annotationPlugin || !annotationPlugin.provides) return;
|
||||
|
||||
@@ -265,8 +268,8 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
|
||||
<SearchAPIBridge />
|
||||
<ThumbnailAPIBridge />
|
||||
<RotateAPIBridge />
|
||||
{enableSignature && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||
{enableSignature && <HistoryAPIBridge ref={historyApiRef} />}
|
||||
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
||||
<ExportAPIBridge />
|
||||
<GlobalPointerProvider>
|
||||
<Viewport
|
||||
@@ -310,9 +313,9 @@ export function LocalEmbedPDF({ file, url, enableSignature = false, onSignatureA
|
||||
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Selection layer for text interaction */}
|
||||
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||
{/* Annotation layer for signatures (only when enabled) */}
|
||||
{enableSignature && (
|
||||
{enableAnnotations && (
|
||||
<AnnotationLayer
|
||||
pageIndex={pageIndex}
|
||||
scale={scale}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createPluginRegistration } from '@embedpdf/core';
|
||||
import { EmbedPDF } from '@embedpdf/core/react';
|
||||
import { usePdfiumEngine } from '@embedpdf/engines/react';
|
||||
@@ -312,4 +312,4 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
</EmbedPDF>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
|
||||
import { PdfAnnotationSubtype, PdfStandardFont, PdfTextAlignment, PdfVerticalAlignment, uuidV4 } from '@embedpdf/models';
|
||||
import { SignParameters } from '../../hooks/tools/sign/useSignParameters';
|
||||
import { useSignature } from '../../contexts/SignatureContext';
|
||||
import { useViewer } from '../../contexts/ViewerContext';
|
||||
|
||||
export interface SignatureAPI {
|
||||
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => void;
|
||||
@@ -20,11 +21,12 @@ export interface SignatureAPI {
|
||||
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
|
||||
const { provides: annotationApi } = useAnnotationCapability();
|
||||
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
|
||||
const { isAnnotationMode } = useViewer();
|
||||
|
||||
|
||||
// Enable keyboard deletion of selected annotations - only when in signature placement mode
|
||||
// Enable keyboard deletion of selected annotations - when in signature placement mode or viewer annotation mode
|
||||
useEffect(() => {
|
||||
if (!annotationApi || !isPlacementMode) return;
|
||||
if (!annotationApi || (!isPlacementMode && !isAnnotationMode)) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
@@ -65,7 +67,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [annotationApi, storeImageData, isPlacementMode]);
|
||||
}, [annotationApi, storeImageData, isPlacementMode, isAnnotationMode]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
addImageSignature: (signatureData: string, x: number, y: number, width: number, height: number, pageIndex: number) => {
|
||||
|
||||
@@ -134,6 +134,7 @@ export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSide
|
||||
{/* Thumbnail Image */}
|
||||
{thumbnails[pageIndex] && thumbnails[pageIndex] !== 'error' ? (
|
||||
<img
|
||||
className='ph-no-capture'
|
||||
src={thumbnails[pageIndex]}
|
||||
alt={`Page ${pageIndex + 1} thumbnail`}
|
||||
style={{
|
||||
|
||||
@@ -15,7 +15,19 @@ export function ZoomAPIBridge() {
|
||||
if (zoom && !hasSetInitialZoom.current) {
|
||||
hasSetInitialZoom.current = true;
|
||||
setTimeout(() => {
|
||||
zoom.requestZoom(1.4);
|
||||
try {
|
||||
zoom.requestZoom(1.4);
|
||||
} catch (error) {
|
||||
console.log('Zoom initialization delayed, viewport not ready:', error);
|
||||
// Retry after a longer delay
|
||||
setTimeout(() => {
|
||||
try {
|
||||
zoom.requestZoom(1.4);
|
||||
} catch (retryError) {
|
||||
console.log('Zoom initialization failed:', retryError);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}, [zoom]);
|
||||
|
||||
@@ -18,6 +18,7 @@ interface FileManagerContextValue {
|
||||
expandedFileIds: Set<FileId>;
|
||||
fileGroups: Map<FileId, StirlingFileStub[]>;
|
||||
loadedHistoryFiles: Map<FileId, StirlingFileStub[]>;
|
||||
isLoading: boolean;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
|
||||
@@ -58,6 +59,7 @@ interface FileManagerProviderProps {
|
||||
onFileRemove: (index: number) => void;
|
||||
modalHeight: string;
|
||||
refreshRecentFiles: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
@@ -71,6 +73,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
onFileRemove,
|
||||
modalHeight,
|
||||
refreshRecentFiles,
|
||||
isLoading,
|
||||
}) => {
|
||||
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<FileId[]>([]);
|
||||
@@ -574,6 +577,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
expandedFileIds,
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
isLoading,
|
||||
|
||||
// Handlers
|
||||
onSourceChange: handleSourceChange,
|
||||
@@ -607,6 +611,7 @@ export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
expandedFileIds,
|
||||
fileGroups,
|
||||
loadedHistoryFiles,
|
||||
isLoading,
|
||||
handleSourceChange,
|
||||
handleLocalFileClick,
|
||||
handleFileSelect,
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import { HotkeyBinding, bindingEquals, bindingMatchesEvent, deserializeBindings, getDisplayParts, isMacLike, normalizeBinding, serializeBindings } from '../utils/hotkeys';
|
||||
import { useToolWorkflow } from './ToolWorkflowContext';
|
||||
import { ToolId } from '../types/toolId';
|
||||
|
||||
interface HotkeyContextValue {
|
||||
hotkeys: Record<string, HotkeyBinding>;
|
||||
defaults: Record<string, HotkeyBinding>;
|
||||
isMac: boolean;
|
||||
updateHotkey: (toolId: string, binding: HotkeyBinding) => void;
|
||||
resetHotkey: (toolId: string) => void;
|
||||
isBindingAvailable: (binding: HotkeyBinding, excludeToolId?: string) => boolean;
|
||||
pauseHotkeys: () => void;
|
||||
resumeHotkeys: () => void;
|
||||
areHotkeysPaused: boolean;
|
||||
getDisplayParts: (binding: HotkeyBinding | null | undefined) => string[];
|
||||
}
|
||||
|
||||
const HotkeyContext = createContext<HotkeyContextValue | undefined>(undefined);
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf.hotkeys';
|
||||
|
||||
const KEY_ORDER: string[] = [
|
||||
'Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9', 'Digit0',
|
||||
'KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyT', 'KeyY', 'KeyU', 'KeyI', 'KeyO', 'KeyP',
|
||||
'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyG', 'KeyH', 'KeyJ', 'KeyK', 'KeyL',
|
||||
'KeyZ', 'KeyX', 'KeyC', 'KeyV', 'KeyB', 'KeyN', 'KeyM',
|
||||
'F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8', 'F9', 'F10', 'F11', 'F12',
|
||||
];
|
||||
|
||||
const generateDefaultHotkeys = (toolIds: string[], macLike: boolean): Record<string, HotkeyBinding> => {
|
||||
const defaults: Record<string, HotkeyBinding> = {};
|
||||
let index = 0;
|
||||
let useShift = false;
|
||||
|
||||
const nextBinding = (): HotkeyBinding => {
|
||||
if (index >= KEY_ORDER.length) {
|
||||
index = 0;
|
||||
if (!useShift) {
|
||||
useShift = true;
|
||||
} else {
|
||||
// If we somehow run out of combinations, wrap back around (unlikely given tool count)
|
||||
useShift = false;
|
||||
}
|
||||
}
|
||||
|
||||
const code = KEY_ORDER[index];
|
||||
index += 1;
|
||||
|
||||
return {
|
||||
code,
|
||||
alt: true,
|
||||
shift: useShift,
|
||||
meta: macLike,
|
||||
ctrl: !macLike,
|
||||
};
|
||||
};
|
||||
|
||||
toolIds.forEach(toolId => {
|
||||
defaults[toolId] = nextBinding();
|
||||
});
|
||||
|
||||
return defaults;
|
||||
};
|
||||
|
||||
const shouldIgnoreTarget = (target: EventTarget | null): boolean => {
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
const editable = target.closest('input, textarea, [contenteditable="true"], [role="textbox"]');
|
||||
return Boolean(editable);
|
||||
};
|
||||
|
||||
export const HotkeyProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { toolRegistry, handleToolSelect } = useToolWorkflow();
|
||||
const isMac = useMemo(() => isMacLike(), []);
|
||||
const [customBindings, setCustomBindings] = useState<Record<string, HotkeyBinding>>(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
return deserializeBindings(window.localStorage?.getItem(STORAGE_KEY));
|
||||
});
|
||||
const [areHotkeysPaused, setHotkeysPaused] = useState(false);
|
||||
|
||||
const toolIds = useMemo(() => Object.keys(toolRegistry), [toolRegistry]);
|
||||
|
||||
const defaults = useMemo(() => generateDefaultHotkeys(toolIds, isMac), [toolIds, isMac]);
|
||||
|
||||
// Remove bindings for tools that are no longer present
|
||||
useEffect(() => {
|
||||
setCustomBindings(prev => {
|
||||
const next: Record<string, HotkeyBinding> = {};
|
||||
let changed = false;
|
||||
Object.entries(prev).forEach(([toolId, binding]) => {
|
||||
if (toolRegistry[toolId]) {
|
||||
next[toolId] = binding;
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [toolRegistry]);
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
const merged: Record<string, HotkeyBinding> = {};
|
||||
toolIds.forEach(toolId => {
|
||||
const custom = customBindings[toolId];
|
||||
merged[toolId] = custom ? normalizeBinding(custom) : defaults[toolId];
|
||||
});
|
||||
return merged;
|
||||
}, [customBindings, defaults, toolIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, serializeBindings(customBindings));
|
||||
}, [customBindings]);
|
||||
|
||||
const isBindingAvailable = useCallback((binding: HotkeyBinding, excludeToolId?: string) => {
|
||||
const normalized = normalizeBinding(binding);
|
||||
return Object.entries(resolved).every(([toolId, existing]) => {
|
||||
if (toolId === excludeToolId) {
|
||||
return true;
|
||||
}
|
||||
return !bindingEquals(existing, normalized);
|
||||
});
|
||||
}, [resolved]);
|
||||
|
||||
const updateHotkey = useCallback((toolId: string, binding: HotkeyBinding) => {
|
||||
setCustomBindings(prev => {
|
||||
const normalized = normalizeBinding(binding);
|
||||
const defaultsForTool = defaults[toolId];
|
||||
const next = { ...prev };
|
||||
if (defaultsForTool && bindingEquals(defaultsForTool, normalized)) {
|
||||
delete next[toolId];
|
||||
} else {
|
||||
next[toolId] = normalized;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [defaults]);
|
||||
|
||||
const resetHotkey = useCallback((toolId: string) => {
|
||||
setCustomBindings(prev => {
|
||||
if (!(toolId in prev)) {
|
||||
return prev;
|
||||
}
|
||||
const next = { ...prev };
|
||||
delete next[toolId];
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pauseHotkeys = useCallback(() => setHotkeysPaused(true), []);
|
||||
const resumeHotkeys = useCallback(() => setHotkeysPaused(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (areHotkeysPaused) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return;
|
||||
if (shouldIgnoreTarget(event.target)) return;
|
||||
|
||||
const entries = Object.entries(resolved) as Array<[string, HotkeyBinding]>;
|
||||
for (const [toolId, binding] of entries) {
|
||||
if (bindingMatchesEvent(binding, event)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleToolSelect(toolId as ToolId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler, true);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler, true);
|
||||
};
|
||||
}, [resolved, areHotkeysPaused, handleToolSelect]);
|
||||
|
||||
const contextValue = useMemo<HotkeyContextValue>(() => ({
|
||||
hotkeys: resolved,
|
||||
defaults,
|
||||
isMac,
|
||||
updateHotkey,
|
||||
resetHotkey,
|
||||
isBindingAvailable,
|
||||
pauseHotkeys,
|
||||
resumeHotkeys,
|
||||
areHotkeysPaused,
|
||||
getDisplayParts: (binding) => getDisplayParts(binding ?? null, isMac),
|
||||
}), [resolved, defaults, isMac, updateHotkey, resetHotkey, isBindingAvailable, pauseHotkeys, resumeHotkeys, areHotkeysPaused]);
|
||||
|
||||
return (
|
||||
<HotkeyContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</HotkeyContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useHotkeys = (): HotkeyContextValue => {
|
||||
const context = useContext(HotkeyContext);
|
||||
if (!context) {
|
||||
throw new Error('useHotkeys must be used within a HotkeyProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -75,7 +75,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
// Tool management (from hook)
|
||||
selectedToolKey: string | null;
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolRegistry: any; // From useToolManagement
|
||||
toolRegistry: Record<string, ToolRegistryEntry>;
|
||||
getSelectedTool: (toolId: string | null) => ToolRegistryEntry | null;
|
||||
|
||||
// UI Actions
|
||||
@@ -231,7 +231,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
// Filter tools based on search query with fuzzy matching (name, description, id, synonyms)
|
||||
const filteredTools = useMemo(() => {
|
||||
if (!toolRegistry) return [];
|
||||
return filterToolRegistryByQuery(toolRegistry as Record<string, ToolRegistryEntry>, state.searchQuery);
|
||||
return filterToolRegistryByQuery(toolRegistry as ToolRegistry, state.searchQuery);
|
||||
}, [toolRegistry, state.searchQuery]);
|
||||
|
||||
const isPanelVisible = useMemo(() =>
|
||||
|
||||
@@ -123,6 +123,15 @@ interface ViewerContextType {
|
||||
isThumbnailSidebarVisible: boolean;
|
||||
toggleThumbnailSidebar: () => void;
|
||||
|
||||
// Annotation visibility toggle
|
||||
isAnnotationsVisible: boolean;
|
||||
toggleAnnotationsVisibility: () => void;
|
||||
|
||||
// Annotation/drawing mode for viewer
|
||||
isAnnotationMode: boolean;
|
||||
setAnnotationMode: (enabled: boolean) => void;
|
||||
toggleAnnotationMode: () => void;
|
||||
|
||||
// State getters - read current state from bridges
|
||||
getScrollState: () => ScrollState;
|
||||
getZoomState: () => ZoomState;
|
||||
@@ -208,6 +217,8 @@ interface ViewerProviderProps {
|
||||
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
// UI state - only state directly managed by this context
|
||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
||||
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
||||
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
||||
|
||||
// Get current navigation state to check if we're in sign mode
|
||||
useNavigation();
|
||||
@@ -268,6 +279,18 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
setIsThumbnailSidebarVisible(prev => !prev);
|
||||
};
|
||||
|
||||
const toggleAnnotationsVisibility = () => {
|
||||
setIsAnnotationsVisible(prev => !prev);
|
||||
};
|
||||
|
||||
const setAnnotationMode = (enabled: boolean) => {
|
||||
setIsAnnotationModeState(enabled);
|
||||
};
|
||||
|
||||
const toggleAnnotationMode = () => {
|
||||
setIsAnnotationModeState(prev => !prev);
|
||||
};
|
||||
|
||||
// State getters - read from bridge refs
|
||||
const getScrollState = (): ScrollState => {
|
||||
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
|
||||
@@ -547,6 +570,13 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
isThumbnailSidebarVisible,
|
||||
toggleThumbnailSidebar,
|
||||
|
||||
// Annotation controls
|
||||
isAnnotationsVisible,
|
||||
toggleAnnotationsVisibility,
|
||||
isAnnotationMode,
|
||||
setAnnotationMode,
|
||||
toggleAnnotationMode,
|
||||
|
||||
// State getters
|
||||
getScrollState,
|
||||
getZoomState,
|
||||
|
||||
@@ -476,7 +476,6 @@ export async function addStirlingFileStubs(
|
||||
await addFilesMutex.lock();
|
||||
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Adding ${stirlingFileStubs.length} StirlingFileStubs preserving metadata`);
|
||||
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
const validStubs: StirlingFileStub[] = [];
|
||||
@@ -515,14 +514,12 @@ export async function addStirlingFileStubs(
|
||||
record.processedFile.totalPages !== record.processedFile.pages.length;
|
||||
|
||||
if (needsProcessing) {
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerating processedFile for ${record.name}`);
|
||||
|
||||
// Use centralized metadata generation function
|
||||
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
|
||||
if (processedFileMetadata) {
|
||||
record.processedFile = processedFileMetadata;
|
||||
record.thumbnailUrl = processedFileMetadata.thumbnailUrl; // Update thumbnail if needed
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Regenerated processedFile for ${record.name} with ${processedFileMetadata.totalPages} pages`);
|
||||
} else {
|
||||
// Fallback for files that couldn't be processed
|
||||
if (DEBUG) console.warn(`📄 addStirlingFileStubs: Failed to regenerate processedFile for ${record.name}`);
|
||||
@@ -541,7 +538,6 @@ export async function addStirlingFileStubs(
|
||||
// Dispatch ADD_FILES action if we have new files
|
||||
if (validStubs.length > 0) {
|
||||
dispatch({ type: 'ADD_FILES', payload: { stirlingFileStubs: validStubs } });
|
||||
if (DEBUG) console.log(`📄 addStirlingFileStubs: Successfully added ${validStubs.length} files with preserved metadata`);
|
||||
}
|
||||
|
||||
return loadedFiles;
|
||||
|
||||
@@ -37,14 +37,14 @@ export type ToolRegistryEntry = {
|
||||
endpoints?: string[];
|
||||
link?: string;
|
||||
type?: string;
|
||||
// URL path for routing (e.g., '/split-pdfs', '/compress-pdf')
|
||||
urlPath?: string;
|
||||
// Workbench type for navigation
|
||||
workbench?: WorkbenchType;
|
||||
// Operation configuration for automation
|
||||
operationConfig?: ToolOperationConfig<any>;
|
||||
// Settings component for automation configuration
|
||||
settingsComponent?: React.ComponentType<any>;
|
||||
automationSettings: React.ComponentType<any> | null;
|
||||
// Whether this tool supports automation (defaults to true)
|
||||
supportsAutomate?: boolean;
|
||||
// Synonyms for search (optional)
|
||||
synonyms?: string[];
|
||||
}
|
||||
@@ -130,8 +130,8 @@ export const getToolWorkbench = (tool: ToolRegistryEntry): WorkbenchType => {
|
||||
/**
|
||||
* Get URL path for a tool
|
||||
*/
|
||||
export const getToolUrlPath = (toolId: string, tool: ToolRegistryEntry): string => {
|
||||
return tool.urlPath || `/${toolId.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
export const getToolUrlPath = (toolId: string): string => {
|
||||
return `/${toolId.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -140,3 +140,10 @@ export const getToolUrlPath = (toolId: string, tool: ToolRegistryEntry): string
|
||||
export const isValidToolId = (toolId: string, registry: ToolRegistry): boolean => {
|
||||
return toolId in registry;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a tool supports automation (defaults to true)
|
||||
*/
|
||||
export const getToolSupportsAutomate = (tool: ToolRegistryEntry): boolean => {
|
||||
return tool.supportsAutomate !== false;
|
||||
};
|
||||
|
||||
@@ -61,22 +61,19 @@ import { cropOperationConfig } from "../hooks/tools/crop/useCropOperation";
|
||||
import { removeAnnotationsOperationConfig } from "../hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
|
||||
import { extractImagesOperationConfig } from "../hooks/tools/extractImages/useExtractImagesOperation";
|
||||
import { replaceColorOperationConfig } from "../hooks/tools/replaceColor/useReplaceColorOperation";
|
||||
import { removePagesOperationConfig } from "../hooks/tools/removePages/useRemovePagesOperation";
|
||||
import { removeBlanksOperationConfig } from "../hooks/tools/removeBlanks/useRemoveBlanksOperation";
|
||||
import CompressSettings from "../components/tools/compress/CompressSettings";
|
||||
import SplitSettings from "../components/tools/split/SplitSettings";
|
||||
import AddPasswordSettings from "../components/tools/addPassword/AddPasswordSettings";
|
||||
import RemovePasswordSettings from "../components/tools/removePassword/RemovePasswordSettings";
|
||||
import SanitizeSettings from "../components/tools/sanitize/SanitizeSettings";
|
||||
import RepairSettings from "../components/tools/repair/RepairSettings";
|
||||
import UnlockPdfFormsSettings from "../components/tools/unlockPdfForms/UnlockPdfFormsSettings";
|
||||
import AddWatermarkSingleStepSettings from "../components/tools/addWatermark/AddWatermarkSingleStepSettings";
|
||||
import OCRSettings from "../components/tools/ocr/OCRSettings";
|
||||
import ConvertSettings from "../components/tools/convert/ConvertSettings";
|
||||
import ChangePermissionsSettings from "../components/tools/changePermissions/ChangePermissionsSettings";
|
||||
import CertificateTypeSettings from "../components/tools/certSign/CertificateTypeSettings";
|
||||
import BookletImpositionSettings from "../components/tools/bookletImposition/BookletImpositionSettings";
|
||||
import FlattenSettings from "../components/tools/flatten/FlattenSettings";
|
||||
import RedactSingleStepSettings from "../components/tools/redact/RedactSingleStepSettings";
|
||||
import RotateSettings from "../components/tools/rotate/RotateSettings";
|
||||
import Redact from "../tools/Redact";
|
||||
import AdjustPageScale from "../tools/AdjustPageScale";
|
||||
import ReplaceColor from "../tools/ReplaceColor";
|
||||
@@ -89,15 +86,22 @@ import AdjustPageScaleSettings from "../components/tools/adjustPageScale/AdjustP
|
||||
import ScannerImageSplitSettings from "../components/tools/scannerImageSplit/ScannerImageSplitSettings";
|
||||
import ChangeMetadataSingleStep from "../components/tools/changeMetadata/ChangeMetadataSingleStep";
|
||||
import SignSettings from "../components/tools/sign/SignSettings";
|
||||
import CropSettings from "../components/tools/crop/CropSettings";
|
||||
import AddPageNumbers from "../tools/AddPageNumbers";
|
||||
import { addPageNumbersOperationConfig } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
|
||||
import RemoveAnnotations from "../tools/RemoveAnnotations";
|
||||
import RemoveAnnotationsSettings from "../components/tools/removeAnnotations/RemoveAnnotationsSettings";
|
||||
import PageLayoutSettings from "../components/tools/pageLayout/PageLayoutSettings";
|
||||
import ExtractImages from "../tools/ExtractImages";
|
||||
import ExtractImagesSettings from "../components/tools/extractImages/ExtractImagesSettings";
|
||||
import ReplaceColorSettings from "../components/tools/replaceColor/ReplaceColorSettings";
|
||||
import AddStampAutomationSettings from "../components/tools/addStamp/AddStampAutomationSettings";
|
||||
import CertSignAutomationSettings from "../components/tools/certSign/CertSignAutomationSettings";
|
||||
import CropAutomationSettings from "../components/tools/crop/CropAutomationSettings";
|
||||
import RotateAutomationSettings from "../components/tools/rotate/RotateAutomationSettings";
|
||||
import SplitAutomationSettings from "../components/tools/split/SplitAutomationSettings";
|
||||
import AddAttachmentsSettings from "../components/tools/addAttachments/AddAttachmentsSettings";
|
||||
import RemovePagesSettings from "../components/tools/removePages/RemovePagesSettings";
|
||||
import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings";
|
||||
import AddPageNumbersAutomationSettings from "../components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
|
||||
|
||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||
|
||||
@@ -199,6 +203,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
synonyms: getSynonyms(t, "multiTool"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
merge: {
|
||||
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -210,7 +216,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["merge-pdfs"],
|
||||
operationConfig: mergeOperationConfig,
|
||||
settingsComponent: MergeSettings,
|
||||
automationSettings: MergeSettings,
|
||||
synonyms: getSynonyms(t, "merge")
|
||||
},
|
||||
// Signing
|
||||
@@ -225,7 +231,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["cert-sign"],
|
||||
operationConfig: certSignOperationConfig,
|
||||
settingsComponent: CertificateTypeSettings,
|
||||
automationSettings: CertSignAutomationSettings,
|
||||
},
|
||||
sign: {
|
||||
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -235,8 +241,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.SIGNING,
|
||||
operationConfig: signOperationConfig,
|
||||
settingsComponent: SignSettings,
|
||||
synonyms: getSynonyms(t, "sign")
|
||||
automationSettings: SignSettings, // TODO:: not all settings shown, suggested next tools shown
|
||||
synonyms: getSynonyms(t, "sign"),
|
||||
supportsAutomate: false, //TODO make support Sign
|
||||
},
|
||||
|
||||
// Document Security
|
||||
@@ -251,7 +258,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-password"],
|
||||
operationConfig: addPasswordOperationConfig,
|
||||
settingsComponent: AddPasswordSettings,
|
||||
automationSettings: AddPasswordSettings,
|
||||
synonyms: getSynonyms(t, "addPassword")
|
||||
},
|
||||
watermark: {
|
||||
@@ -264,7 +271,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.DOCUMENT_SECURITY,
|
||||
endpoints: ["add-watermark"],
|
||||
operationConfig: addWatermarkOperationConfig,
|
||||
settingsComponent: AddWatermarkSingleStepSettings,
|
||||
automationSettings: AddWatermarkSingleStepSettings,
|
||||
synonyms: getSynonyms(t, "watermark")
|
||||
},
|
||||
addStamp: {
|
||||
@@ -278,6 +285,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-stamp"],
|
||||
operationConfig: addStampOperationConfig,
|
||||
automationSettings: AddStampAutomationSettings,
|
||||
},
|
||||
sanitize: {
|
||||
icon: <LocalIcon icon="cleaning-services-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -289,7 +297,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.sanitize.desc", "Remove potentially harmful elements from PDF files"),
|
||||
endpoints: ["sanitize-pdf"],
|
||||
operationConfig: sanitizeOperationConfig,
|
||||
settingsComponent: SanitizeSettings,
|
||||
automationSettings: SanitizeSettings,
|
||||
synonyms: getSynonyms(t, "sanitize")
|
||||
},
|
||||
flatten: {
|
||||
@@ -302,7 +310,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["flatten"],
|
||||
operationConfig: flattenOperationConfig,
|
||||
settingsComponent: FlattenSettings,
|
||||
automationSettings: FlattenSettings,
|
||||
synonyms: getSynonyms(t, "flatten")
|
||||
},
|
||||
unlockPDFForms: {
|
||||
@@ -315,8 +323,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["unlock-pdf-forms"],
|
||||
operationConfig: unlockPdfFormsOperationConfig,
|
||||
settingsComponent: UnlockPdfFormsSettings,
|
||||
synonyms: getSynonyms(t, "unlockPDFForms"),
|
||||
automationSettings: null
|
||||
},
|
||||
changePermissions: {
|
||||
icon: <LocalIcon icon="lock-outline" width="1.5rem" height="1.5rem" />,
|
||||
@@ -328,7 +336,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-password"],
|
||||
operationConfig: changePermissionsOperationConfig,
|
||||
settingsComponent: ChangePermissionsSettings,
|
||||
automationSettings: ChangePermissionsSettings,
|
||||
synonyms: getSynonyms(t, "changePermissions"),
|
||||
},
|
||||
getPdfInfo: {
|
||||
@@ -339,6 +347,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION,
|
||||
synonyms: getSynonyms(t, "getPdfInfo"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
validateSignature: {
|
||||
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -348,6 +358,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION,
|
||||
synonyms: getSynonyms(t, "validateSignature"),
|
||||
automationSettings: null
|
||||
},
|
||||
|
||||
// Document Review
|
||||
@@ -363,7 +374,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.DOCUMENT_REVIEW,
|
||||
synonyms: getSynonyms(t, "read")
|
||||
synonyms: getSynonyms(t, "read"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
changeMetadata: {
|
||||
icon: <LocalIcon icon="assignment-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -375,7 +388,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["update-metadata"],
|
||||
operationConfig: changeMetadataOperationConfig,
|
||||
settingsComponent: ChangeMetadataSingleStep,
|
||||
automationSettings: ChangeMetadataSingleStep,
|
||||
synonyms: getSynonyms(t, "changeMetadata")
|
||||
},
|
||||
// Page Formatting
|
||||
@@ -390,7 +403,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["crop"],
|
||||
operationConfig: cropOperationConfig,
|
||||
settingsComponent: CropSettings,
|
||||
automationSettings: CropAutomationSettings,
|
||||
},
|
||||
rotate: {
|
||||
icon: <LocalIcon icon="rotate-right-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -402,7 +415,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["rotate-pdf"],
|
||||
operationConfig: rotateOperationConfig,
|
||||
settingsComponent: RotateSettings,
|
||||
automationSettings: RotateAutomationSettings,
|
||||
synonyms: getSynonyms(t, "rotate")
|
||||
},
|
||||
split: {
|
||||
@@ -413,7 +426,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
operationConfig: splitOperationConfig,
|
||||
settingsComponent: SplitSettings,
|
||||
automationSettings: SplitAutomationSettings,
|
||||
synonyms: getSynonyms(t, "split")
|
||||
},
|
||||
reorganizePages: {
|
||||
@@ -428,7 +441,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: ["rearrange-pages"],
|
||||
operationConfig: reorganizePagesOperationConfig,
|
||||
synonyms: getSynonyms(t, "reorganizePages")
|
||||
synonyms: getSynonyms(t, "reorganizePages"),
|
||||
automationSettings: null
|
||||
|
||||
},
|
||||
scalePages: {
|
||||
icon: <LocalIcon icon="crop-free-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -440,7 +455,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["scale-pages"],
|
||||
operationConfig: adjustPageScaleOperationConfig,
|
||||
settingsComponent: AdjustPageScaleSettings,
|
||||
automationSettings: AdjustPageScaleSettings,
|
||||
synonyms: getSynonyms(t, "scalePages")
|
||||
},
|
||||
addPageNumbers: {
|
||||
@@ -450,6 +465,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.addPageNumbers.desc", "Add Page numbers throughout a document in a set location"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
automationSettings: AddPageNumbersAutomationSettings,
|
||||
maxFiles: -1,
|
||||
endpoints: ["add-page-numbers"],
|
||||
operationConfig: addPageNumbersOperationConfig,
|
||||
@@ -464,7 +480,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
endpoints: ["multi-page-layout"],
|
||||
settingsComponent: PageLayoutSettings,
|
||||
automationSettings: PageLayoutSettings,
|
||||
synonyms: getSynonyms(t, "pageLayout")
|
||||
},
|
||||
bookletImposition: {
|
||||
@@ -472,7 +488,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
name: t("home.bookletImposition.title", "Booklet Imposition"),
|
||||
component: BookletImposition,
|
||||
operationConfig: bookletImpositionOperationConfig,
|
||||
settingsComponent: BookletImpositionSettings,
|
||||
automationSettings: BookletImpositionSettings,
|
||||
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
@@ -487,10 +503,10 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
maxFiles: -1,
|
||||
urlPath: '/pdf-to-single-page',
|
||||
endpoints: ["pdf-to-single-page"],
|
||||
operationConfig: singleLargePageOperationConfig,
|
||||
synonyms: getSynonyms(t, "pdfToSinglePage")
|
||||
synonyms: getSynonyms(t, "pdfToSinglePage"),
|
||||
automationSettings: null,
|
||||
},
|
||||
addAttachments: {
|
||||
icon: <LocalIcon icon="attachment-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -503,6 +519,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: 1,
|
||||
endpoints: ["add-attachments"],
|
||||
operationConfig: addAttachmentsOperationConfig,
|
||||
automationSettings: AddAttachmentsSettings,
|
||||
},
|
||||
|
||||
// Extraction
|
||||
@@ -514,7 +531,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.extractPages.desc", "Extract specific pages from a PDF document"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.EXTRACTION,
|
||||
synonyms: getSynonyms(t, "extractPages")
|
||||
synonyms: getSynonyms(t, "extractPages"),
|
||||
automationSettings: null,
|
||||
},
|
||||
extractImages: {
|
||||
icon: <LocalIcon icon="photo-library-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -526,7 +544,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["extract-images"],
|
||||
operationConfig: extractImagesOperationConfig,
|
||||
settingsComponent: ExtractImagesSettings,
|
||||
automationSettings: ExtractImagesSettings,
|
||||
synonyms: getSynonyms(t, "extractImages")
|
||||
},
|
||||
|
||||
@@ -541,7 +559,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: 1,
|
||||
endpoints: ["remove-pages"],
|
||||
synonyms: getSynonyms(t, "removePages")
|
||||
synonyms: getSynonyms(t, "removePages"),
|
||||
operationConfig: removePagesOperationConfig,
|
||||
automationSettings: RemovePagesSettings,
|
||||
},
|
||||
removeBlanks: {
|
||||
icon: <LocalIcon icon="scan-delete-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -552,7 +572,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: 1,
|
||||
endpoints: ["remove-blanks"],
|
||||
synonyms: getSynonyms(t, "removeBlanks")
|
||||
synonyms: getSynonyms(t, "removeBlanks"),
|
||||
operationConfig: removeBlanksOperationConfig,
|
||||
automationSettings: RemoveBlanksSettings,
|
||||
},
|
||||
removeAnnotations: {
|
||||
icon: <LocalIcon icon="thread-unread-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -563,7 +585,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
operationConfig: removeAnnotationsOperationConfig,
|
||||
settingsComponent: RemoveAnnotationsSettings,
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "removeAnnotations")
|
||||
},
|
||||
removeImage: {
|
||||
@@ -577,6 +599,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["remove-image-pdf"],
|
||||
operationConfig: undefined,
|
||||
synonyms: getSynonyms(t, "removeImage"),
|
||||
automationSettings: null,
|
||||
},
|
||||
removePassword: {
|
||||
icon: <LocalIcon icon="lock-open-right-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -588,7 +611,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["remove-password"],
|
||||
maxFiles: -1,
|
||||
operationConfig: removePasswordOperationConfig,
|
||||
settingsComponent: RemovePasswordSettings,
|
||||
automationSettings: RemovePasswordSettings,
|
||||
synonyms: getSynonyms(t, "removePassword")
|
||||
},
|
||||
removeCertSign: {
|
||||
@@ -602,6 +625,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
operationConfig: removeCertificateSignOperationConfig,
|
||||
synonyms: getSynonyms(t, "removeCertSign"),
|
||||
automationSettings: null,
|
||||
},
|
||||
|
||||
// Automation
|
||||
@@ -620,6 +644,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
supportedFormats: CONVERT_SUPPORTED_FORMATS,
|
||||
endpoints: ["handleData"],
|
||||
synonyms: getSynonyms(t, "automate"),
|
||||
automationSettings: null,
|
||||
},
|
||||
autoRename: {
|
||||
icon: <LocalIcon icon="match-word-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -632,6 +657,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.AUTOMATION,
|
||||
synonyms: getSynonyms(t, "autoRename"),
|
||||
automationSettings: null,
|
||||
},
|
||||
|
||||
// Advanced Formatting
|
||||
@@ -644,6 +670,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "adjustContrast"),
|
||||
automationSettings: null,
|
||||
},
|
||||
repair: {
|
||||
icon: <LocalIcon icon="build-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -655,8 +682,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["repair"],
|
||||
operationConfig: repairOperationConfig,
|
||||
settingsComponent: RepairSettings,
|
||||
synonyms: getSynonyms(t, "repair")
|
||||
synonyms: getSynonyms(t, "repair"),
|
||||
automationSettings: null
|
||||
},
|
||||
scannerImageSplit: {
|
||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -668,7 +695,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["extract-image-scans"],
|
||||
operationConfig: scannerImageSplitOperationConfig,
|
||||
settingsComponent: ScannerImageSplitSettings,
|
||||
automationSettings: ScannerImageSplitSettings,
|
||||
synonyms: getSynonyms(t, "ScannerImageSplit"),
|
||||
},
|
||||
overlayPdfs: {
|
||||
@@ -679,6 +706,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "overlayPdfs"),
|
||||
automationSettings: null
|
||||
},
|
||||
replaceColor: {
|
||||
icon: <LocalIcon icon="format-color-fill-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -690,7 +718,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["replace-invert-pdf"],
|
||||
operationConfig: replaceColorOperationConfig,
|
||||
settingsComponent: ReplaceColorSettings,
|
||||
automationSettings: ReplaceColorSettings,
|
||||
synonyms: getSynonyms(t, "replaceColor"),
|
||||
},
|
||||
addImage: {
|
||||
@@ -701,6 +729,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "addImage"),
|
||||
automationSettings: null
|
||||
},
|
||||
editTableOfContents: {
|
||||
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -710,6 +739,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "editTableOfContents"),
|
||||
automationSettings: null
|
||||
},
|
||||
scannerEffect: {
|
||||
icon: <LocalIcon icon="scanner-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -719,6 +749,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
synonyms: getSynonyms(t, "scannerEffect"),
|
||||
automationSettings: null
|
||||
},
|
||||
|
||||
// Developer Tools
|
||||
@@ -731,6 +762,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
synonyms: getSynonyms(t, "showJS"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
devApi: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -741,6 +774,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://stirlingpdf.io/swagger-ui/5.21.0/index.html",
|
||||
synonyms: getSynonyms(t, "devApi"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
devFolderScanning: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -751,6 +786,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Folder%20Scanning/",
|
||||
synonyms: getSynonyms(t, "devFolderScanning"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
devSsoGuide: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -761,6 +798,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration",
|
||||
synonyms: getSynonyms(t, "devSsoGuide"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
devAirgapped: {
|
||||
icon: <LocalIcon icon="open-in-new-rounded" width="1.5rem" height="1.5rem" style={{ color: "#2F7BF6" }} />,
|
||||
@@ -771,6 +810,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.DEVELOPER_TOOLS,
|
||||
link: "https://docs.stirlingpdf.com/Pro/#activation",
|
||||
synonyms: getSynonyms(t, "devAirgapped"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
|
||||
// Recommended Tools
|
||||
@@ -781,7 +822,9 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
description: t("home.compare.desc", "Compare two PDF documents and highlight differences"),
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
synonyms: getSynonyms(t, "compare")
|
||||
synonyms: getSynonyms(t, "compare"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
},
|
||||
compress: {
|
||||
icon: <LocalIcon icon="zoom-in-map-rounded" width="1.5rem" height="1.5rem" />,
|
||||
@@ -792,7 +835,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
operationConfig: compressOperationConfig,
|
||||
settingsComponent: CompressSettings,
|
||||
automationSettings: CompressSettings,
|
||||
synonyms: getSynonyms(t, "compress")
|
||||
},
|
||||
convert: {
|
||||
@@ -822,7 +865,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
],
|
||||
|
||||
operationConfig: convertOperationConfig,
|
||||
settingsComponent: ConvertSettings,
|
||||
automationSettings: ConvertSettings,
|
||||
synonyms: getSynonyms(t, "convert")
|
||||
},
|
||||
|
||||
@@ -834,9 +877,8 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
urlPath: '/ocr-pdf',
|
||||
operationConfig: ocrOperationConfig,
|
||||
settingsComponent: OCRSettings,
|
||||
automationSettings: OCRSettings,
|
||||
synonyms: getSynonyms(t, "ocr")
|
||||
},
|
||||
redact: {
|
||||
@@ -849,7 +891,7 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
maxFiles: -1,
|
||||
endpoints: ["auto-redact"],
|
||||
operationConfig: redactOperationConfig,
|
||||
settingsComponent: RedactSingleStepSettings,
|
||||
automationSettings: RedactSingleStepSettings,
|
||||
synonyms: getSynonyms(t, "redact")
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AutomationTool, AutomationConfig, AutomationMode } from '../../../types/automation';
|
||||
import { AUTOMATION_CONSTANTS } from '../../../constants/automation';
|
||||
import { ToolRegistry } from '../../../data/toolsTaxonomy';
|
||||
import { ToolId } from 'src/types/toolId';
|
||||
|
||||
|
||||
interface UseAutomationFormProps {
|
||||
mode: AutomationMode;
|
||||
@@ -41,11 +43,15 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
|
||||
const operations = existingAutomation.operations || [];
|
||||
const tools = operations.map((op, index) => {
|
||||
const operation = typeof op === 'string' ? op : op.operation;
|
||||
const toolEntry = toolRegistry[operation as ToolId];
|
||||
// If tool has no settingsComponent, it's automatically configured
|
||||
const isConfigured = mode === AutomationMode.EDIT ? true : !toolEntry?.automationSettings;
|
||||
|
||||
return {
|
||||
id: `${operation}-${Date.now()}-${index}`,
|
||||
operation: operation,
|
||||
name: getToolName(operation),
|
||||
configured: mode === AutomationMode.EDIT ? true : false,
|
||||
configured: isConfigured,
|
||||
parameters: typeof op === 'object' ? op.parameters || {} : {}
|
||||
};
|
||||
});
|
||||
@@ -65,11 +71,15 @@ export function useAutomationForm({ mode, existingAutomation, toolRegistry }: Us
|
||||
}, [mode, existingAutomation, t, getToolName]);
|
||||
|
||||
const addTool = (operation: string) => {
|
||||
const toolEntry = toolRegistry[operation as ToolId];
|
||||
// If tool has no settingsComponent, it's automatically configured
|
||||
const isConfigured = !toolEntry?.automationSettings;
|
||||
|
||||
const newTool: AutomationTool = {
|
||||
id: `${operation}-${Date.now()}`,
|
||||
operation,
|
||||
name: getToolName(operation),
|
||||
configured: false,
|
||||
configured: isConfigured,
|
||||
parameters: getToolDefaultParameters(operation)
|
||||
};
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import React from 'react';
|
||||
import LocalIcon from '../../../components/shared/LocalIcon';
|
||||
import { SuggestedAutomation } from '../../../types/automation';
|
||||
import { SPLIT_METHODS } from '../../../constants/splitConstants';
|
||||
|
||||
// Create icon components
|
||||
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
|
||||
@@ -83,18 +84,18 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
|
||||
}
|
||||
},
|
||||
{
|
||||
operation: "splitPdf",
|
||||
operation: "split",
|
||||
parameters: {
|
||||
mode: 'bySizeOrCount',
|
||||
method: SPLIT_METHODS.BY_SIZE,
|
||||
pages: '',
|
||||
hDiv: '1',
|
||||
vDiv: '1',
|
||||
merge: false,
|
||||
splitType: 'size',
|
||||
splitValue: '20MB',
|
||||
bookmarkLevel: '1',
|
||||
includeMetadata: false,
|
||||
allowDuplicates: false,
|
||||
duplexMode: false,
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -70,7 +70,6 @@ async function processRequestQueue() {
|
||||
const pageNumbers = requests.map(req => req.pageNumber);
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
|
||||
console.log(`📸 Batch generating ${requests.length} thumbnails for pages: ${pageNumbers.slice(0, 5).join(', ')}${pageNumbers.length > 5 ? '...' : ''}`);
|
||||
|
||||
// Use quickKey for PDF document caching (same metadata, consistent format)
|
||||
const fileId = createQuickKey(file) as FileId;
|
||||
@@ -80,9 +79,8 @@ async function processRequestQueue() {
|
||||
arrayBuffer,
|
||||
pageNumbers,
|
||||
{ scale: 1.0, quality: 0.8, batchSize: BATCH_SIZE },
|
||||
(progress) => {
|
||||
(_progress) => {
|
||||
// Optional: Could emit progress events here for UI feedback
|
||||
console.log(`📸 Batch progress: ${progress.completed}/${progress.total} thumbnails generated`);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useToolNavigation(): {
|
||||
|
||||
const getToolNavigation = useCallback((toolId: string, tool: ToolRegistryEntry): ToolNavigationProps => {
|
||||
// Generate SSR-safe relative path
|
||||
const path = getToolUrlPath(toolId, tool);
|
||||
const path = getToolUrlPath(toolId);
|
||||
const href = path; // Relative path, no window.location needed
|
||||
|
||||
// Click handler that maintains SPA behavior
|
||||
|
||||
@@ -15,6 +15,7 @@ import RightRail from "../components/shared/RightRail";
|
||||
import FileManager from "../components/FileManager";
|
||||
import LocalIcon from "../components/shared/LocalIcon";
|
||||
import { useFilesModalContext } from "../contexts/FilesModalContext";
|
||||
import AppConfigModal from "../components/shared/AppConfigModal";
|
||||
|
||||
import "./HomePage.css";
|
||||
|
||||
@@ -37,6 +38,7 @@ export default function HomePage() {
|
||||
const sliderRef = useRef<HTMLDivElement | null>(null);
|
||||
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
|
||||
const isProgrammaticScroll = useRef(false);
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
|
||||
@@ -207,8 +209,20 @@ export default function HomePage() {
|
||||
<LocalIcon icon="folder-rounded" width="1.5rem" height="1.5rem" />
|
||||
<span className="mobile-bottom-button-label">{t('quickAccess.files', 'Files')}</span>
|
||||
</button>
|
||||
<button
|
||||
className="mobile-bottom-button"
|
||||
aria-label={t('quickAccess.config', 'Config')}
|
||||
onClick={() => setConfigModalOpen(true)}
|
||||
>
|
||||
<LocalIcon icon="settings-rounded" width="1.5rem" height="1.5rem" />
|
||||
<span className="mobile-bottom-button-label">{t('quickAccess.config', 'Config')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
<AppConfigModal
|
||||
opened={configModalOpen}
|
||||
onClose={() => setConfigModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Group
|
||||
|
||||
@@ -224,6 +224,15 @@
|
||||
--bulk-card-hover-border: #d1d5db; /* slightly darker on hover */
|
||||
--unsupported-bar-bg: #5a616e;
|
||||
--unsupported-bar-border: #6B7280;
|
||||
|
||||
/* Config Modal colors (light mode) */
|
||||
--modal-nav-bg: #F5F6F8;
|
||||
--modal-nav-section-title: #6B7280;
|
||||
--modal-nav-item: #374151;
|
||||
--modal-nav-item-active: #0A8BFF;
|
||||
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.08);
|
||||
--modal-content-bg: #ffffff;
|
||||
--modal-header-border: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
@@ -414,6 +423,15 @@
|
||||
--bulk-card-hover-border: var(--border-strong); /* stronger border on hover */
|
||||
--unsupported-bar-bg: #1F2329;
|
||||
--unsupported-bar-border: #4B525A;
|
||||
|
||||
/* Config Modal colors (dark mode) */
|
||||
--modal-nav-bg: #1F2329;
|
||||
--modal-nav-section-title: #9CA3AF;
|
||||
--modal-nav-item: #D0D6DC;
|
||||
--modal-nav-item-active: #0A8BFF;
|
||||
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.15);
|
||||
--modal-content-bg: #2A2F36;
|
||||
--modal-header-border: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Dropzone drop state styling */
|
||||
|
||||
@@ -6,10 +6,8 @@ import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useAddAttachmentsParameters } from "../hooks/tools/addAttachments/useAddAttachmentsParameters";
|
||||
import { useAddAttachmentsOperation } from "../hooks/tools/addAttachments/useAddAttachmentsOperation";
|
||||
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
|
||||
import LocalIcon from "../components/shared/LocalIcon";
|
||||
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
|
||||
// Removed FitText for two-line wrapping with clamping
|
||||
import AddAttachmentsSettings from "../components/tools/addAttachments/AddAttachmentsSettings";
|
||||
|
||||
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -67,99 +65,11 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
|
||||
isVisible: true,
|
||||
content: (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
|
||||
</Text>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
// Append to existing attachments instead of replacing
|
||||
const newAttachments = [...params.parameters.attachments, ...files];
|
||||
params.updateParameter('attachments', newAttachments);
|
||||
// Reset the input so the same file can be selected again
|
||||
e.target.value = '';
|
||||
}}
|
||||
disabled={endpointLoading}
|
||||
style={{ display: 'none' }}
|
||||
id="attachments-input"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
color="blue"
|
||||
component="label"
|
||||
htmlFor="attachments-input"
|
||||
disabled={endpointLoading}
|
||||
leftSection={<LocalIcon icon="plus" width="14" height="14" />}
|
||||
>
|
||||
{params.parameters.attachments.length > 0
|
||||
? t("AddAttachmentsRequest.addMoreFiles", "Add more files...")
|
||||
: t("AddAttachmentsRequest.placeholder", "Choose files...")
|
||||
}
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{params.parameters.attachments && params.parameters.attachments.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({params.parameters.attachments.length})
|
||||
</Text>
|
||||
<ScrollArea.Autosize mah={300} type="scroll" offsetScrollbars styles={{ viewport: { overflowX: 'hidden' } }}>
|
||||
<Stack gap="xs">
|
||||
{params.parameters.attachments.map((file, index) => (
|
||||
<Group key={index} justify="space-between" p="xs" style={{ border: '1px solid var(--mantine-color-gray-3)', borderRadius: 'var(--mantine-radius-sm)', alignItems: 'flex-start' }}>
|
||||
<Group gap="xs" style={{ flex: 1, minWidth: 0, alignItems: 'flex-start' }}>
|
||||
{/* Filename (two-line clamp, wraps, no icon on the left) */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'var(--mantine-font-size-sm)',
|
||||
fontWeight: 400,
|
||||
lineHeight: 1.2,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2 as any,
|
||||
WebkitBoxOrient: 'vertical' as any,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
({(file.size / 1024).toFixed(1)} KB)
|
||||
</Text>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
style={{ flexShrink: 0 }}
|
||||
onClick={() => {
|
||||
const newAttachments = params.parameters.attachments.filter((_, i) => i !== index);
|
||||
params.updateParameter('attachments', newAttachments);
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="14" height="14" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<AddAttachmentsSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,10 +6,9 @@ import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useAddPageNumbersParameters } from "../components/tools/addPageNumbers/useAddPageNumbersParameters";
|
||||
import { useAddPageNumbersOperation } from "../components/tools/addPageNumbers/useAddPageNumbersOperation";
|
||||
import { Select, Stack, TextInput, NumberInput, Divider, Text } from "@mantine/core";
|
||||
import { Tooltip } from "../components/shared/Tooltip";
|
||||
import PageNumberPreview from "../components/tools/addPageNumbers/PageNumberPreview";
|
||||
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
|
||||
import AddPageNumbersPositionSettings from "../components/tools/addPageNumbers/AddPageNumbersPositionSettings";
|
||||
import AddPageNumbersAppearanceSettings from "../components/tools/addPageNumbers/AddPageNumbersAppearanceSettings";
|
||||
|
||||
const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -68,44 +67,13 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.POSITION_AND_PAGES),
|
||||
isVisible: hasFiles || hasResults,
|
||||
content: (
|
||||
<Stack gap="lg">
|
||||
{/* Position Selection */}
|
||||
<Stack gap="md">
|
||||
<PageNumberPreview
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
file={selectedFiles[0] || null}
|
||||
showQuickGrid={true}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Pages & Starting Number Section */}
|
||||
<Stack gap="md">
|
||||
<Text size="sm" fw={500} mb="xs">{t('addPageNumbers.pagesAndStarting', 'Pages & Starting Number')}</Text>
|
||||
|
||||
<Tooltip content={t('pageSelectionPrompt', 'Specify which pages to add numbers to. Examples: "1,3,5" for specific pages, "1-5" for ranges, "2n" for even pages, or leave blank for all pages.')}>
|
||||
<TextInput
|
||||
label={t('addPageNumbers.selectText.5', 'Pages to Number')}
|
||||
value={params.parameters.pagesToNumber}
|
||||
onChange={(e) => params.updateParameter('pagesToNumber', e.currentTarget.value)}
|
||||
placeholder={t('addPageNumbers.numberPagesDesc', 'e.g., 1,3,5-8 or leave blank for all pages')}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('startingNumberTooltip', 'The first number to display. Subsequent pages will increment from this number.')}>
|
||||
<NumberInput
|
||||
label={t('addPageNumbers.selectText.4', 'Starting Number')}
|
||||
value={params.parameters.startingNumber}
|
||||
onChange={(v) => params.updateParameter('startingNumber', typeof v === 'number' ? v : 1)}
|
||||
min={1}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<AddPageNumbersPositionSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
file={selectedFiles[0] || null}
|
||||
showQuickGrid={true}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -116,56 +84,11 @@ const AddPageNumbers = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddPageNumbersStep.CUSTOMIZE),
|
||||
isVisible: hasFiles || hasResults,
|
||||
content: (
|
||||
<Stack gap="md">
|
||||
<Tooltip content={t('marginTooltip', 'Distance between the page number and the edge of the page.')}>
|
||||
<Select
|
||||
label={t('addPageNumbers.selectText.2', 'Margin')}
|
||||
value={params.parameters.customMargin}
|
||||
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
|
||||
data={[
|
||||
{ value: 'small', label: t('sizes.small', 'Small') },
|
||||
{ value: 'medium', label: t('sizes.medium', 'Medium') },
|
||||
{ value: 'large', label: t('sizes.large', 'Large') },
|
||||
{ value: 'x-large', label: t('sizes.x-large', 'Extra Large') },
|
||||
]}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('fontSizeTooltip', 'Size of the page number text in points. Larger numbers create bigger text.')}>
|
||||
<NumberInput
|
||||
label={t('addPageNumbers.fontSize', 'Font Size')}
|
||||
value={params.parameters.fontSize}
|
||||
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 12)}
|
||||
min={1}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('fontTypeTooltip', 'Font family for the page numbers. Choose based on your document style.')}>
|
||||
<Select
|
||||
label={t('addPageNumbers.fontName', 'Font Type')}
|
||||
value={params.parameters.fontType}
|
||||
onChange={(v) => params.updateParameter('fontType', (v as any) || 'Times')}
|
||||
data={[
|
||||
{ value: 'Times', label: 'Times Roman' },
|
||||
{ value: 'Helvetica', label: 'Helvetica' },
|
||||
{ value: 'Courier', label: 'Courier New' },
|
||||
]}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={t('customTextTooltip', 'Optional custom format for page numbers. Use {n} as placeholder for the number. Example: "Page {n}" will show "Page 1", "Page 2", etc.')}>
|
||||
<TextInput
|
||||
label={t('addPageNumbers.selectText.6', 'Custom Text Format')}
|
||||
value={params.parameters.customText}
|
||||
onChange={(e) => params.updateParameter('customText', e.currentTarget.value)}
|
||||
placeholder={t('addPageNumbers.customNumberDesc', 'e.g., "Page {n}" or leave blank for just numbers')}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
<AddPageNumbersAppearanceSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
|
||||
+16
-241
@@ -6,15 +6,14 @@ import { BaseToolProps, ToolComponent } from "../types/tool";
|
||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||
import { useAddStampParameters } from "../components/tools/addStamp/useAddStampParameters";
|
||||
import { useAddStampOperation } from "../components/tools/addStamp/useAddStampOperation";
|
||||
import { Group, Select, Stack, Textarea, TextInput, ColorInput, Button, Slider, Text, NumberInput, Divider } from "@mantine/core";
|
||||
import { Stack, Text } from "@mantine/core";
|
||||
import StampPreview from "../components/tools/addStamp/StampPreview";
|
||||
import LocalIcon from "../components/shared/LocalIcon";
|
||||
import styles from "../components/tools/addStamp/StampPreview.module.css";
|
||||
import { Tooltip } from "../components/shared/Tooltip";
|
||||
import ButtonSelector from "../components/shared/ButtonSelector";
|
||||
import { useAccordionSteps } from "../hooks/tools/shared/useAccordionSteps";
|
||||
import ObscuredOverlay from "../components/shared/ObscuredOverlay";
|
||||
import { getDefaultFontSizeForAlphabet } from "../components/tools/addStamp/StampPreviewUtils";
|
||||
import StampSetupSettings from "../components/tools/addStamp/StampSetupSettings";
|
||||
import StampPositionFormattingSettings from "../components/tools/addStamp/StampPositionFormattingSettings";
|
||||
|
||||
const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -70,108 +69,22 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const getSteps = () => {
|
||||
const steps: any[] = [];
|
||||
|
||||
// Step 1: Stamp Setup
|
||||
// Step 1: Stamp Setup
|
||||
steps.push({
|
||||
title: t("AddStampRequest.stampSetup", "Stamp Setup"),
|
||||
isCollapsed: accordion.getCollapsedState(AddStampStep.STAMP_SETUP),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddStampStep.STAMP_SETUP),
|
||||
isVisible: hasFiles || hasResults,
|
||||
content: (
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('pageSelectionPrompt', 'Page Selection (e.g. 1,3,2 or 4-8,2,10-12 or 2n-1)')}
|
||||
value={params.parameters.pageNumbers}
|
||||
onChange={(e) => params.updateParameter('pageNumbers', e.currentTarget.value)}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
<Divider/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb="xs">{t('AddStampRequest.stampType', 'Stamp Type')}</Text>
|
||||
<ButtonSelector
|
||||
value={params.parameters.stampType}
|
||||
onChange={(v: 'text' | 'image') => params.updateParameter('stampType', v)}
|
||||
options={[
|
||||
{ value: 'text', label: t('watermark.type.1', 'Text') },
|
||||
{ value: 'image', label: t('watermark.type.2', 'Image') },
|
||||
]}
|
||||
disabled={endpointLoading}
|
||||
buttonClassName={styles.modeToggleButton}
|
||||
textClassName={styles.modeToggleButtonText}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{params.parameters.stampType === 'text' && (
|
||||
<>
|
||||
<Textarea
|
||||
label={t('AddStampRequest.stampText', 'Stamp Text')}
|
||||
value={params.parameters.stampText}
|
||||
onChange={(e) => params.updateParameter('stampText', e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
<Select
|
||||
label={t('AddStampRequest.alphabet', 'Alphabet')}
|
||||
value={params.parameters.alphabet}
|
||||
onChange={(v) => {
|
||||
const nextAlphabet = (v as any) || 'roman';
|
||||
params.updateParameter('alphabet', nextAlphabet);
|
||||
const nextDefault = getDefaultFontSizeForAlphabet(nextAlphabet);
|
||||
params.updateParameter('fontSize', nextDefault);
|
||||
}}
|
||||
data={[
|
||||
{ value: 'roman', label: 'Roman' },
|
||||
{ value: 'arabic', label: 'العربية' },
|
||||
{ value: 'japanese', label: '日本語' },
|
||||
{ value: 'korean', label: '한국어' },
|
||||
{ value: 'chinese', label: '简体中文' },
|
||||
{ value: 'thai', label: 'ไทย' },
|
||||
]}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{params.parameters.stampType === 'image' && (
|
||||
<Stack gap="xs">
|
||||
<input
|
||||
type="file"
|
||||
accept=".png,.jpg,.jpeg,.gif,.bmp,.tiff,.tif,.webp"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) params.updateParameter('stampImage', file);
|
||||
}}
|
||||
disabled={endpointLoading}
|
||||
style={{ display: 'none' }}
|
||||
id="stamp-image-input"
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
component="label"
|
||||
htmlFor="stamp-image-input"
|
||||
disabled={endpointLoading}
|
||||
>
|
||||
{t('chooseFile', 'Choose File')}
|
||||
</Button>
|
||||
{params.parameters.stampImage && (
|
||||
<Stack gap="xs">
|
||||
<img
|
||||
src={URL.createObjectURL(params.parameters.stampImage)}
|
||||
alt="Selected stamp image"
|
||||
className="max-h-24 w-full object-contain border border-gray-200 rounded bg-gray-50"
|
||||
/>
|
||||
<Text size="xs" c="dimmed">
|
||||
{params.parameters.stampImage.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
<StampSetupSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
// Step 3: Formatting & Position
|
||||
// Step 2: Formatting & Position
|
||||
steps.push({
|
||||
title: t("AddStampRequest.positionAndFormatting", "Position & Formatting"),
|
||||
isCollapsed: accordion.getCollapsedState(AddStampStep.POSITION_FORMATTING),
|
||||
@@ -209,151 +122,13 @@ const AddStamp = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StampPositionFormattingSettings
|
||||
parameters={params.parameters}
|
||||
onParameterChange={params.updateParameter}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
|
||||
{/* Icon pill buttons row */}
|
||||
<div className="flex justify-between gap-[0.5rem]">
|
||||
<Tooltip content={t('AddStampRequest.rotation', 'Rotation')} position="top">
|
||||
<Button
|
||||
variant={params.parameters._activePill === 'rotation' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => params.updateParameter('_activePill', 'rotation')}
|
||||
>
|
||||
<LocalIcon icon="rotate-right-rounded" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('AddStampRequest.opacity', 'Opacity')} position="top">
|
||||
<Button
|
||||
variant={params.parameters._activePill === 'opacity' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => params.updateParameter('_activePill', 'opacity')}
|
||||
>
|
||||
<LocalIcon icon="opacity" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content={params.parameters.stampType === 'image' ? t('AddStampRequest.imageSize', 'Image Size') : t('AddStampRequest.fontSize', 'Font Size')} position="top">
|
||||
<Button
|
||||
variant={params.parameters._activePill === 'fontSize' ? 'filled' : 'outline'}
|
||||
className="flex-1"
|
||||
onClick={() => params.updateParameter('_activePill', 'fontSize')}
|
||||
>
|
||||
<LocalIcon icon="zoom-in-map-rounded" width="1.1rem" height="1.1rem" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Single slider bound to selected pill */}
|
||||
{params.parameters._activePill === 'fontSize' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>
|
||||
{params.parameters.stampType === 'image'
|
||||
? t('AddStampRequest.imageSize', 'Image Size')
|
||||
: t('AddStampRequest.fontSize', 'Font Size')
|
||||
}
|
||||
</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={params.parameters.fontSize}
|
||||
onChange={(v) => params.updateParameter('fontSize', typeof v === 'number' ? v : 1)}
|
||||
min={1}
|
||||
max={400}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
<Slider
|
||||
value={params.parameters.fontSize}
|
||||
onChange={(v) => params.updateParameter('fontSize', v as number)}
|
||||
min={1}
|
||||
max={400}
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{params.parameters._activePill === 'rotation' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>{t('AddStampRequest.rotation', 'Rotation')}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={params.parameters.rotation}
|
||||
onChange={(v) => params.updateParameter('rotation', typeof v === 'number' ? v : 0)}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
hideControls
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
<Slider
|
||||
value={params.parameters.rotation}
|
||||
onChange={(v) => params.updateParameter('rotation', v as number)}
|
||||
min={-180}
|
||||
max={180}
|
||||
step={1}
|
||||
className={styles.sliderWide}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
{params.parameters._activePill === 'opacity' && (
|
||||
<Stack gap="xs">
|
||||
<Text className={styles.labelText}>{t('AddStampRequest.opacity', 'Opacity')}</Text>
|
||||
<Group className={styles.sliderGroup} align="center">
|
||||
<NumberInput
|
||||
value={params.parameters.opacity}
|
||||
onChange={(v) => params.updateParameter('opacity', typeof v === 'number' ? v : 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
size="sm"
|
||||
className={styles.numberInput}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
<Slider
|
||||
value={params.parameters.opacity}
|
||||
onChange={(v) => params.updateParameter('opacity', v as number)}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
className={styles.slider}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
|
||||
{params.parameters.stampType !== 'image' && (
|
||||
<ColorInput
|
||||
label={t('AddStampRequest.customColor', 'Custom Text Color')}
|
||||
value={params.parameters.customColor}
|
||||
onChange={(value) => params.updateParameter('customColor', value)}
|
||||
format="hex"
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Margin selection appears when using quick grid (and for text stamps) */}
|
||||
{(params.parameters.stampType === 'text' || (params.parameters.stampType === 'image' && quickPositionModeSelected)) && (
|
||||
<Select
|
||||
label={t('AddStampRequest.margin', 'Margin')}
|
||||
value={params.parameters.customMargin}
|
||||
onChange={(v) => params.updateParameter('customMargin', (v as any) || 'medium')}
|
||||
data={[
|
||||
{ value: 'small', label: t('margin.small', 'Small') },
|
||||
{ value: 'medium', label: t('margin.medium', 'Medium') },
|
||||
{ value: 'large', label: t('margin.large', 'Large') },
|
||||
{ value: 'x-large', label: t('margin.xLarge', 'Extra Large') },
|
||||
]}
|
||||
disabled={endpointLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* Unified preview wrapped with obscured overlay if no stamp selected in step 4 */}
|
||||
{/* Unified preview wrapped with obscured overlay if no stamp selected */}
|
||||
<ObscuredOverlay
|
||||
obscured={
|
||||
accordion.currentStep === AddStampStep.POSITION_FORMATTING &&
|
||||
|
||||
@@ -5,6 +5,127 @@ import { AutomationFileProcessor } from './automationFileProcessor';
|
||||
import { ToolType } from '../hooks/tools/shared/useToolOperation';
|
||||
import { processResponse } from './toolResponseProcessor';
|
||||
|
||||
/**
|
||||
* Process multi-file tool response (handles ZIP or single PDF responses)
|
||||
*/
|
||||
const processMultiFileResponse = async (
|
||||
responseData: Blob,
|
||||
responseHeaders: any,
|
||||
files: File[],
|
||||
filePrefix: string,
|
||||
preserveBackendFilename?: boolean
|
||||
): Promise<File[]> => {
|
||||
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
|
||||
if (responseData.type === 'application/pdf' ||
|
||||
(responseHeaders && responseHeaders['content-type'] === 'application/pdf')) {
|
||||
// Single PDF response - use processResponse to respect preserveBackendFilename
|
||||
const processedFiles = await processResponse(
|
||||
responseData,
|
||||
files,
|
||||
filePrefix,
|
||||
undefined,
|
||||
preserveBackendFilename ? responseHeaders : undefined
|
||||
);
|
||||
return processedFiles;
|
||||
} else {
|
||||
// ZIP response
|
||||
const result = await AutomationFileProcessor.extractAutomationZipFiles(responseData);
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.warn(`⚠️ File processing warnings:`, result.errors);
|
||||
}
|
||||
|
||||
// Apply prefix to files, replacing any existing prefix
|
||||
const processedFiles = filePrefix && !preserveBackendFilename
|
||||
? result.files.map(file => {
|
||||
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
|
||||
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
|
||||
})
|
||||
: result.files;
|
||||
|
||||
return processedFiles;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Core execution function for API requests
|
||||
*/
|
||||
const executeApiRequest = async (
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
files: File[],
|
||||
filePrefix: string,
|
||||
preserveBackendFilename?: boolean
|
||||
): Promise<File[]> => {
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
responseType: 'blob',
|
||||
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
|
||||
});
|
||||
|
||||
return await processMultiFileResponse(
|
||||
response.data,
|
||||
response.headers,
|
||||
files,
|
||||
filePrefix,
|
||||
preserveBackendFilename
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute single-file tool operation (processes files one at a time)
|
||||
*/
|
||||
const executeSingleFileOperation = async (
|
||||
config: any,
|
||||
parameters: any,
|
||||
files: File[],
|
||||
filePrefix: string
|
||||
): Promise<File[]> => {
|
||||
const resultFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const endpoint = typeof config.endpoint === 'function'
|
||||
? config.endpoint(parameters)
|
||||
: config.endpoint;
|
||||
|
||||
const formData = (config.buildFormData as (params: any, file: File) => FormData)(parameters, file);
|
||||
|
||||
const processedFiles = await executeApiRequest(
|
||||
endpoint,
|
||||
formData,
|
||||
[file],
|
||||
filePrefix,
|
||||
config.preserveBackendFilename
|
||||
);
|
||||
resultFiles.push(...processedFiles);
|
||||
}
|
||||
|
||||
return resultFiles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute multi-file tool operation (processes all files in one request)
|
||||
*/
|
||||
const executeMultiFileOperation = async (
|
||||
config: any,
|
||||
parameters: any,
|
||||
files: File[],
|
||||
filePrefix: string
|
||||
): Promise<File[]> => {
|
||||
const endpoint = typeof config.endpoint === 'function'
|
||||
? config.endpoint(parameters)
|
||||
: config.endpoint;
|
||||
|
||||
const formData = (config.buildFormData as (params: any, files: File[]) => FormData)(parameters, files);
|
||||
|
||||
return await executeApiRequest(
|
||||
endpoint,
|
||||
formData,
|
||||
files,
|
||||
filePrefix,
|
||||
config.preserveBackendFilename
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Execute a tool operation directly without using React hooks
|
||||
@@ -28,119 +149,27 @@ export const executeToolOperationWithPrefix = async (
|
||||
toolRegistry: ToolRegistry,
|
||||
filePrefix: string = AUTOMATION_CONSTANTS.FILE_PREFIX
|
||||
): Promise<File[]> => {
|
||||
console.log(`🔧 Executing tool: ${operationName}`, { parameters, fileCount: files.length });
|
||||
|
||||
const config = toolRegistry[operationName as keyof ToolRegistry]?.operationConfig;
|
||||
if (!config) {
|
||||
console.error(`❌ Tool operation not supported: ${operationName}`);
|
||||
throw new Error(`Tool operation not supported: ${operationName}`);
|
||||
}
|
||||
|
||||
console.log(`📋 Using config:`, config);
|
||||
|
||||
try {
|
||||
// Check if tool uses custom processor (like Convert tool)
|
||||
if (config.customProcessor) {
|
||||
console.log(`🎯 Using custom processor for ${config.operationType}`);
|
||||
const resultFiles = await config.customProcessor(parameters, files);
|
||||
console.log(`✅ Custom processor returned ${resultFiles.length} files`);
|
||||
return resultFiles;
|
||||
}
|
||||
|
||||
// Execute based on tool type
|
||||
if (config.toolType === ToolType.multiFile) {
|
||||
// Multi-file processing - single API call with all files
|
||||
const endpoint = typeof config.endpoint === 'function'
|
||||
? config.endpoint(parameters)
|
||||
: config.endpoint;
|
||||
|
||||
console.log(`🌐 Making multi-file request to: ${endpoint}`);
|
||||
const formData = (config.buildFormData as (params: any, files: File[]) => FormData)(parameters, files);
|
||||
console.log(`📤 FormData entries:`, Array.from(formData.entries()));
|
||||
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
responseType: 'blob',
|
||||
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
|
||||
});
|
||||
|
||||
console.log(`📥 Response status: ${response.status}, size: ${response.data.size} bytes`);
|
||||
|
||||
// Multi-file responses are typically ZIP files, but may be single files (e.g. split with merge=true)
|
||||
let result;
|
||||
if (response.data.type === 'application/pdf' ||
|
||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||
// Single PDF response (e.g. split with merge option) - use processResponse to respect preserveBackendFilename
|
||||
const processedFiles = await processResponse(
|
||||
response.data,
|
||||
files,
|
||||
filePrefix,
|
||||
undefined,
|
||||
config.preserveBackendFilename ? response.headers : undefined
|
||||
);
|
||||
result = {
|
||||
success: true,
|
||||
files: processedFiles,
|
||||
errors: []
|
||||
};
|
||||
} else {
|
||||
// ZIP response
|
||||
result = await AutomationFileProcessor.extractAutomationZipFiles(response.data);
|
||||
}
|
||||
|
||||
if (result.errors.length > 0) {
|
||||
console.warn(`⚠️ File processing warnings:`, result.errors);
|
||||
}
|
||||
// Apply prefix to files, replacing any existing prefix
|
||||
// Skip prefixing if preserveBackendFilename is true and backend provided a filename
|
||||
const processedFiles = filePrefix && !config.preserveBackendFilename
|
||||
? result.files.map(file => {
|
||||
const nameWithoutPrefix = file.name.replace(/^[^_]*_/, '');
|
||||
return new File([file], `${filePrefix}${nameWithoutPrefix}`, { type: file.type });
|
||||
})
|
||||
: result.files;
|
||||
|
||||
console.log(`📁 Processed ${processedFiles.length} files from response`);
|
||||
return processedFiles;
|
||||
|
||||
return await executeMultiFileOperation(config, parameters, files, filePrefix);
|
||||
} else {
|
||||
// Single-file processing - separate API call per file
|
||||
console.log(`🔄 Processing ${files.length} files individually`);
|
||||
const resultFiles: File[] = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const endpoint = typeof config.endpoint === 'function'
|
||||
? config.endpoint(parameters)
|
||||
: config.endpoint;
|
||||
|
||||
console.log(`🌐 Making single-file request ${i+1}/${files.length} to: ${endpoint} for file: ${file.name}`);
|
||||
const formData = (config.buildFormData as (params: any, file: File) => FormData)(parameters, file);
|
||||
console.log(`📤 FormData entries:`, Array.from(formData.entries()));
|
||||
|
||||
const response = await axios.post(endpoint, formData, {
|
||||
responseType: 'blob',
|
||||
timeout: AUTOMATION_CONSTANTS.OPERATION_TIMEOUT
|
||||
});
|
||||
|
||||
console.log(`📥 Response ${i+1} status: ${response.status}, size: ${response.data.size} bytes`);
|
||||
|
||||
// Create result file using processResponse to respect preserveBackendFilename setting
|
||||
const processedFiles = await processResponse(
|
||||
response.data,
|
||||
[file],
|
||||
filePrefix,
|
||||
undefined,
|
||||
config.preserveBackendFilename ? response.headers : undefined
|
||||
);
|
||||
resultFiles.push(...processedFiles);
|
||||
console.log(`✅ Created result file(s): ${processedFiles.map(f => f.name).join(', ')}`);
|
||||
}
|
||||
|
||||
console.log(`🎉 Single-file processing complete: ${resultFiles.length} files`);
|
||||
return resultFiles;
|
||||
return await executeSingleFileOperation(config, parameters, files, filePrefix);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error(`Tool operation ${operationName} failed:`, error);
|
||||
console.error(`❌ ${operationName} failed:`, error);
|
||||
throw new Error(`${operationName} operation failed: ${error.response?.data || error.message}`);
|
||||
}
|
||||
};
|
||||
@@ -156,9 +185,8 @@ export const executeAutomationSequence = async (
|
||||
onStepComplete?: (stepIndex: number, resultFiles: File[]) => void,
|
||||
onStepError?: (stepIndex: number, error: string) => void
|
||||
): Promise<File[]> => {
|
||||
console.log(`🚀 Starting automation sequence: ${automation.name || 'Unnamed'}`);
|
||||
console.log(`📁 Initial files: ${initialFiles.length}`);
|
||||
console.log(`🔧 Operations: ${automation.operations?.length || 0}`);
|
||||
console.log(`🚀 Starting automation: ${automation.name || 'Unnamed'}`);
|
||||
console.log(`📁 Input: ${initialFiles.length} file(s)`);
|
||||
|
||||
if (!automation?.operations || automation.operations.length === 0) {
|
||||
throw new Error('No operations in automation');
|
||||
@@ -170,9 +198,8 @@ export const executeAutomationSequence = async (
|
||||
for (let i = 0; i < automation.operations.length; i++) {
|
||||
const operation = automation.operations[i];
|
||||
|
||||
console.log(`📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`);
|
||||
console.log(`📄 Input files: ${currentFiles.length}`);
|
||||
console.log(`⚙️ Parameters:`, operation.parameters || {});
|
||||
console.log(`\n📋 Step ${i + 1}/${automation.operations.length}: ${operation.operation}`);
|
||||
console.log(` Input: ${currentFiles.length} file(s)`);
|
||||
|
||||
try {
|
||||
onStepStart?.(i, operation.operation);
|
||||
@@ -196,6 +223,6 @@ export const executeAutomationSequence = async (
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🎉 Automation sequence completed: ${currentFiles.length} final files`);
|
||||
console.log(`\n🎉 Automation complete: ${currentFiles.length} file(s)`);
|
||||
return currentFiles;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
|
||||
export interface HotkeyBinding {
|
||||
code: string;
|
||||
alt?: boolean;
|
||||
ctrl?: boolean;
|
||||
meta?: boolean;
|
||||
shift?: boolean;
|
||||
}
|
||||
|
||||
const MODIFIER_CODES = new Set([
|
||||
'ShiftLeft',
|
||||
'ShiftRight',
|
||||
'ControlLeft',
|
||||
'ControlRight',
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
'MetaLeft',
|
||||
'MetaRight',
|
||||
]);
|
||||
|
||||
const CODE_LABEL_MAP: Record<string, string> = {
|
||||
Minus: '-',
|
||||
Equal: '=',
|
||||
Backquote: '`',
|
||||
BracketLeft: '[',
|
||||
BracketRight: ']',
|
||||
Backslash: '\\',
|
||||
IntlBackslash: '\\',
|
||||
Semicolon: ';',
|
||||
Quote: '\'',
|
||||
Comma: ',',
|
||||
Period: '.',
|
||||
Slash: '/',
|
||||
Space: 'Space',
|
||||
Tab: 'Tab',
|
||||
Escape: 'Esc',
|
||||
Enter: 'Enter',
|
||||
NumpadEnter: 'Num Enter',
|
||||
NumpadAdd: 'Num +',
|
||||
NumpadSubtract: 'Num -',
|
||||
NumpadMultiply: 'Num *',
|
||||
NumpadDivide: 'Num /',
|
||||
NumpadDecimal: 'Num .',
|
||||
NumpadComma: 'Num ,',
|
||||
NumpadEqual: 'Num =',
|
||||
};
|
||||
|
||||
export const isMacLike = (): boolean => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
const platform = navigator.platform?.toLowerCase() ?? '';
|
||||
const userAgent = navigator.userAgent?.toLowerCase() ?? '';
|
||||
return /mac|iphone|ipad|ipod/.test(platform) || /mac|iphone|ipad|ipod/.test(userAgent);
|
||||
};
|
||||
|
||||
export const isModifierCode = (code: string): boolean => MODIFIER_CODES.has(code);
|
||||
|
||||
const isFunctionKey = (code: string): boolean => /^F\d{1,2}$/.test(code);
|
||||
|
||||
export const bindingEquals = (a?: HotkeyBinding | null, b?: HotkeyBinding | null): boolean => {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
return (
|
||||
a.code === b.code &&
|
||||
Boolean(a.alt) === Boolean(b.alt) &&
|
||||
Boolean(a.ctrl) === Boolean(b.ctrl) &&
|
||||
Boolean(a.meta) === Boolean(b.meta) &&
|
||||
Boolean(a.shift) === Boolean(b.shift)
|
||||
);
|
||||
};
|
||||
|
||||
export const bindingMatchesEvent = (binding: HotkeyBinding, event: KeyboardEvent): boolean => {
|
||||
return (
|
||||
event.code === binding.code &&
|
||||
event.altKey === Boolean(binding.alt) &&
|
||||
event.ctrlKey === Boolean(binding.ctrl) &&
|
||||
event.metaKey === Boolean(binding.meta) &&
|
||||
event.shiftKey === Boolean(binding.shift)
|
||||
);
|
||||
};
|
||||
|
||||
export const eventToBinding = (event: KeyboardEvent | ReactKeyboardEvent): HotkeyBinding | null => {
|
||||
const code = event.code;
|
||||
if (!code || isModifierCode(code)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const binding: HotkeyBinding = {
|
||||
code,
|
||||
alt: event.altKey,
|
||||
ctrl: event.ctrlKey,
|
||||
meta: event.metaKey,
|
||||
shift: event.shiftKey,
|
||||
};
|
||||
|
||||
// Require at least one modifier to avoid clashing with text input
|
||||
if (!binding.alt && !binding.ctrl && !binding.meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return binding;
|
||||
};
|
||||
|
||||
const getKeyLabel = (code: string): string => {
|
||||
if (CODE_LABEL_MAP[code]) {
|
||||
return CODE_LABEL_MAP[code];
|
||||
}
|
||||
|
||||
if (code.startsWith('Key')) {
|
||||
return code.slice(3);
|
||||
}
|
||||
|
||||
if (code.startsWith('Digit')) {
|
||||
return code.slice(5);
|
||||
}
|
||||
|
||||
if (code.startsWith('Numpad')) {
|
||||
const remainder = code.slice(6);
|
||||
if (/^[0-9]$/.test(remainder)) {
|
||||
return `Num ${remainder}`;
|
||||
}
|
||||
return `Num ${remainder}`;
|
||||
}
|
||||
|
||||
// Match function keys (F1-F12)
|
||||
if (isFunctionKey(code)) {
|
||||
return code;
|
||||
}
|
||||
|
||||
switch (code) {
|
||||
case 'ArrowUp':
|
||||
return '↑';
|
||||
case 'ArrowDown':
|
||||
return '↓';
|
||||
case 'ArrowLeft':
|
||||
return '←';
|
||||
case 'ArrowRight':
|
||||
return '→';
|
||||
default:
|
||||
return code;
|
||||
}
|
||||
};
|
||||
|
||||
export const getDisplayParts = (binding: HotkeyBinding | null | undefined, macLike: boolean): string[] => {
|
||||
if (!binding) return [];
|
||||
const parts: string[] = [];
|
||||
if (binding.meta) {
|
||||
parts.push(macLike ? '⌘' : 'Win');
|
||||
}
|
||||
if (binding.ctrl) {
|
||||
parts.push(macLike ? '⌃' : 'Ctrl');
|
||||
}
|
||||
if (binding.alt) {
|
||||
parts.push(macLike ? '⌥' : 'Alt');
|
||||
}
|
||||
if (binding.shift) {
|
||||
parts.push(macLike ? '⇧' : 'Shift');
|
||||
}
|
||||
parts.push(getKeyLabel(binding.code));
|
||||
return parts;
|
||||
};
|
||||
|
||||
export const serializeBindings = (bindings: Record<string, HotkeyBinding>): string => {
|
||||
return JSON.stringify(bindings);
|
||||
};
|
||||
|
||||
export const deserializeBindings = (value: string | null | undefined): Record<string, HotkeyBinding> => {
|
||||
if (!value) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Record<string, HotkeyBinding>;
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
return {};
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse stored hotkey bindings', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeBinding = (binding: HotkeyBinding): HotkeyBinding => ({
|
||||
code: binding.code,
|
||||
alt: Boolean(binding.alt),
|
||||
ctrl: Boolean(binding.ctrl),
|
||||
meta: Boolean(binding.meta),
|
||||
shift: Boolean(binding.shift),
|
||||
});
|
||||
@@ -33,7 +33,7 @@ export function parseToolRoute(registry: ToolRegistry): ToolRoute {
|
||||
|
||||
// Fallback: Try to find tool by primary URL path in registry
|
||||
for (const [toolId, tool] of Object.entries(registry)) {
|
||||
const toolUrlPath = getToolUrlPath(toolId, tool);
|
||||
const toolUrlPath = getToolUrlPath(toolId);
|
||||
if (path === toolUrlPath && isValidToolId(toolId)) {
|
||||
return {
|
||||
workbench: getToolWorkbench(tool),
|
||||
@@ -88,7 +88,7 @@ export function updateToolRoute(toolId: ToolId, registry: ToolRegistry, replace:
|
||||
return;
|
||||
}
|
||||
|
||||
const toolPath = getToolUrlPath(toolId, tool);
|
||||
const toolPath = getToolUrlPath(toolId);
|
||||
const newPath = withBasePath(toolPath);
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
@@ -116,19 +116,3 @@ export function getToolDisplayName(toolId: ToolId, registry: ToolRegistry): stri
|
||||
return tool ? tool.name : toolId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate shareable URL for current tool state using registry
|
||||
*/
|
||||
export function generateShareableUrl(toolId: ToolId | null, registry: ToolRegistry): string {
|
||||
const baseUrl = window.location.origin;
|
||||
|
||||
if (!toolId || !registry[toolId]) {
|
||||
return `${baseUrl}${BASE_PATH || ''}`;
|
||||
}
|
||||
|
||||
const tool = registry[toolId];
|
||||
|
||||
const toolPath = getToolUrlPath(toolId, tool);
|
||||
const fullPath = withBasePath(toolPath);
|
||||
return `${baseUrl}${fullPath}`;
|
||||
}
|
||||
|
||||
@@ -192,7 +192,6 @@ ignore = [
|
||||
'AddStampRequest.alphabet',
|
||||
'AddStampRequest.position',
|
||||
'PDFToBook.selectText.1',
|
||||
'addPageNumbers.selectText.3',
|
||||
'adminUserSettings.team',
|
||||
'alphabet',
|
||||
'audit.dashboard.modal.id',
|
||||
@@ -325,7 +324,6 @@ ignore = [
|
||||
'AddStampRequest.position',
|
||||
'AddStampRequest.rotation',
|
||||
'PDFToBook.selectText.1',
|
||||
'addPageNumbers.selectText.3',
|
||||
'adminUserSettings.actions',
|
||||
'alphabet',
|
||||
'compare.document.1',
|
||||
|
||||
@@ -1,9 +1,96 @@
|
||||
# Translation Management Scripts
|
||||
|
||||
This directory contains Python scripts for managing frontend translations in Stirling PDF. These tools help analyze, merge, and manage translations against the en-GB golden truth file.
|
||||
This directory contains Python scripts for managing frontend translations in Stirling PDF. These tools help analyze, merge, validate, and manage translations against the en-GB golden truth file.
|
||||
|
||||
## Scripts Overview
|
||||
|
||||
### 0. Validation Scripts (Run First!)
|
||||
|
||||
#### `json_validator.py`
|
||||
Validates JSON syntax in translation files with detailed error reporting.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Validate single file
|
||||
python scripts/translations/json_validator.py ar_AR_batch_1_of_3.json
|
||||
|
||||
# Validate all batches for a language
|
||||
python scripts/translations/json_validator.py --all-batches ar_AR
|
||||
|
||||
# Validate pattern with wildcards
|
||||
python scripts/translations/json_validator.py "ar_AR_batch_*.json"
|
||||
|
||||
# Brief output (no context)
|
||||
python scripts/translations/json_validator.py --all-batches ar_AR --brief
|
||||
|
||||
# Only show files with errors
|
||||
python scripts/translations/json_validator.py --all-batches ar_AR --quiet
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Validates JSON syntax with detailed error messages
|
||||
- Shows exact line, column, and character position of errors
|
||||
- Displays context around errors for easy fixing
|
||||
- Suggests common fixes based on error type
|
||||
- Detects unescaped quotes and backslashes
|
||||
- Reports entry counts for valid files
|
||||
- Exit code 1 if any files invalid (good for CI/CD)
|
||||
|
||||
**Common Issues Detected:**
|
||||
- Unescaped quotes inside strings: `"text with "quotes""` → `"text with \"quotes\""`
|
||||
- Invalid backslash escapes: `\d{4}` → `\\d{4}`
|
||||
- Missing commas between entries
|
||||
- Trailing commas before closing braces
|
||||
|
||||
#### `validate_placeholders.py`
|
||||
Validates that translation files have correct placeholders matching en-GB (source of truth).
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Validate all languages
|
||||
python scripts/translations/validate_placeholders.py
|
||||
|
||||
# Validate specific language
|
||||
python scripts/translations/validate_placeholders.py --language es-ES
|
||||
|
||||
# Show detailed text samples
|
||||
python scripts/translations/validate_placeholders.py --verbose
|
||||
|
||||
# Output as JSON
|
||||
python scripts/translations/validate_placeholders.py --json
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Detects missing placeholders (e.g., {n}, {total}, {filename})
|
||||
- Detects extra placeholders not in en-GB
|
||||
- Shows exact keys and text where issues occur
|
||||
- Exit code 1 if issues found (good for CI/CD)
|
||||
|
||||
#### `validate_json_structure.py`
|
||||
Validates JSON structure and key consistency with en-GB.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Validate all languages
|
||||
python scripts/translations/validate_json_structure.py
|
||||
|
||||
# Validate specific language
|
||||
python scripts/translations/validate_json_structure.py --language de-DE
|
||||
|
||||
# Show all missing/extra keys
|
||||
python scripts/translations/validate_json_structure.py --verbose
|
||||
|
||||
# Output as JSON
|
||||
python scripts/translations/validate_json_structure.py --json
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Validates JSON syntax
|
||||
- Detects missing keys (not translated yet)
|
||||
- Detects extra keys (not in en-GB, should be removed)
|
||||
- Reports key counts and structure differences
|
||||
- Exit code 1 if issues found (good for CI/CD)
|
||||
|
||||
### 1. `translation_analyzer.py`
|
||||
Analyzes translation files to find missing translations, untranslated entries, and provides completion statistics.
|
||||
|
||||
@@ -142,7 +229,20 @@ python scripts/translations/translation_analyzer.py --language it-IT --summary
|
||||
|
||||
#### Step 2: Extract Untranslated Entries
|
||||
```bash
|
||||
# For small files (< 1200 entries)
|
||||
python scripts/translations/compact_translator.py it-IT --output to_translate.json
|
||||
|
||||
# For large files, split into batches
|
||||
python scripts/translations/compact_translator.py it-IT --output it_IT_batch --batch-size 400
|
||||
# Creates: it_IT_batch_1_of_N.json, it_IT_batch_2_of_N.json, etc.
|
||||
```
|
||||
|
||||
#### Step 2.5: Validate JSON (if using batches)
|
||||
```bash
|
||||
# After AI translates the batches, validate them before merging
|
||||
python scripts/translations/json_validator.py --all-batches it_IT
|
||||
|
||||
# Fix any errors reported (common issues: unescaped quotes, backslashes)
|
||||
```
|
||||
|
||||
**Output format**: Compact JSON with minimal whitespace
|
||||
@@ -309,6 +409,34 @@ ignore = [
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
#### JSON Syntax Errors in AI Translations
|
||||
**Problem**: AI-translated batch files have JSON syntax errors
|
||||
**Symptoms**:
|
||||
- `JSONDecodeError: Expecting ',' delimiter`
|
||||
- `JSONDecodeError: Invalid \escape`
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# 1. Validate all batches to find errors
|
||||
python scripts/translations/json_validator.py --all-batches ar_AR
|
||||
|
||||
# 2. Check detailed error with context
|
||||
python scripts/translations/json_validator.py ar_AR_batch_2_of_3.json
|
||||
|
||||
# 3. Fix the reported issues:
|
||||
# - Unescaped quotes: "text with "quotes"" → "text with \"quotes\""
|
||||
# - Backslashes in regex: "\d{4}" → "\\d{4}"
|
||||
# - Missing commas between entries
|
||||
|
||||
# 4. Validate again until all pass
|
||||
python scripts/translations/json_validator.py --all-batches ar_AR
|
||||
```
|
||||
|
||||
**Common fixes:**
|
||||
- Arabic/RTL text with embedded quotes: Always escape with backslash
|
||||
- Regex patterns: Double all backslashes (`\d` → `\\d`)
|
||||
- Check for missing/extra commas at line reported in error
|
||||
|
||||
#### [UNTRANSLATED] Pollution
|
||||
**Problem**: Hundreds of [UNTRANSLATED] markers from incomplete translation attempts
|
||||
**Solution**:
|
||||
@@ -326,6 +454,54 @@ ignore = [
|
||||
|
||||
## Real-World Examples
|
||||
|
||||
### Complete Arabic Translation with Validation (Batch Method)
|
||||
```bash
|
||||
# Check status
|
||||
python scripts/translations/translation_analyzer.py --language ar-AR --summary
|
||||
# Result: 50% complete, 1088 missing
|
||||
|
||||
# Extract in batches due to AI token limits
|
||||
python scripts/translations/compact_translator.py ar-AR --output ar_AR_batch --batch-size 400
|
||||
# Created: ar_AR_batch_1_of_3.json (400 entries)
|
||||
# ar_AR_batch_2_of_3.json (400 entries)
|
||||
# ar_AR_batch_3_of_3.json (288 entries)
|
||||
|
||||
# [Send each batch to AI for translation]
|
||||
|
||||
# Validate translated batches before merging
|
||||
python scripts/translations/json_validator.py --all-batches ar_AR
|
||||
# Found errors in batch 1 and 2:
|
||||
# - Line 263: Unescaped quotes in "انقر "إضافة ملفات""
|
||||
# - Line 132: Unescaped quotes in "أو "and""
|
||||
# - Line 213: Invalid escape "\d{4}"
|
||||
|
||||
# Fix errors manually or with sed, then validate again
|
||||
python scripts/translations/json_validator.py --all-batches ar_AR
|
||||
# All valid!
|
||||
|
||||
# Merge all batches
|
||||
python3 << 'EOF'
|
||||
import json
|
||||
merged = {}
|
||||
for i in range(1, 4):
|
||||
with open(f'ar_AR_batch_{i}_of_3.json', 'r', encoding='utf-8') as f:
|
||||
merged.update(json.load(f))
|
||||
with open('ar_AR_merged.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(merged, f, ensure_ascii=False, indent=2)
|
||||
EOF
|
||||
|
||||
# Apply merged translations
|
||||
python scripts/translations/translation_merger.py ar-AR apply-translations --translations-file ar_AR_merged.json
|
||||
# Result: Applied 1088 translations
|
||||
|
||||
# Beautify to match en-GB structure
|
||||
python scripts/translations/json_beautifier.py --language ar-AR
|
||||
|
||||
# Check final progress
|
||||
python scripts/translations/translation_analyzer.py --language ar-AR --summary
|
||||
# Result: 98.7% complete, 9 missing, 20 untranslated
|
||||
```
|
||||
|
||||
### Complete Italian Translation (Compact Method)
|
||||
```bash
|
||||
# Check status
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JSON Validator for Translation Files
|
||||
|
||||
Validates JSON syntax in translation files and reports detailed error information.
|
||||
Useful for validating batch translation files before merging.
|
||||
|
||||
Usage:
|
||||
python3 json_validator.py <file_or_pattern>
|
||||
python3 json_validator.py ar_AR_batch_*.json
|
||||
python3 json_validator.py ar_AR_batch_1_of_3.json
|
||||
python3 json_validator.py --all-batches ar_AR
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
import argparse
|
||||
import glob
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_line_context(file_path, line_num, context_lines=3):
|
||||
"""Get lines around the error for context"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
start = max(0, line_num - context_lines - 1)
|
||||
end = min(len(lines), line_num + context_lines)
|
||||
|
||||
context = []
|
||||
for i in range(start, end):
|
||||
marker = ">>> " if i == line_num - 1 else " "
|
||||
context.append(f"{marker}{i+1:4d}: {lines[i].rstrip()}")
|
||||
|
||||
return "\n".join(context)
|
||||
except Exception as e:
|
||||
return f"Could not read context: {e}"
|
||||
|
||||
|
||||
def get_character_context(file_path, char_pos, context_chars=100):
|
||||
"""Get characters around the error position"""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
start = max(0, char_pos - context_chars)
|
||||
end = min(len(content), char_pos + context_chars)
|
||||
|
||||
before = content[start:char_pos]
|
||||
error_char = content[char_pos] if char_pos < len(content) else "EOF"
|
||||
after = content[char_pos+1:end]
|
||||
|
||||
return {
|
||||
'before': before,
|
||||
'error_char': error_char,
|
||||
'after': after,
|
||||
'display': f"{before}[{error_char}]{after}"
|
||||
}
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
|
||||
def validate_json_file(file_path):
|
||||
"""Validate a single JSON file and return detailed error info"""
|
||||
result = {
|
||||
'file': str(file_path),
|
||||
'valid': False,
|
||||
'error': None,
|
||||
'line': None,
|
||||
'column': None,
|
||||
'position': None,
|
||||
'context': None,
|
||||
'char_context': None,
|
||||
'entry_count': 0
|
||||
}
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
result['valid'] = True
|
||||
result['entry_count'] = len(data) if isinstance(data, dict) else 0
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
result['error'] = e.msg
|
||||
result['line'] = e.lineno
|
||||
result['column'] = e.colno
|
||||
result['position'] = e.pos
|
||||
result['context'] = get_line_context(file_path, e.lineno)
|
||||
result['char_context'] = get_character_context(file_path, e.pos)
|
||||
|
||||
except FileNotFoundError:
|
||||
result['error'] = "File not found"
|
||||
|
||||
except Exception as e:
|
||||
result['error'] = str(e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def print_validation_result(result, verbose=True):
|
||||
"""Print validation result in a formatted way"""
|
||||
file_name = Path(result['file']).name
|
||||
|
||||
if result['valid']:
|
||||
print(f"✓ {file_name}: Valid JSON ({result['entry_count']} entries)")
|
||||
else:
|
||||
print(f"✗ {file_name}: Invalid JSON")
|
||||
print(f" Error: {result['error']}")
|
||||
|
||||
if result['line']:
|
||||
print(f" Location: Line {result['line']}, Column {result['column']} (character {result['position']})")
|
||||
|
||||
if verbose and result['context']:
|
||||
print(f"\n Context:")
|
||||
for line in result['context'].split('\n'):
|
||||
print(f" {line}")
|
||||
|
||||
if verbose and result['char_context']:
|
||||
print(f"\n Character context:")
|
||||
print(f" ...{result['char_context']['display'][-150:]}...")
|
||||
print(f" Error character: {repr(result['char_context']['error_char'])}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def get_common_fixes(error_msg):
|
||||
"""Suggest common fixes based on error message"""
|
||||
fixes = []
|
||||
|
||||
if "Expecting ',' delimiter" in error_msg:
|
||||
fixes.append("Missing comma between JSON entries")
|
||||
fixes.append("Check for unescaped quotes inside string values")
|
||||
|
||||
if "Invalid \\escape" in error_msg or "Invalid escape" in error_msg:
|
||||
fixes.append("Unescaped backslash in string (use \\\\ for literal backslash)")
|
||||
fixes.append("Common in regex patterns: \\d should be \\\\d")
|
||||
|
||||
if "Expecting property name" in error_msg:
|
||||
fixes.append("Missing or extra comma")
|
||||
fixes.append("Trailing comma before closing brace")
|
||||
|
||||
if "Expecting value" in error_msg:
|
||||
fixes.append("Missing value after colon")
|
||||
fixes.append("Extra comma")
|
||||
|
||||
return fixes
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Validate JSON syntax in translation files',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
Validate single file:
|
||||
python3 json_validator.py ar_AR_batch_1_of_3.json
|
||||
|
||||
Validate all batches for a language:
|
||||
python3 json_validator.py --all-batches ar_AR
|
||||
|
||||
Validate pattern:
|
||||
python3 json_validator.py "ar_AR_batch_*.json"
|
||||
|
||||
Validate multiple files:
|
||||
python3 json_validator.py file1.json file2.json file3.json
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'files',
|
||||
nargs='*',
|
||||
help='JSON file(s) to validate (supports wildcards)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--all-batches',
|
||||
metavar='LANGUAGE',
|
||||
help='Validate all batch files for a language (e.g., ar_AR)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--quiet',
|
||||
action='store_true',
|
||||
help='Only show files with errors'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--brief',
|
||||
action='store_true',
|
||||
help='Brief output without context'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine which files to validate
|
||||
files_to_validate = []
|
||||
|
||||
if args.all_batches:
|
||||
pattern = f"{args.all_batches}_batch_*.json"
|
||||
files_to_validate = glob.glob(pattern)
|
||||
if not files_to_validate:
|
||||
print(f"No batch files found matching: {pattern}")
|
||||
return 1
|
||||
elif args.files:
|
||||
for file_pattern in args.files:
|
||||
if '*' in file_pattern or '?' in file_pattern:
|
||||
files_to_validate.extend(glob.glob(file_pattern))
|
||||
else:
|
||||
files_to_validate.append(file_pattern)
|
||||
else:
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
if not files_to_validate:
|
||||
print("No files to validate")
|
||||
return 1
|
||||
|
||||
# Sort files for consistent output
|
||||
files_to_validate.sort()
|
||||
|
||||
print(f"Validating {len(files_to_validate)} file(s)...\n")
|
||||
|
||||
# Validate each file
|
||||
results = []
|
||||
for file_path in files_to_validate:
|
||||
result = validate_json_file(file_path)
|
||||
results.append(result)
|
||||
|
||||
if not args.quiet or not result['valid']:
|
||||
print_validation_result(result, verbose=not args.brief)
|
||||
|
||||
# Summary
|
||||
valid_count = sum(1 for r in results if r['valid'])
|
||||
invalid_count = len(results) - valid_count
|
||||
|
||||
print("=" * 60)
|
||||
print(f"Summary: {valid_count} valid, {invalid_count} invalid")
|
||||
|
||||
# Show common fixes for errors
|
||||
if invalid_count > 0:
|
||||
all_errors = [r['error'] for r in results if r['error']]
|
||||
unique_error_types = set(all_errors)
|
||||
|
||||
print("\nCommon fixes:")
|
||||
fixes_shown = set()
|
||||
for error in unique_error_types:
|
||||
fixes = get_common_fixes(error)
|
||||
for fix in fixes:
|
||||
if fix not in fixes_shown:
|
||||
print(f" • {fix}")
|
||||
fixes_shown.add(fix)
|
||||
|
||||
return 0 if invalid_count == 0 else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate JSON structure and formatting of translation files.
|
||||
|
||||
Checks for:
|
||||
- Valid JSON syntax
|
||||
- Consistent key structure with en-GB
|
||||
- Missing keys
|
||||
- Extra keys not in en-GB
|
||||
- Malformed entries
|
||||
|
||||
Usage:
|
||||
python scripts/translations/validate_json_structure.py [--language LANG]
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set
|
||||
import argparse
|
||||
|
||||
|
||||
def get_all_keys(d: dict, parent_key: str = '', sep: str = '.') -> Set[str]:
|
||||
"""Get all keys from nested dict as dot-notation paths."""
|
||||
keys = set()
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
keys.add(new_key)
|
||||
if isinstance(v, dict):
|
||||
keys.update(get_all_keys(v, new_key, sep=sep))
|
||||
return keys
|
||||
|
||||
|
||||
def validate_json_file(file_path: Path) -> tuple[bool, str]:
|
||||
"""Validate that a file contains valid JSON."""
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
json.load(f)
|
||||
return True, "Valid JSON"
|
||||
except json.JSONDecodeError as e:
|
||||
return False, f"Invalid JSON at line {e.lineno}, column {e.colno}: {e.msg}"
|
||||
except Exception as e:
|
||||
return False, f"Error reading file: {str(e)}"
|
||||
|
||||
|
||||
def validate_structure(
|
||||
en_gb_keys: Set[str],
|
||||
lang_keys: Set[str],
|
||||
lang_code: str
|
||||
) -> Dict:
|
||||
"""Compare structure between en-GB and target language."""
|
||||
missing_keys = en_gb_keys - lang_keys
|
||||
extra_keys = lang_keys - en_gb_keys
|
||||
|
||||
return {
|
||||
'language': lang_code,
|
||||
'missing_keys': sorted(missing_keys),
|
||||
'extra_keys': sorted(extra_keys),
|
||||
'total_keys': len(lang_keys),
|
||||
'expected_keys': len(en_gb_keys),
|
||||
'missing_count': len(missing_keys),
|
||||
'extra_count': len(extra_keys)
|
||||
}
|
||||
|
||||
|
||||
def print_validation_result(result: Dict, verbose: bool = False):
|
||||
"""Print validation results in readable format."""
|
||||
lang = result['language']
|
||||
|
||||
print(f"\n{'='*100}")
|
||||
print(f"Language: {lang}")
|
||||
print(f"{'='*100}")
|
||||
print(f" Total keys: {result['total_keys']}")
|
||||
print(f" Expected keys (en-GB): {result['expected_keys']}")
|
||||
print(f" Missing keys: {result['missing_count']}")
|
||||
print(f" Extra keys: {result['extra_count']}")
|
||||
|
||||
if result['missing_count'] == 0 and result['extra_count'] == 0:
|
||||
print(f" ✅ Structure matches en-GB perfectly!")
|
||||
else:
|
||||
if result['missing_count'] > 0:
|
||||
print(f"\n ⚠️ Missing {result['missing_count']} key(s):")
|
||||
if verbose or result['missing_count'] <= 20:
|
||||
for key in result['missing_keys'][:50]:
|
||||
print(f" - {key}")
|
||||
if result['missing_count'] > 50:
|
||||
print(f" ... and {result['missing_count'] - 50} more")
|
||||
else:
|
||||
print(f" (use --verbose to see all)")
|
||||
|
||||
if result['extra_count'] > 0:
|
||||
print(f"\n ⚠️ Extra {result['extra_count']} key(s) not in en-GB:")
|
||||
if verbose or result['extra_count'] <= 20:
|
||||
for key in result['extra_keys'][:50]:
|
||||
print(f" - {key}")
|
||||
if result['extra_count'] > 50:
|
||||
print(f" ... and {result['extra_count'] - 50} more")
|
||||
else:
|
||||
print(f" (use --verbose to see all)")
|
||||
|
||||
print("-" * 100)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Validate translation JSON structure'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--language',
|
||||
help='Specific language code to validate (e.g., es-ES)',
|
||||
default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose', '-v',
|
||||
action='store_true',
|
||||
help='Show all missing/extra keys'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--json',
|
||||
action='store_true',
|
||||
help='Output results as JSON'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Define paths
|
||||
locales_dir = Path('frontend/public/locales')
|
||||
en_gb_path = locales_dir / 'en-GB' / 'translation.json'
|
||||
|
||||
if not en_gb_path.exists():
|
||||
print(f"❌ Error: en-GB translation file not found at {en_gb_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# Validate en-GB itself
|
||||
is_valid, message = validate_json_file(en_gb_path)
|
||||
if not is_valid:
|
||||
print(f"❌ Error in en-GB file: {message}")
|
||||
sys.exit(1)
|
||||
|
||||
# Load en-GB structure
|
||||
with open(en_gb_path, 'r', encoding='utf-8') as f:
|
||||
en_gb = json.load(f)
|
||||
|
||||
en_gb_keys = get_all_keys(en_gb)
|
||||
|
||||
# Get list of languages to validate
|
||||
if args.language:
|
||||
languages = [args.language]
|
||||
else:
|
||||
languages = [
|
||||
d.name for d in locales_dir.iterdir()
|
||||
if d.is_dir() and d.name != 'en-GB' and (d / 'translation.json').exists()
|
||||
]
|
||||
|
||||
results = []
|
||||
json_errors = []
|
||||
|
||||
# Validate each language
|
||||
for lang_code in sorted(languages):
|
||||
lang_path = locales_dir / lang_code / 'translation.json'
|
||||
|
||||
if not lang_path.exists():
|
||||
print(f"⚠️ Warning: {lang_code}/translation.json not found, skipping")
|
||||
continue
|
||||
|
||||
# First check if JSON is valid
|
||||
is_valid, message = validate_json_file(lang_path)
|
||||
if not is_valid:
|
||||
json_errors.append({
|
||||
'language': lang_code,
|
||||
'file': str(lang_path),
|
||||
'error': message
|
||||
})
|
||||
continue
|
||||
|
||||
# Load and compare structure
|
||||
with open(lang_path, 'r', encoding='utf-8') as f:
|
||||
lang_data = json.load(f)
|
||||
|
||||
lang_keys = get_all_keys(lang_data)
|
||||
result = validate_structure(en_gb_keys, lang_keys, lang_code)
|
||||
results.append(result)
|
||||
|
||||
# Output results
|
||||
if args.json:
|
||||
output = {
|
||||
'json_errors': json_errors,
|
||||
'structure_validation': results
|
||||
}
|
||||
print(json.dumps(output, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
# Print JSON errors first
|
||||
if json_errors:
|
||||
print("\n❌ JSON Syntax Errors:")
|
||||
print("=" * 100)
|
||||
for error in json_errors:
|
||||
print(f"\nLanguage: {error['language']}")
|
||||
print(f"File: {error['file']}")
|
||||
print(f"Error: {error['error']}")
|
||||
print("\n")
|
||||
|
||||
# Print structure validation results
|
||||
if results:
|
||||
print("\n📊 Structure Validation Summary:")
|
||||
print(f" Languages validated: {len(results)}")
|
||||
|
||||
perfect = sum(1 for r in results if r['missing_count'] == 0 and r['extra_count'] == 0)
|
||||
print(f" Perfect matches: {perfect}/{len(results)}")
|
||||
|
||||
total_missing = sum(r['missing_count'] for r in results)
|
||||
total_extra = sum(r['extra_count'] for r in results)
|
||||
print(f" Total missing keys: {total_missing}")
|
||||
print(f" Total extra keys: {total_extra}")
|
||||
|
||||
for result in results:
|
||||
print_validation_result(result, verbose=args.verbose)
|
||||
|
||||
if not json_errors and perfect == len(results):
|
||||
print("\n✅ All translations have perfect structure!")
|
||||
|
||||
# Exit with error code if issues found
|
||||
has_issues = len(json_errors) > 0 or any(
|
||||
r['missing_count'] > 0 or r['extra_count'] > 0 for r in results
|
||||
)
|
||||
sys.exit(1 if has_issues else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Validate that translation files have the same placeholders as en-GB (source of truth).
|
||||
|
||||
Usage:
|
||||
python scripts/translations/validate_placeholders.py [--language LANG] [--fix]
|
||||
|
||||
--language: Validate specific language (e.g., es-ES, de-DE)
|
||||
--fix: Automatically remove extra placeholders (use with caution)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set, Tuple
|
||||
import argparse
|
||||
|
||||
|
||||
def find_placeholders(text: str) -> Set[str]:
|
||||
"""Find all placeholders in text like {n}, {{var}}, {0}, etc."""
|
||||
if not isinstance(text, str):
|
||||
return set()
|
||||
return set(re.findall(r'\{\{?[^}]+\}\}?', text))
|
||||
|
||||
|
||||
def flatten_dict(d: dict, parent_key: str = '', sep: str = '.') -> Dict[str, str]:
|
||||
"""Flatten nested dict to dot-notation keys."""
|
||||
items = []
|
||||
for k, v in d.items():
|
||||
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
||||
if isinstance(v, dict):
|
||||
items.extend(flatten_dict(v, new_key, sep=sep).items())
|
||||
else:
|
||||
items.append((new_key, v))
|
||||
return dict(items)
|
||||
|
||||
|
||||
def validate_language(
|
||||
en_gb_flat: Dict[str, str],
|
||||
lang_flat: Dict[str, str],
|
||||
lang_code: str
|
||||
) -> List[Dict]:
|
||||
"""Validate placeholders for a language against en-GB."""
|
||||
issues = []
|
||||
|
||||
for key in en_gb_flat:
|
||||
if key not in lang_flat:
|
||||
continue
|
||||
|
||||
en_placeholders = find_placeholders(en_gb_flat[key])
|
||||
lang_placeholders = find_placeholders(lang_flat[key])
|
||||
|
||||
if en_placeholders != lang_placeholders:
|
||||
missing = en_placeholders - lang_placeholders
|
||||
extra = lang_placeholders - en_placeholders
|
||||
|
||||
issue = {
|
||||
'language': lang_code,
|
||||
'key': key,
|
||||
'missing': missing,
|
||||
'extra': extra,
|
||||
'en_text': en_gb_flat[key],
|
||||
'lang_text': lang_flat[key]
|
||||
}
|
||||
issues.append(issue)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def print_issues(issues: List[Dict], verbose: bool = False):
|
||||
"""Print validation issues in a readable format."""
|
||||
if not issues:
|
||||
print("✅ No placeholder validation issues found!")
|
||||
return
|
||||
|
||||
print(f"❌ Found {len(issues)} placeholder validation issue(s):\n")
|
||||
print("=" * 100)
|
||||
|
||||
for i, issue in enumerate(issues, 1):
|
||||
print(f"\n{i}. Language: {issue['language']}")
|
||||
print(f" Key: {issue['key']}")
|
||||
|
||||
if issue['missing']:
|
||||
print(f" ⚠️ MISSING placeholders: {issue['missing']}")
|
||||
if issue['extra']:
|
||||
print(f" ⚠️ EXTRA placeholders: {issue['extra']}")
|
||||
|
||||
if verbose:
|
||||
print(f" EN-GB: {issue['en_text'][:150]}")
|
||||
print(f" {issue['language']}: {issue['lang_text'][:150]}")
|
||||
|
||||
print("-" * 100)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Validate translation placeholder consistency'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--language',
|
||||
help='Specific language code to validate (e.g., es-ES)',
|
||||
default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose', '-v',
|
||||
action='store_true',
|
||||
help='Show full text samples for each issue'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--json',
|
||||
action='store_true',
|
||||
help='Output results as JSON'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Define paths
|
||||
locales_dir = Path('frontend/public/locales')
|
||||
en_gb_path = locales_dir / 'en-GB' / 'translation.json'
|
||||
|
||||
if not en_gb_path.exists():
|
||||
print(f"❌ Error: en-GB translation file not found at {en_gb_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# Load en-GB (source of truth)
|
||||
with open(en_gb_path, 'r', encoding='utf-8') as f:
|
||||
en_gb = json.load(f)
|
||||
|
||||
en_gb_flat = flatten_dict(en_gb)
|
||||
|
||||
# Get list of languages to validate
|
||||
if args.language:
|
||||
languages = [args.language]
|
||||
else:
|
||||
# Validate all languages except en-GB
|
||||
languages = [
|
||||
d.name for d in locales_dir.iterdir()
|
||||
if d.is_dir() and d.name != 'en-GB' and (d / 'translation.json').exists()
|
||||
]
|
||||
|
||||
all_issues = []
|
||||
|
||||
# Validate each language
|
||||
for lang_code in sorted(languages):
|
||||
lang_path = locales_dir / lang_code / 'translation.json'
|
||||
|
||||
if not lang_path.exists():
|
||||
print(f"⚠️ Warning: {lang_code}/translation.json not found, skipping")
|
||||
continue
|
||||
|
||||
with open(lang_path, 'r', encoding='utf-8') as f:
|
||||
lang_data = json.load(f)
|
||||
|
||||
lang_flat = flatten_dict(lang_data)
|
||||
issues = validate_language(en_gb_flat, lang_flat, lang_code)
|
||||
all_issues.extend(issues)
|
||||
|
||||
# Output results
|
||||
if args.json:
|
||||
print(json.dumps(all_issues, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
if all_issues:
|
||||
# Group by language
|
||||
by_language = {}
|
||||
for issue in all_issues:
|
||||
lang = issue['language']
|
||||
if lang not in by_language:
|
||||
by_language[lang] = []
|
||||
by_language[lang].append(issue)
|
||||
|
||||
print(f"📊 Validation Summary:")
|
||||
print(f" Total issues: {len(all_issues)}")
|
||||
print(f" Languages with issues: {len(by_language)}\n")
|
||||
|
||||
for lang in sorted(by_language.keys()):
|
||||
print(f"\n{'='*100}")
|
||||
print(f"Language: {lang} ({len(by_language[lang])} issue(s))")
|
||||
print(f"{'='*100}")
|
||||
print_issues(by_language[lang], verbose=args.verbose)
|
||||
else:
|
||||
print("✅ All translations have correct placeholders!")
|
||||
|
||||
# Exit with error code if issues found
|
||||
sys.exit(1 if all_issues else 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user