diff --git a/.editorconfig b/.editorconfig index 5b76408cc..5e5445769 100644 --- a/.editorconfig +++ b/.editorconfig @@ -24,7 +24,7 @@ indent_size = 2 insert_final_newline = false trim_trailing_whitespace = false -[{*.js,*.jsx,*.ts,*.tsx}] +[{*.js,*.jsx,*.mjs,*.ts,*.tsx}] indent_size = 2 [*.css] diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e38b82fb..b38abe5dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -147,6 +147,8 @@ jobs: cache-dependency-path: frontend/package-lock.json - name: Install frontend dependencies run: cd frontend && npm ci + - name: Lint frontend + run: cd frontend && npm run lint - name: Build frontend run: cd frontend && npm run build - name: Run frontend tests diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 6ab09796f..4f627148e 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -19,5 +19,6 @@ "yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing "stylelint.vscode-stylelint", // Stylelint extension for CSS and SCSS linting "redhat.vscode-yaml", // YAML extension for Visual Studio Code + "dbaeumer.vscode-eslint", // ESLint extension for TypeScript linting ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 5b8f77bbc..7c231b4ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -139,5 +139,8 @@ "app/core/src/main/java", "app/common/src/main/java", "app/proprietary/src/main/java" - ] + ], + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + } } diff --git a/ADDING_TOOLS.md b/ADDING_TOOLS.md new file mode 100644 index 000000000..ef1501bfc --- /dev/null +++ b/ADDING_TOOLS.md @@ -0,0 +1,300 @@ +# Adding New React Tools to Stirling PDF + +This guide covers how to add new PDF tools to the React frontend, either by migrating existing Thymeleaf templates or creating entirely new tools. + +## Overview + +When adding tools, follow this systematic approach using the established patterns and architecture. + +## 1. Create Tool Structure + +Create these files in the correct directories: +``` +frontend/src/hooks/tools/[toolName]/ + ├── use[ToolName]Parameters.ts # Parameter definitions and validation + └── use[ToolName]Operation.ts # Tool operation logic using useToolOperation + +frontend/src/components/tools/[toolName]/ + └── [ToolName]Settings.tsx # Settings UI component (if needed) + +frontend/src/tools/ + └── [ToolName].tsx # Main tool component +``` + +## 2. Implementation Pattern + +Use `useBaseTool` for simplified hook management. This is the recommended approach for all new tools: + +**Parameters Hook** (`use[ToolName]Parameters.ts`): +```typescript +import { BaseParameters } from '../../../types/parameters'; +import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters'; + +export interface [ToolName]Parameters extends BaseParameters { + // Define your tool-specific parameters here + someOption: boolean; +} + +export const defaultParameters: [ToolName]Parameters = { + someOption: false, +}; + +export const use[ToolName]Parameters = (): BaseParametersHook<[ToolName]Parameters> => { + return useBaseParameters({ + defaultParameters, + endpointName: 'your-endpoint-name', + validateFn: (params) => true, // Add validation logic + }); +}; +``` + +**Operation Hook** (`use[ToolName]Operation.ts`): +```typescript +import { useTranslation } from 'react-i18next'; +import { ToolType, useToolOperation } from '../shared/useToolOperation'; +import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; + +export const build[ToolName]FormData = (parameters: [ToolName]Parameters, file: File): FormData => { + const formData = new FormData(); + formData.append('fileInput', file); + // Add parameters to formData + return formData; +}; + +export const [toolName]OperationConfig = { + toolType: ToolType.singleFile, // or ToolType.multiFile (buildFormData's file parameter will need to be updated) + buildFormData: build[ToolName]FormData, + operationType: '[toolName]', + endpoint: '/api/v1/category/endpoint-name', + filePrefix: 'processed_', // Will be overridden with translation + defaultParameters, +} as const; + +export const use[ToolName]Operation = () => { + const { t } = useTranslation(); + return useToolOperation({ + ...[toolName]OperationConfig, + filePrefix: t('[toolName].filenamePrefix', 'processed') + '_', + getErrorMessage: createStandardErrorHandler(t('[toolName].error.failed', 'Operation failed')) + }); +}; +``` + +**Main Component** (`[ToolName].tsx`): +```typescript +import { useTranslation } from "react-i18next"; +import { createToolFlow } from "../components/tools/shared/createToolFlow"; +import { use[ToolName]Parameters } from "../hooks/tools/[toolName]/use[ToolName]Parameters"; +import { use[ToolName]Operation } from "../hooks/tools/[toolName]/use[ToolName]Operation"; +import { useBaseTool } from "../hooks/tools/shared/useBaseTool"; +import { BaseToolProps, ToolComponent } from "../types/tool"; + +const [ToolName] = (props: BaseToolProps) => { + const { t } = useTranslation(); + const base = useBaseTool('[toolName]', use[ToolName]Parameters, use[ToolName]Operation, props); + + return createToolFlow({ + files: { + selectedFiles: base.selectedFiles, + isCollapsed: base.hasResults, + placeholder: t("[toolName].files.placeholder", "Select files to get started"), + }, + steps: [ + // Add settings steps if needed + ], + executeButton: { + text: t("[toolName].submit", "Process"), + isVisible: !base.hasResults, + loadingText: t("loading"), + onClick: base.handleExecute, + disabled: !base.params.validateParameters() || !base.hasFiles || !base.endpointEnabled, + }, + review: { + isVisible: base.hasResults, + operation: base.operation, + title: t("[toolName].results.title", "Results"), + onFileClick: base.handleThumbnailClick, + onUndo: base.handleUndo, + }, + }); +}; + +[ToolName].tool = () => use[ToolName]Operation; +export default [ToolName] as ToolComponent; +``` + +**Note**: Some existing tools (like AddPassword, Compress) use a legacy pattern with manual hook management. **Always use the Modern Pattern above for new tools** - it's cleaner, more maintainable, and includes automation support. + +## 3. Register Tool in System +Update these files to register your new tool: + +**Tool Registry** (`frontend/src/data/useTranslatedToolRegistry.tsx`): +1. Add imports at the top: +```typescript +import [ToolName] from "../tools/[ToolName]"; +import { [toolName]OperationConfig } from "../hooks/tools/[toolName]/use[ToolName]Operation"; +import [ToolName]Settings from "../components/tools/[toolName]/[ToolName]Settings"; +``` + +2. Add tool entry in the `allTools` object: +```typescript +[toolName]: { + icon: , + name: t("home.[toolName].title", "Tool Name"), + component: [ToolName], + description: t("home.[toolName].desc", "Tool description"), + categoryId: ToolCategoryId.STANDARD_TOOLS, // or appropriate category + subcategoryId: SubcategoryId.APPROPRIATE_SUBCATEGORY, + maxFiles: -1, // or specific number + endpoints: ["endpoint-name"], + operationConfig: [toolName]OperationConfig, + settingsComponent: [ToolName]Settings, // if settings exist +}, +``` + +## 4. Add Tooltips (Optional but Recommended) +Create user-friendly tooltips to help non-technical users understand your tool. **Use simple, clear language - avoid technical jargon:** + +**Tooltip Hook** (`frontend/src/components/tooltips/use[ToolName]Tips.ts`): +```typescript +import { useTranslation } from 'react-i18next'; +import { TooltipContent } from '../../types/tips'; + +export const use[ToolName]Tips = (): TooltipContent => { + const { t } = useTranslation(); + + return { + header: { + title: t("[toolName].tooltip.header.title", "Tool Overview") + }, + tips: [ + { + title: t("[toolName].tooltip.description.title", "What does this tool do?"), + description: t("[toolName].tooltip.description.text", "Simple explanation in everyday language that non-technical users can understand."), + bullets: [ + t("[toolName].tooltip.description.bullet1", "Easy-to-understand benefit 1"), + t("[toolName].tooltip.description.bullet2", "Easy-to-understand benefit 2") + ] + } + // Add more tip sections as needed + ] + }; +}; +``` + +**Add tooltip to your main component:** +```typescript +import { use[ToolName]Tips } from "../components/tooltips/use[ToolName]Tips"; + +const [ToolName] = (props: BaseToolProps) => { + const tips = use[ToolName]Tips(); + + // In your steps array: + steps: [ + { + title: t("[toolName].steps.settings", "Settings"), + tooltip: tips, // Add this line + content: <[ToolName]Settings ... /> + } + ] +``` + +## 5. Add Translations +Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately. + +**File to update:** `frontend/public/locales/en-GB/translation.json` + +**Required Translation Keys**: +```json +{ + "home": { + "[toolName]": { + "title": "Tool Name", + "desc": "Tool description" + } + }, + "[toolName]": { + "title": "Tool Name", + "submit": "Process", + "filenamePrefix": "processed", + "files": { + "placeholder": "Select files to get started" + }, + "steps": { + "settings": "Settings" + }, + "options": { + "title": "Tool Options", + "someOption": "Option Label", + "someOption.desc": "Option description", + "note": "General information about the tool." + }, + "results": { + "title": "Results" + }, + "error": { + "failed": "Operation failed" + }, + "tooltip": { + "header": { + "title": "Tool Overview" + }, + "description": { + "title": "What does this tool do?", + "text": "Simple explanation in everyday language", + "bullet1": "Easy-to-understand benefit 1", + "bullet2": "Easy-to-understand benefit 2" + } + } + } +} +``` + +**Translation Notes:** +- **Only update `en-GB/translation.json`** - other locale files are managed separately +- Use descriptive keys that match your component's `t()` calls +- Include tooltip translations if you created tooltip hooks +- Add `options.*` keys if your tool has settings with descriptions + +**Tooltip Writing Guidelines:** +- **Use simple, everyday language** - avoid technical terms like "converts interactive elements" +- **Focus on benefits** - explain what the user gains, not how it works internally +- **Use concrete examples** - "text boxes become regular text" vs "form fields are flattened" +- **Answer user questions** - "What does this do?", "When should I use this?", "What's this option for?" +- **Keep descriptions concise** - 1-2 sentences maximum per section +- **Use bullet points** for multiple benefits or features + +## 6. Migration from Thymeleaf +When migrating existing Thymeleaf templates: + +1. **Identify Form Parameters**: Look at the original `
` inputs to determine parameter structure +2. **Extract Translation Keys**: Find `#{key.name}` references and add them to JSON translations (For many tools these translations will already exist but some parts will be missing) +3. **Map API Endpoint**: Note the `th:action` URL for the operation hook +4. **Preserve Functionality**: Ensure all original form behaviour is replicated which is applicable to V2 react UI + +## 7. Testing Your Tool +- Verify tool appears in UI with correct icon and description +- Test with various file sizes and types +- Confirm translations work +- Check error handling +- Test undo functionality +- Verify results display correctly + +## Tool Development Patterns + +### Three Tool Patterns: + +**Pattern 1: Single-File Tools** (Individual processing) +- Backend processes one file per API call +- Set `multiFileEndpoint: false` +- Examples: Compress, Rotate + +**Pattern 2: Multi-File Tools** (Batch processing) +- Backend accepts `MultipartFile[]` arrays in single API call +- Set `multiFileEndpoint: true` +- Examples: Split, Merge, Overlay + +**Pattern 3: Complex Tools** (Custom processing) +- Tools with complex routing logic or non-standard processing +- Provide `customProcessor` for full control +- Examples: Convert, OCR diff --git a/CLAUDE.md b/CLAUDE.md index be4e92201..a806d0098 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -208,6 +208,7 @@ return useToolOperation({ - **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`) - **Performance Target**: Must handle PDFs up to 100GB+ without browser crashes - **Preview System**: Tools can preview results without polluting main file context (see Split tool implementation) +- **Adding Tools**: See `ADDING_TOOLS.md` for complete guide to creating new PDF tools ## Communication Style - Be direct and to the point diff --git a/devGuide/FILE_HISTORY_SPECIFICATION.md b/devGuide/FILE_HISTORY_SPECIFICATION.md new file mode 100644 index 000000000..d624d28fb --- /dev/null +++ b/devGuide/FILE_HISTORY_SPECIFICATION.md @@ -0,0 +1,319 @@ +# Stirling PDF File History Specification + +## Overview + +Stirling PDF implements a client-side file history system using IndexedDB storage. File metadata, including version history and tool chains, are stored as `StirlingFileStub` objects that travel alongside the actual file data. This enables comprehensive version tracking, tool history, and file lineage management without modifying PDF content. + +## Storage Architecture + +### IndexedDB-Based Storage +File history is stored in the browser's IndexedDB using the `fileStorage` service, providing: +- **Persistent storage**: Survives browser sessions and page reloads +- **Large capacity**: Supports files up to 100GB+ with full metadata +- **Fast queries**: Optimized for file browsing and history lookups +- **Type safety**: Structured TypeScript interfaces + +### Core Data Structures + +```typescript +interface StirlingFileStub extends BaseFileMetadata { + id: FileId; // Unique file identifier (UUID) + quickKey: string; // Deduplication key: name|size|lastModified + thumbnailUrl?: string; // Generated thumbnail blob URL + processedFile?: ProcessedFileMetadata; // PDF page data and processing results + + // File Metadata + name: string; + size: number; + type: string; + lastModified: number; + createdAt: number; + + // Version Control + isLeaf: boolean; // True if this is the latest version + versionNumber?: number; // Version number (1, 2, 3, etc.) + originalFileId?: string; // UUID of the root file in version chain + parentFileId?: string; // UUID of immediate parent file + + // Tool History + toolHistory?: ToolOperation[]; // Complete sequence of applied tools +} + +interface ToolOperation { + toolName: string; // Tool identifier (e.g., 'compress', 'sanitize') + timestamp: number; // When the tool was applied +} + +interface StoredStirlingFileRecord extends StirlingFileStub { + data: ArrayBuffer; // Actual file content + fileId: FileId; // Duplicate for indexing +} +``` + +## Version Management System + +### Version Progression +- **v1**: Original uploaded file (first version) +- **v2**: First tool applied to original +- **v3**: Second tool applied (inherits from v2) +- **v4**: Third tool applied (inherits from v3) +- **etc.** + +### Leaf Node System +Only the latest version of each file family is marked as `isLeaf: true`: +- **Leaf files**: Show in default file list, available for tool processing +- **History files**: Hidden by default, accessible via history expansion + +### File Relationships +``` +document.pdf (v1, isLeaf: false) + ↓ compress +document.pdf (v2, isLeaf: false) + ↓ sanitize +document.pdf (v3, isLeaf: true) ← Current active version +``` + +## Implementation Architecture + +### 1. FileStorage Service (`fileStorage.ts`) + +**Core Methods:** +```typescript +// Store file with complete metadata +async storeStirlingFile(stirlingFile: StirlingFile, stub: StirlingFileStub): Promise + +// Load file with metadata +async getStirlingFile(id: FileId): Promise +async getStirlingFileStub(id: FileId): Promise + +// Query operations +async getLeafStirlingFileStubs(): Promise +async getAllStirlingFileStubs(): Promise + +// Version management +async markFileAsProcessed(fileId: FileId): Promise // Set isLeaf = false +async markFileAsLeaf(fileId: FileId): Promise // Set isLeaf = true +``` + +### 2. File Context Integration + +**FileContext** manages runtime state with `StirlingFileStub[]` in memory: +```typescript +interface FileContextState { + files: { + ids: FileId[]; + byId: Record; + }; +} +``` + +**Key Operations:** +- `addFiles()`: Stores new files with initial metadata +- `addStirlingFileStubs()`: Loads existing files from storage with preserved metadata +- `consumeFiles()`: Processes files through tools, creating new versions + +### 3. Tool Operation Integration + +**Tool Processing Flow:** +1. **Input**: User selects files (marked as `isLeaf: true`) +2. **Processing**: Backend processes files and returns results +3. **History Creation**: New `StirlingFileStub` created with: + - Incremented version number + - Updated tool history + - Parent file reference +4. **Storage**: Both parent (marked `isLeaf: false`) and child (marked `isLeaf: true`) stored +5. **UI Update**: FileContext updated with new file state + +**Child Stub Creation:** +```typescript +export function createChildStub( + parentStub: StirlingFileStub, + operation: { toolName: string; timestamp: number }, + resultingFile: File, + thumbnail?: string +): StirlingFileStub { + return { + id: createFileId(), + name: resultingFile.name, + size: resultingFile.size, + type: resultingFile.type, + lastModified: resultingFile.lastModified, + quickKey: createQuickKey(resultingFile), + createdAt: Date.now(), + isLeaf: true, + + // Version Control + versionNumber: (parentStub.versionNumber || 1) + 1, + originalFileId: parentStub.originalFileId || parentStub.id, + parentFileId: parentStub.id, + + // Tool History + toolHistory: [...(parentStub.toolHistory || []), operation], + thumbnailUrl: thumbnail + }; +} +``` + +## UI Integration + +### File Manager History Display + +**FileManager** (`FileManager.tsx`) provides: +- **Default View**: Shows only leaf files (`isLeaf: true`) +- **History Expansion**: Click to show all versions of a file family +- **History Groups**: Nested display using `FileHistoryGroup.tsx` + +**FileListItem** (`FileListItem.tsx`) displays: +- **Version Badges**: v1, v2, v3 indicators +- **Tool Chain**: Complete processing history in tooltips +- **History Actions**: "Show/Hide History" toggle, "Restore" for history files + +### FileManagerContext Integration + +**File Selection Flow:** +```typescript +// Recent files (from storage) +onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void +// Calls: actions.addStirlingFileStubs(stirlingFileStubs, options) + +// New uploads +onFileUpload: (files: File[]) => void +// Calls: actions.addFiles(files, options) +``` + +**History Management:** +```typescript +// Toggle history visibility +const { expandedFileIds, onToggleExpansion } = useFileManagerContext(); + +// Restore history file to current +const handleAddToRecents = (file: StirlingFileStub) => { + fileStorage.markFileAsLeaf(file.id); // Make this version current +}; +``` + +## Data Flow + +### New File Upload +``` +1. User uploads files → addFiles() +2. Generate thumbnails and page count +3. Create StirlingFileStub with isLeaf: true, versionNumber: 1 +4. Store both StirlingFile + StirlingFileStub in IndexedDB +5. Dispatch to FileContext state +``` + +### Tool Processing +``` +1. User selects tool + files → useToolOperation() +2. API processes files → returns processed File objects +3. createChildStub() for each result: + - Parent marked isLeaf: false + - Child created with isLeaf: true, incremented version +4. Store all files with updated metadata +5. Update FileContext with new state +``` + +### File Loading (Recent Files) +``` +1. User selects from FileManager → onRecentFileSelect() +2. addStirlingFileStubs() with preserved metadata +3. Load actual StirlingFile data from storage +4. Files appear in workbench with complete history intact +``` + +## Performance Optimizations + +### Metadata Regeneration +When loading files from storage, missing `processedFile` data is regenerated: +```typescript +// In addStirlingFileStubs() +const needsProcessing = !record.processedFile || + !record.processedFile.pages || + record.processedFile.pages.length === 0; + +if (needsProcessing) { + const result = await generateThumbnailWithMetadata(stirlingFile); + record.processedFile = createProcessedFile(result.pageCount, result.thumbnail); +} +``` + +### Memory Management +- **Blob URL Tracking**: Automatic cleanup of thumbnail URLs +- **Lazy Loading**: Files loaded from storage only when needed +- **LRU Caching**: File objects cached in memory with size limits + +## File Deduplication + +### QuickKey System +Files are deduplicated using `quickKey` format: +```typescript +const quickKey = `${file.name}|${file.size}|${file.lastModified}`; +``` + +This prevents duplicate uploads while allowing different versions of the same logical file. + +## Error Handling + +### Graceful Degradation +- **Storage Failures**: Files continue to work without persistence +- **Metadata Issues**: Missing metadata regenerated on demand +- **Version Conflicts**: Automatic version number resolution + +### Recovery Scenarios +- **Corrupted Storage**: Automatic cleanup and re-initialization +- **Missing Files**: Stubs cleaned up automatically +- **Version Mismatches**: Automatic version chain reconstruction + +## Developer Guidelines + +### Adding File History to New Components + +1. **Use FileContext Actions**: +```typescript +const { actions } = useFileActions(); +await actions.addFiles(files); // For new uploads +await actions.addStirlingFileStubs(stubs); // For existing files +``` + +2. **Preserve Metadata When Processing**: +```typescript +const childStub = createChildStub(parentStub, { + toolName: 'compress', + timestamp: Date.now() +}, processedFile, thumbnail); +``` + +3. **Handle Storage Operations**: +```typescript +await fileStorage.storeStirlingFile(stirlingFile, stirlingFileStub); +const stub = await fileStorage.getStirlingFileStub(fileId); +``` + +### Testing File History + +1. **Upload files**: Should show v1, marked as leaf +2. **Apply tool**: Should create v2, mark v1 as non-leaf +3. **Check FileManager**: History should show both versions +4. **Restore old version**: Should mark old version as leaf +5. **Check storage**: Both versions should persist in IndexedDB + +## Future Enhancements + +### Potential Improvements +- **Branch History**: Support for parallel processing branches +- **History Export**: Export complete version history as JSON +- **Conflict Resolution**: Handle concurrent modifications +- **Cloud Sync**: Sync history across devices +- **Compression**: Compress historical file data + +### API Extensions +- **Batch Operations**: Process multiple version chains simultaneously +- **Search Integration**: Search within tool history and file metadata +- **Analytics**: Track usage patterns and tool effectiveness + +--- + +**Last Updated**: January 2025 +**Implementation**: Stirling PDF Frontend v2 +**Storage Version**: IndexedDB with fileStorage service \ No newline at end of file diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 000000000..c0f9fd678 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,42 @@ +// @ts-check + +import eslint from '@eslint/js'; +import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; + +export default defineConfig( + eslint.configs.recommended, + tseslint.configs.recommended, + { + ignores: [ + "dist", // Contains 3rd party code + "public", // Contains 3rd party code + ], + }, + { + rules: { + "no-undef": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/no-empty-object-type": [ + "error", + { + // Allow empty extending interfaces because there's no real reason not to, and it makes it obvious where to put extra attributes in the future + allowInterfaces: 'with-single-extends', + }, + ], + "@typescript-eslint/no-explicit-any": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/no-require-imports": "off", // Temporarily disabled until codebase conformant + "@typescript-eslint/no-unused-vars": [ + "error", + { + "args": "all", // All function args must be used (or explicitly ignored) + "argsIgnorePattern": "^_", // Allow unused variables beginning with an underscore + "caughtErrors": "all", // Caught errors must be used (or explicitly ignored) + "caughtErrorsIgnorePattern": "^_", // Allow unused variables beginning with an underscore + "destructuredArrayIgnorePattern": "^_", // Allow unused variables beginning with an underscore + "varsIgnorePattern": "^_", // Allow unused variables beginning with an underscore + "ignoreRestSiblings": true, // Allow unused variables when removing attributes from objects (otherwise this requires explicit renaming like `({ x: _x, ...y }) => y`, which is clunky) + }, + ], + }, + } +); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7e6d23d50..342f0512f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,6 +41,7 @@ "web-vitals": "^2.1.4" }, "devDependencies": { + "@eslint/js": "^9.34.0", "@iconify-json/material-symbols": "^1.2.33", "@iconify/utils": "^3.0.1", "@playwright/test": "^1.40.0", @@ -49,6 +50,7 @@ "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.0", "@vitest/coverage-v8": "^1.0.0", + "eslint": "^9.34.0", "jsdom": "^23.0.0", "license-checker": "^25.0.1", "madge": "^8.0.0", @@ -56,7 +58,8 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", - "typescript": "^5.8.3", + "typescript": "^5.9.2", + "typescript-eslint": "^8.42.0", "vite": "^6.3.5", "vitest": "^1.0.0" } @@ -1164,6 +1167,173 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@floating-ui/core": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz", @@ -1217,6 +1387,72 @@ "integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==", "license": "MIT" }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@iconify-json/material-symbols": { "version": "1.2.33", "resolved": "https://registry.npmjs.org/@iconify-json/material-symbols/-/material-symbols-1.2.33.tgz", @@ -2659,6 +2895,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.2.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.1.tgz", @@ -2709,15 +2952,80 @@ "@types/react": "*" } }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.40.0.tgz", - "integrity": "sha512-/A89vz7Wf5DEXsGVvcGdYKbVM9F7DyFXj52lNYUDS1L9yJfqjW/fIp5PgMuEJL/KeqVTe2QSbXAGUZljDUpArw==", + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.42.0.tgz", + "integrity": "sha512-Aq2dPqsQkxHOLfb2OPv43RnIvfj05nw8v/6n3B2NABIPpHnjQnaLo9QGMTvml+tv4korl/Cjfrb/BYhoL8UUTQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.40.0", - "@typescript-eslint/types": "^8.40.0", + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/type-utils": "8.42.0", + "@typescript-eslint/utils": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.42.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.42.0.tgz", + "integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.42.0.tgz", + "integrity": "sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.42.0", + "@typescript-eslint/types": "^8.42.0", "debug": "^4.3.4" }, "engines": { @@ -2731,10 +3039,28 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz", + "integrity": "sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz", - "integrity": "sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz", + "integrity": "sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==", "dev": true, "license": "MIT", "engines": { @@ -2748,10 +3074,35 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.42.0.tgz", + "integrity": "sha512-9KChw92sbPTYVFw3JLRH1ockhyR3zqqn9lQXol3/YbI6jVxzWoGcT3AsAW0mu1MY0gYtsXnUGV/AKpkAj5tVlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/utils": "8.42.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/@typescript-eslint/types": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.40.0.tgz", - "integrity": "sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz", + "integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==", "dev": true, "license": "MIT", "engines": { @@ -2763,16 +3114,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz", - "integrity": "sha512-k1z9+GJReVVOkc1WfVKs1vBrR5MIKKbdAjDTPvIK3L8De6KbFfPFt6BKpdkdk7rZS2GtC/m6yI5MYX+UsuvVYQ==", + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz", + "integrity": "sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.40.0", - "@typescript-eslint/tsconfig-utils": "8.40.0", - "@typescript-eslint/types": "8.40.0", - "@typescript-eslint/visitor-keys": "8.40.0", + "@typescript-eslint/project-service": "8.42.0", + "@typescript-eslint/tsconfig-utils": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/visitor-keys": "8.42.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2817,14 +3168,38 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.40.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.40.0.tgz", - "integrity": "sha512-8CZ47QwalyRjsypfwnbI3hKy5gJDPmrkLjkgMxhi0+DZZ2QNx2naS6/hWoVYUHU7LU2zleF68V9miaVZvhFfTA==", + "node_modules/@typescript-eslint/utils": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.42.0.tgz", + "integrity": "sha512-JnIzu7H3RH5BrKC4NoZqRfmjqCIS1u3hGZltDYJgkVdqAezl4L9d1ZLw+36huCujtSBSAirGINF/S4UxOcR+/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.40.0", + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.42.0", + "@typescript-eslint/types": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz", + "integrity": "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.42.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -3136,6 +3511,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", @@ -3162,6 +3547,23 @@ "node": ">= 6.0.0" } }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -3236,6 +3638,13 @@ "node": ">=10" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/aria-query": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", @@ -4028,6 +4437,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/defaults": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", @@ -4492,6 +4908,84 @@ "node": ">=0.10.0" } }, + "node_modules/eslint": { + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.34.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -4505,6 +4999,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -4519,6 +5031,32 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -4592,6 +5130,13 @@ "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", "dev": true }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -4622,6 +5167,20 @@ "node": ">= 6" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -4638,6 +5197,19 @@ "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", "license": "MIT" }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/file-selector": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", @@ -4705,6 +5277,44 @@ "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", "license": "MIT" }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, "node_modules/follow-redirects": { "version": "1.15.9", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", @@ -4971,6 +5581,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -5014,6 +5637,13 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5246,6 +5876,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", @@ -5267,6 +5907,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -5558,6 +6208,19 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/jsdom": { "version": "23.2.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-23.2.0.tgz", @@ -5635,12 +6298,33 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5705,12 +6389,36 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kolorist": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", "dev": true }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/license-checker": { "version": "25.0.1", "resolved": "https://registry.npmjs.org/license-checker/-/license-checker-25.0.1.tgz", @@ -6105,12 +6813,35 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/log-symbols": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", @@ -6535,6 +7266,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -6736,6 +7474,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", @@ -6805,6 +7561,51 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate/node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-manager-detector": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", @@ -6869,6 +7670,16 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -7493,6 +8304,16 @@ "node": ">=18" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", @@ -9022,6 +9843,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", @@ -9045,9 +9879,9 @@ } }, "node_modules/typescript": { - "version": "5.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", - "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", "bin": { @@ -9058,6 +9892,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.42.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.42.0.tgz", + "integrity": "sha512-ozR/rQn+aQXQxh1YgbCzQWDFrsi9mcg+1PM3l/z5o1+20P7suOIaNg515bpr/OYt6FObz/NHcBstydDLHWeEKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.42.0", + "@typescript-eslint/parser": "8.42.0", + "@typescript-eslint/typescript-estree": "8.42.0", + "@typescript-eslint/utils": "8.42.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/ufo": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", @@ -9111,6 +9969,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/url-parse": { "version": "1.5.10", "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", @@ -10541,6 +11409,16 @@ "string-width": "^1.0.2 || 2 || 3 || 4" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 160c96c18..0b14a8ffc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -38,15 +38,18 @@ }, "scripts": { "predev": "npm run generate-icons", - "dev": "npx tsc --noEmit && vite", + "dev": "npm run typecheck && vite", "prebuild": "npm run generate-icons", - "build": "npx tsc --noEmit && vite build", + "lint": "eslint", + "build": "npm run typecheck && vite build", "preview": "vite preview", "typecheck": "tsc --noEmit", + "check": "npm run typecheck && npm run lint && npm run test:run", "generate-licenses": "node scripts/generate-licenses.js", "generate-icons": "node scripts/generate-icons.js", "generate-icons:verbose": "node scripts/generate-icons.js --verbose", "test": "vitest", + "test:run": "vitest run", "test:watch": "vitest --watch", "test:coverage": "vitest --coverage", "test:e2e": "playwright test", @@ -72,6 +75,7 @@ ] }, "devDependencies": { + "@eslint/js": "^9.34.0", "@iconify-json/material-symbols": "^1.2.33", "@iconify/utils": "^3.0.1", "@playwright/test": "^1.40.0", @@ -80,6 +84,7 @@ "@types/react-dom": "^19.1.5", "@vitejs/plugin-react": "^4.5.0", "@vitest/coverage-v8": "^1.0.0", + "eslint": "^9.34.0", "jsdom": "^23.0.0", "license-checker": "^25.0.1", "madge": "^8.0.0", @@ -87,7 +92,8 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.17.0", "postcss-simple-vars": "^7.0.1", - "typescript": "^5.8.3", + "typescript": "^5.9.2", + "typescript-eslint": "^8.42.0", "vite": "^6.3.5", "vitest": "^1.0.0" } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index ad1427bb2..f090fd08e 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -51,11 +51,11 @@ "filesSelected": "{{count}} files selected", "files": { "title": "Files", - "placeholder": "Select a PDF file in the main view to get started", "upload": "Upload", "uploadFiles": "Upload Files", "addFiles": "Add files", - "selectFromWorkbench": "Select files from the workbench or " + "selectFromWorkbench": "Select files from the workbench or ", + "selectMultipleFromWorkbench": "Select at least {{count}} files from the workbench or " }, "noFavourites": "No favourites added", "downloadComplete": "Download Complete", @@ -510,13 +510,9 @@ "title": "Show Javascript", "desc": "Searches and displays any JS injected into a PDF" }, - "autoRedact": { - "title": "Auto Redact", - "desc": "Auto Redacts(Blacks out) text in a PDF based on input text" - }, "redact": { - "title": "Manual Redaction", - "desc": "Redacts a PDF based on selected text, drawn shapes and/or selected page(s)" + "title": "Redact", + "desc": "Redacts (blacks out) a PDF based on selected text, drawn shapes and/or selected page(s)" }, "overlay-pdfs": { "title": "Overlay PDFs", @@ -660,11 +656,29 @@ "merge": { "tags": "merge,Page operations,Back end,server side", "title": "Merge", - "header": "Merge multiple PDFs (2+)", - "sortByName": "Sort by name", - "sortByDate": "Sort by date", - "removeCertSign": "Remove digital signature in the merged file?", - "submit": "Merge" + "removeDigitalSignature": "Remove digital signature in the merged file?", + "generateTableOfContents": "Generate table of contents in the merged file?", + "removeDigitalSignature.tooltip": { + "title": "Remove Digital Signature", + "description": "Digital signatures will be invalidated when merging files. Check this to remove them from the final merged PDF." + }, + "generateTableOfContents.tooltip": { + "title": "Generate Table of Contents", + "description": "Automatically creates a clickable table of contents in the merged PDF based on the original file names and page numbers." + }, + "submit": "Merge", + "sortBy": { + "description": "Files will be merged in the order they're selected. Drag to reorder or sort below.", + "label": "Sort By", + "filename": "File Name", + "dateModified": "Date Modified", + "ascending": "Ascending", + "descending": "Descending", + "sort": "Sort" + }, + "error": { + "failed": "An error occurred while merging the PDFs." + } }, "split": { "tags": "Page operations,divide,Multi Page,cut,server side", @@ -681,7 +695,116 @@ "8": "Document #6: Page 10" }, "splitPages": "Enter pages to split on:", - "submit": "Split" + "submit": "Split", + "steps": { + "chooseMethod": "Choose Method", + "settings": "Settings" + }, + "settings": { + "selectMethodFirst": "Please select a split method first" + }, + "error": { + "failed": "An error occurred while splitting the PDF." + }, + "method": { + "label": "Choose split method", + "placeholder": "Select how to split the PDF" + }, + "methods": { + "prefix": { + "splitAt": "Split at", + "splitBy": "Split by" + }, + "byPages": { + "name": "Page Numbers", + "desc": "Extract specific pages (1,3,5-10)", + "tooltip": "Enter page numbers separated by commas or ranges with hyphens" + }, + "bySections": { + "name": "Sections", + "desc": "Divide pages into grid sections", + "tooltip": "Split each page into horizontal and vertical sections" + }, + "bySize": { + "name": "File Size", + "desc": "Limit maximum file size", + "tooltip": "Specify maximum file size (e.g. 10MB, 500KB)" + }, + "byPageCount": { + "name": "Page Count", + "desc": "Fixed pages per file", + "tooltip": "Enter the number of pages for each split file" + }, + "byDocCount": { + "name": "Document Count", + "desc": "Create specific number of files", + "tooltip": "Enter how many files you want to create" + }, + "byChapters": { + "name": "Chapters", + "desc": "Split at bookmark boundaries", + "tooltip": "Uses PDF bookmarks to determine split points" + }, + "byPageDivider": { + "name": "Page Divider", + "desc": "Auto-split with divider sheets", + "tooltip": "Use QR code divider sheets between documents when scanning" + } + }, + "value": { + "fileSize": { + "label": "File Size", + "placeholder": "e.g. 10MB, 500KB" + }, + "pageCount": { + "label": "Pages per File", + "placeholder": "e.g. 5, 10" + }, + "docCount": { + "label": "Number of Files", + "placeholder": "e.g. 3, 5" + } + }, + "tooltip": { + "header": { + "title": "Split Methods Overview" + }, + "byPages": { + "title": "Split at Page Numbers", + "text": "Split your PDF at specific page numbers. Using 'n' splits after page n. Using 'n-m' splits before page n and after page m.", + "bullet1": "Single split points: 3,7 (splits after pages 3 and 7)", + "bullet2": "Range split points: 3-8 (splits before page 3 and after page 8)", + "bullet3": "Mixed: 2,5-10,15 (splits after page 2, before page 5, after page 10, and after page 15)" + }, + "bySections": { + "title": "Split by Grid Sections", + "text": "Divide each page into a grid of sections. Useful for splitting documents with multiple columns or extracting specific areas.", + "bullet1": "Horizontal: Number of rows to create", + "bullet2": "Vertical: Number of columns to create", + "bullet3": "Merge: Combine all sections into one PDF" + }, + "bySize": { + "title": "Split by File Size", + "text": "Create multiple PDFs that don't exceed a specified file size. Ideal for file size limitations or email attachments.", + "bullet1": "Use MB for larger files (e.g., 10MB)", + "bullet2": "Use KB for smaller files (e.g., 500KB)", + "bullet3": "System will split at page boundaries" + }, + "byCount": { + "title": "Split by Count", + "text": "Create multiple PDFs with a specific number of pages or documents each.", + "bullet1": "Page Count: Fixed number of pages per file", + "bullet2": "Document Count: Fixed number of output files", + "bullet3": "Useful for batch processing workflows" + }, + "byChapters": { + "title": "Split by Chapters", + "text": "Use PDF bookmarks to automatically split at chapter boundaries. Requires PDFs with bookmark structure.", + "bullet1": "Bookmark Level: Which level to split on (1=top level)", + "bullet2": "Include Metadata: Preserve document properties", + "bullet3": "Allow Duplicates: Handle repeated bookmark names" + } + } }, "rotate": { "tags": "server side", @@ -1317,7 +1440,48 @@ "title": "Flatten", "header": "Flatten PDF", "flattenOnlyForms": "Flatten only forms", - "submit": "Flatten" + "submit": "Flatten", + "filenamePrefix": "flattened", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "steps": { + "settings": "Settings" + }, + "options": { + "stepTitle": "Flatten Options", + "title": "Flatten Options", + "flattenOnlyForms": "Flatten only forms", + "flattenOnlyForms.desc": "Only flatten form fields, leaving other interactive elements intact", + "note": "Flattening removes interactive elements from the PDF, making them non-editable." + }, + "results": { + "title": "Flatten Results" + }, + "error": { + "failed": "An error occurred while flattening the PDF." + }, + "tooltip": { + "header": { + "title": "About Flattening PDFs" + }, + "description": { + "title": "What does flattening do?", + "text": "Flattening makes your PDF non-editable by turning fillable forms and buttons into regular text and images. The PDF will look exactly the same, but no one can change or fill in the forms anymore. Perfect for sharing completed forms, creating final documents for records, or ensuring the PDF looks the same everywhere.", + "bullet1": "Text boxes become regular text (can't be edited)", + "bullet2": "Checkboxes and buttons become pictures", + "bullet3": "Great for final versions you don't want changed", + "bullet4": "Ensures consistent appearance across all devices" + }, + "formsOnly": { + "title": "What does 'Flatten only forms' mean?", + "text": "This option only removes the ability to fill in forms, but keeps other features working like clicking links, viewing bookmarks, and reading comments.", + "bullet1": "Forms become non-editable", + "bullet2": "Links still work when clicked", + "bullet3": "Comments and notes remain visible", + "bullet4": "Bookmarks still help you navigate" + } + } }, "repair": { "tags": "fix,restore,correction,recover", @@ -1528,7 +1692,6 @@ } }, "scalePages": { - "tags": "resize,modify,dimension,adapt", "title": "Adjust page-scale", "header": "Adjust page-scale", "pageSize": "Size of a page of the document.", @@ -1536,6 +1699,44 @@ "scaleFactor": "Zoom level (crop) of a page.", "submit": "Submit" }, + "adjustPageScale": { + "tags": "resize,modify,dimension,adapt", + "title": "Adjust Page Scale", + "header": "Adjust Page Scale", + "scaleFactor": { + "label": "Scale Factor" + }, + "pageSize": { + "label": "Target Page Size", + "keep": "Keep Original Size", + "letter": "Letter", + "legal": "Legal" + }, + "submit": "Adjust Page Scale", + "error": { + "failed": "An error occurred while adjusting the page scale." + }, + "tooltip": { + "header": { + "title": "Page Scale Settings Overview" + }, + "description": { + "title": "Description", + "text": "Adjust the size of PDF content and change the page dimensions." + }, + "scaleFactor": { + "title": "Scale Factor", + "text": "Controls how large or small the content appears on the page. Content is scaled and centred - if scaled content is larger than the page size, it may be cropped.", + "bullet1": "1.0 = Original size", + "bullet2": "0.5 = Half size (50% smaller)", + "bullet3": "2.0 = Double size (200% larger, may crop)" + }, + "pageSize": { + "title": "Target Page Size", + "text": "Sets the dimensions of the output PDF pages. 'Keep Original Size' maintains current dimensions, whilst other options resize to standard paper sizes." + } + } + }, "add-page-numbers": { "tags": "paginate,label,organize,index" }, @@ -1543,7 +1744,29 @@ "tags": "auto-detect,header-based,organize,relabel", "title": "Auto Rename", "header": "Auto Rename PDF", - "submit": "Auto Rename" + "description": "Automatically finds the title from your PDF content and uses it as the filename.", + "submit": "Auto Rename", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred whilst auto-renaming the PDF." + }, + "results": { + "title": "Auto-Rename Results" + }, + "tooltip": { + "header": { + "title": "How Auto-Rename Works" + }, + "howItWorks": { + "title": "Smart Renaming", + "text": "Automatically finds the title from your PDF content and uses it as the filename.", + "bullet1": "Looks for text that appears to be a title or heading", + "bullet2": "Creates a clean, valid filename from the detected title", + "bullet3": "Keeps the original name if no suitable title is found" + } + } }, "adjust-contrast": { "tags": "color-correction,tune,modify,enhance,colour-correction" @@ -1635,50 +1858,123 @@ "downloadJS": "Download Javascript", "submit": "Show" }, - "autoRedact": { - "tags": "Redact,Hide,black out,black,marker,hidden", - "title": "Auto Redact", - "header": "Auto Redact", - "colorLabel": "Colour", - "textsToRedactLabel": "Text to Redact (line-separated)", - "textsToRedactPlaceholder": "e.g. \\nConfidential \\nTop-Secret", - "useRegexLabel": "Use Regex", - "wholeWordSearchLabel": "Whole Word Search", - "customPaddingLabel": "Custom Extra Padding", - "convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)", - "submitButton": "Submit" - }, "redact": { - "tags": "Redact,Hide,black out,black,marker,hidden,manual", - "title": "Manual Redaction", - "header": "Manual Redaction", + "tags": "Redact,Hide,black out,black,marker,hidden,auto redact,manual redact", + "title": "Redact", "submit": "Redact", - "textBasedRedaction": "Text based Redaction", - "pageBasedRedaction": "Page-based Redaction", - "convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)", - "pageRedactionNumbers": { - "title": "Pages", - "placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" + "error": { + "failed": "An error occurred while redacting the PDF." }, - "redactionColor": { - "title": "Redaction Color" + "modeSelector": { + "title": "Redaction Method", + "mode": "Mode", + "automatic": "Automatic", + "automaticDesc": "Redact text based on search terms", + "manual": "Manual", + "manualDesc": "Click and drag to redact specific areas", + "manualComingSoon": "Manual redaction coming soon" }, - "export": "Export", - "upload": "Upload", - "boxRedaction": "Box draw redaction", - "zoom": "Zoom", - "zoomIn": "Zoom in", - "zoomOut": "Zoom out", - "nextPage": "Next Page", - "previousPage": "Previous Page", - "toggleSidebar": "Toggle Sidebar", - "showThumbnails": "Show Thumbnails", - "showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)", - "showAttatchments": "Show Attachments", - "showLayers": "Show Layers (double-click to reset all layers to the default state)", - "colourPicker": "Colour Picker", - "findCurrentOutlineItem": "Find current outline item", - "applyChanges": "Apply Changes" + "auto": { + "header": "Auto Redact", + "settings": { + "title": "Redaction Settings", + "advancedTitle": "Advanced" + }, + "colorLabel": "Box Colour", + "wordsToRedact": { + "title": "Words to Redact", + "placeholder": "Enter a word", + "add": "Add", + "examples": "Examples: Confidential, Top-Secret" + }, + "useRegexLabel": "Use Regex", + "wholeWordSearchLabel": "Whole Word Search", + "customPaddingLabel": "Custom Extra Padding", + "convertPDFToImageLabel": "Convert PDF to PDF-Image" + }, + "tooltip": { + "mode": { + "header": { + "title": "Redaction Method" + }, + "automatic": { + "title": "Automatic Redaction", + "text": "Automatically finds and redacts specified text throughout the document. Perfect for removing consistent sensitive information like names, addresses, or confidential markers." + }, + "manual": { + "title": "Manual Redaction", + "text": "Click and drag to manually select specific areas to redact. Gives you precise control over what gets redacted. (Coming soon)" + } + }, + "words": { + "header": { + "title": "Words to Redact" + }, + "description": { + "title": "Text Matching", + "text": "Enter words or phrases to find and redact in your document. Each word will be searched for separately." + }, + "bullet1": "Add one word at a time", + "bullet2": "Press Enter or click 'Add Another' to add", + "bullet3": "Click × to remove words", + "examples": { + "title": "Common Examples", + "text": "Typical words to redact include: bank details, email addresses, or specific names." + } + }, + "advanced": { + "header": { + "title": "Advanced Redaction Settings" + }, + "color": { + "title": "Box Colour & Padding", + "text": "Customise the appearance of redaction boxes. Black is standard, but you can choose any colour. Padding adds extra space around the found text." + }, + "regex": { + "title": "Use Regex", + "text": "Enable regular expressions for advanced pattern matching. Useful for finding phone numbers, emails, or complex patterns.", + "bullet1": "Example: \\d{4}-\\d{2}-\\d{2} to match any dates in YYYY-MM-DD format", + "bullet2": "Use with caution - test thoroughly" + }, + "wholeWord": { + "title": "Whole Word Search", + "text": "Only match complete words, not partial matches. 'John' won't match 'Johnson' when enabled." + }, + "convert": { + "title": "Convert to PDF-Image", + "text": "Converts the PDF to an image-based PDF after redaction. This ensures text behind redaction boxes is completely removed and unrecoverable." + } + } + }, + "manual": { + "header": "Manual Redaction", + "textBasedRedaction": "Text-based Redaction", + "pageBasedRedaction": "Page-based Redaction", + "convertPDFToImageLabel": "Convert PDF to PDF-Image (Used to remove text behind the box)", + "pageRedactionNumbers": { + "title": "Pages", + "placeholder": "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)" + }, + "redactionColor": { + "title": "Redaction Colour" + }, + "export": "Export", + "upload": "Upload", + "boxRedaction": "Box draw redaction", + "zoom": "Zoom", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "nextPage": "Next Page", + "previousPage": "Previous Page", + "toggleSidebar": "Toggle Sidebar", + "showThumbnails": "Show Thumbnails", + "showDocumentOutline": "Show Document Outline (double-click to expand/collapse all items)", + "showAttachments": "Show Attachments", + "showLayers": "Show Layers (double-click to reset all layers to the default state)", + "colourPicker": "Colour Picker", + "findCurrentOutlineItem": "Find current outline item", + "applyChanges": "Apply Changes" + } }, "tableExtraxt": { "tags": "CSV,Table Extraction,extract,convert" @@ -1889,6 +2185,11 @@ "title": "Compress", "desc": "Compress PDFs to reduce their file size.", "header": "Compress PDF", + "method": { + "title": "Compression Method", + "quality": "Quality", + "filesize": "File Size" + }, "credit": "This service uses qpdf for PDF Compress/Optimisation.", "grayscale": { "label": "Apply Grayscale for Compression" @@ -2220,6 +2521,13 @@ "storageLow": "Storage is running low. Consider removing old files.", "supportMessage": "Powered by browser database storage for unlimited capacity", "noFileSelected": "No files selected", + "showHistory": "Show History", + "hideHistory": "Hide History", + "fileHistory": "File History", + "loadingHistory": "Loading History...", + "lastModified": "Last Modified", + "toolChain": "Tools Applied", + "restore": "Restore", "searchFiles": "Search files...", "recent": "Recent", "localFiles": "Local Files", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 5265483fc..7ee731585 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -1129,7 +1129,28 @@ "tags": "auto-detect,header-based,organize,relabel", "title": "Auto Rename", "header": "Auto Rename PDF", - "submit": "Auto Rename" + "submit": "Auto Rename", + "files": { + "placeholder": "Select a PDF file in the main view to get started" + }, + "error": { + "failed": "An error occurred while auto-renaming the PDF." + }, + "results": { + "title": "Auto-Rename Results" + }, + "tooltip": { + "header": { + "title": "How Auto-Rename Works" + }, + "howItWorks": { + "title": "Smart Renaming", + "text": "Automatically finds the best title from your PDF content and uses it as the filename.", + "bullet1": "Looks for text that appears to be a title or heading", + "bullet2": "Creates a clean, valid filename from the detected title", + "bullet3": "Keeps the original name if no suitable title is found" + } + } }, "adjust-contrast": { "tags": "color-correction,tune,modify,enhance" diff --git a/frontend/scripts/generate-icons.js b/frontend/scripts/generate-icons.js index 0fd42a4df..d99414b66 100644 --- a/frontend/scripts/generate-icons.js +++ b/frontend/scripts/generate-icons.js @@ -107,7 +107,7 @@ async function main() { needsRegeneration = false; info(`✅ Icon set already up-to-date (${usedIcons.length} icons, ${Math.round(fs.statSync(outputPath).size / 1024)}KB)`); } - } catch (error) { + } catch { // If we can't parse existing file, regenerate needsRegeneration = true; } diff --git a/frontend/scripts/generate-licenses.js b/frontend/scripts/generate-licenses.js index aaac69800..3daf04d8d 100644 --- a/frontend/scripts/generate-licenses.js +++ b/frontend/scripts/generate-licenses.js @@ -24,7 +24,7 @@ try { // Install license-checker if not present try { require.resolve('license-checker'); - } catch (e) { + } catch { console.log('📦 Installing license-checker...'); execSync('npm install --save-dev license-checker', { stdio: 'inherit' }); } @@ -224,7 +224,7 @@ function getLicenseUrl(licenseType) { // Handle complex SPDX expressions like "(MIT AND Zlib)" or "(MIT OR CC0-1.0)" if (licenseType.includes('AND') || licenseType.includes('OR')) { // Extract the first license from compound expressions for URL - const match = licenseType.match(/\(?\s*([A-Za-z0-9\-\.]+)/); + const match = licenseType.match(/\(?\s*([A-Za-z0-9\-.]+)/); if (match && licenseUrls[match[1]]) { return licenseUrls[match[1]]; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8a30d3869..c3cbf3e89 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,9 @@ import "./styles/cookieconsent.css"; import "./index.css"; import { RightRailProvider } from "./contexts/RightRailContext"; +// Import file ID debugging helpers (development only) +import "./utils/fileIdSafety"; + // Loading component for i18next suspense const LoadingFallback = () => (
= ({ selectedTool }) => { - const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext(); - const [recentFiles, setRecentFiles] = useState([]); + const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext(); + const [recentFiles, setRecentFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); const [isMobile, setIsMobile] = useState(false); - const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager(); - - // Wrapper for storeFile that generates UUID - const storeFileWithId = useCallback(async (file: File) => { - const fileId = createFileId(); // Generate UUID for storage - return await storeFile(file, fileId); - }, [storeFile]); + const { loadRecentFiles, handleRemoveFile } = useFileManager(); // File management handlers const isFileSupported = useCallback((fileName: string) => { @@ -41,33 +34,26 @@ const FileManager: React.FC = ({ selectedTool }) => { setRecentFiles(files); }, [loadRecentFiles]); - const handleFilesSelected = useCallback(async (files: FileMetadata[]) => { + const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => { try { - // Use stored files flow that preserves original IDs - const filesWithMetadata = await Promise.all( - files.map(async (metadata) => ({ - file: await convertToFile(metadata), - originalId: metadata.id, - metadata - })) - ); - onStoredFilesSelect(filesWithMetadata); + // Use StirlingFileStubs directly - preserves all metadata! + onRecentFileSelect(files); } catch (error) { console.error('Failed to process selected files:', error); } - }, [convertToFile, onStoredFilesSelect]); + }, [onRecentFileSelect]); const handleNewFileUpload = useCallback(async (files: File[]) => { if (files.length > 0) { try { // Files will get IDs assigned through onFilesSelect -> FileContext addFiles - onFilesSelect(files); + onFileUpload(files); await refreshRecentFiles(); } catch (error) { console.error('Failed to process dropped files:', error); } } - }, [onFilesSelect, refreshRecentFiles]); + }, [onFileUpload, refreshRecentFiles]); const handleRemoveFileByIndex = useCallback(async (index: number) => { await handleRemoveFile(index, recentFiles, setRecentFiles); @@ -92,7 +78,7 @@ const FileManager: React.FC = ({ selectedTool }) => { // Cleanup any blob URLs when component unmounts useEffect(() => { return () => { - // FileMetadata doesn't have blob URLs, so no cleanup needed + // StoredFileMetadata doesn't have blob URLs, so no cleanup needed // Blob URLs are managed by FileContext and tool operations console.log('FileManager unmounting - FileContext handles blob URL cleanup'); }; @@ -153,7 +139,7 @@ const FileManager: React.FC = ({ selectedTool }) => { > void; - onMergeFiles?: (files: File[]) => void; + onOpenPageEditor?: () => void; + onMergeFiles?: (files: StirlingFile[]) => void; toolMode?: boolean; - showUpload?: boolean; - showBulkActions?: boolean; supportedExtensions?: string[]; } const FileEditor = ({ - onOpenPageEditor, - onMergeFiles, toolMode = false, - showUpload = true, - showBulkActions = true, supportedExtensions = ["pdf"] }: FileEditorProps) => { - const { t } = useTranslation(); // Utility function to check if a file extension is supported const isFileSupported = useCallback((fileName: string): boolean => { @@ -49,13 +35,10 @@ const FileEditor = ({ const { addFiles, removeFiles, reorderFiles } = useFileManagement(); // Extract needed values from state (memoized to prevent infinite loops) - const activeFiles = useMemo(() => selectors.getFiles(), [selectors.getFilesSignature()]); - const activeFileRecords = useMemo(() => selectors.getFileRecords(), [selectors.getFilesSignature()]); + const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]); const selectedFileIds = state.ui.selectedFileIds; - const isProcessing = state.ui.isProcessing; - // Get the real context actions - const { actions } = useFileActions(); + // Get navigation actions const { actions: navActions } = useNavigationActions(); // Get file selection context @@ -92,25 +75,9 @@ const FileEditor = ({ const contextSelectedIdsRef = useRef([]); contextSelectedIdsRef.current = contextSelectedIds; - // Use activeFileRecords directly - no conversion needed + // Use activeStirlingFileStubs directly - no conversion needed const localSelectedIds = contextSelectedIds; - // Helper to convert FileRecord to FileThumbnail format - const recordToFileItem = useCallback((record: any) => { - const file = selectors.getFile(record.id); - if (!file) return null; - - return { - id: record.id, - name: file.name, - pageCount: record.processedFile?.totalPages || 1, - thumbnail: record.thumbnailUrl || '', - size: file.size, - file: file - }; - }, [selectors]); - - // Process uploaded files using context const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { setError(null); @@ -161,29 +128,9 @@ const FileEditor = ({ if (extractionResult.success) { allExtractedFiles.push(...extractionResult.extractedFiles); - // Record ZIP extraction operation - const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const operation: FileOperation = { - id: operationId, - type: 'convert', - timestamp: Date.now(), - fileIds: extractionResult.extractedFiles.map(f => f.name) as FileId[] /* FIX ME: This doesn't seem right */, - status: 'pending', - metadata: { - originalFileName: file.name, - outputFileNames: extractionResult.extractedFiles.map(f => f.name), - fileSize: file.size, - parameters: { - extractionType: 'zip', - extractedCount: extractionResult.extractedCount, - totalFiles: extractionResult.totalFiles - } + if (extractionResult.errors.length > 0) { + errors.push(...extractionResult.errors); } - }; - - if (extractionResult.errors.length > 0) { - errors.push(...extractionResult.errors); - } } else { errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); } @@ -213,25 +160,6 @@ const FileEditor = ({ // Process all extracted files if (allExtractedFiles.length > 0) { - // Record upload operations for PDF files - for (const file of allExtractedFiles) { - const operationId = `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const operation: FileOperation = { - id: operationId, - type: 'upload', - timestamp: Date.now(), - fileIds: [file.name as FileId /* This doesn't seem right */], - status: 'pending', - metadata: { - originalFileName: file.name, - fileSize: file.size, - parameters: { - uploadMethod: 'drag-drop' - } - } - }; - } - // Add files to context (they will be processed automatically) await addFiles(allExtractedFiles); setStatus(`Added ${allExtractedFiles.length} files`); @@ -252,27 +180,10 @@ const FileEditor = ({ } }, [addFiles]); - const selectAll = useCallback(() => { - setSelectedFiles(activeFileRecords.map(r => r.id)); // Use FileRecord IDs directly - }, [activeFileRecords, setSelectedFiles]); - - const deselectAll = useCallback(() => setSelectedFiles([]), [setSelectedFiles]); - - const closeAllFiles = useCallback(() => { - if (activeFileRecords.length === 0) return; - - // Remove all files from context but keep in storage - const allFileIds = activeFileRecords.map(record => record.id); - removeFiles(allFileIds, false); // false = keep in storage - - // Clear selections - setSelectedFiles([]); - }, [activeFileRecords, removeFiles, setSelectedFiles]); - const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; - const targetRecord = activeFileRecords.find(r => r.id === fileId); + const targetRecord = activeStirlingFileStubs.find(r => r.id === fileId); if (!targetRecord) return; const contextFileId = fileId; // No need to create a new ID @@ -302,21 +213,12 @@ const FileEditor = ({ // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); - }, [setSelectedFiles, toolMode, setStatus, activeFileRecords]); + }, [setSelectedFiles, toolMode, setStatus, activeStirlingFileStubs]); - const toggleSelectionMode = useCallback(() => { - setSelectionMode(prev => { - const newMode = !prev; - if (!newMode) { - setSelectedFiles([]); - } - return newMode; - }); - }, [setSelectedFiles]); // File reordering handler for drag and drop const handleReorderFiles = useCallback((sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => { - const currentIds = activeFileRecords.map(r => r.id); + const currentIds = activeStirlingFileStubs.map(r => r.id); // Find indices const sourceIndex = currentIds.findIndex(id => id === sourceFileId); @@ -368,71 +270,34 @@ const FileEditor = ({ // Update status const moveCount = filesToMove.length; setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`); - }, [activeFileRecords, reorderFiles, setStatus]); + }, [activeStirlingFileStubs, reorderFiles, setStatus]); // File operations using context const handleDeleteFile = useCallback((fileId: FileId) => { - const record = activeFileRecords.find(r => r.id === fileId); + const record = activeStirlingFileStubs.find(r => r.id === fileId); const file = record ? selectors.getFile(record.id) : null; if (record && file) { - // Record close operation - const fileName = file.name; - const contextFileId = record.id; - const operationId = `close-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const operation: FileOperation = { - id: operationId, - type: 'remove', - timestamp: Date.now(), - fileIds: [fileName as FileId /* FIX ME: This doesn't seem right */], - status: 'pending', - metadata: { - originalFileName: fileName, - fileSize: record.size, - parameters: { - action: 'close', - reason: 'user_request' - } - } - }; - // Remove file from context but keep in storage (close, don't delete) + const contextFileId = record.id; removeFiles([contextFileId], false); // Remove from context selections const currentSelected = selectedFileIds.filter(id => id !== contextFileId); setSelectedFiles(currentSelected); } - }, [activeFileRecords, selectors, removeFiles, setSelectedFiles, selectedFileIds]); + }, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]); const handleViewFile = useCallback((fileId: FileId) => { - const record = activeFileRecords.find(r => r.id === fileId); + const record = activeStirlingFileStubs.find(r => r.id === fileId); if (record) { // Set the file as selected in context and switch to viewer for preview setSelectedFiles([fileId]); navActions.setWorkbench('viewer'); } - }, [activeFileRecords, setSelectedFiles, navActions.setWorkbench]); - - const handleMergeFromHere = useCallback((fileId: FileId) => { - const startIndex = activeFileRecords.findIndex(r => r.id === fileId); - if (startIndex === -1) return; - - const recordsToMerge = activeFileRecords.slice(startIndex); - const filesToMerge = recordsToMerge.map(r => selectors.getFile(r.id)).filter(Boolean) as File[]; - if (onMergeFiles) { - onMergeFiles(filesToMerge); - } - }, [activeFileRecords, selectors, onMergeFiles]); - - const handleSplitFile = useCallback((fileId: FileId) => { - const file = selectors.getFile(fileId); - if (file && onOpenPageEditor) { - onOpenPageEditor(file); - } - }, [selectors, onOpenPageEditor]); + }, [activeStirlingFileStubs, setSelectedFiles, navActions.setWorkbench]); const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { if (selectedFiles.length === 0) return; @@ -467,7 +332,7 @@ const FileEditor = ({ - {activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? ( + {activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? (
📁 @@ -475,7 +340,7 @@ const FileEditor = ({ Upload PDF files, ZIP archives, or load from storage to get started
- ) : activeFileRecords.length === 0 && zipExtractionProgress.isExtracting ? ( + ) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? ( @@ -522,16 +387,13 @@ const FileEditor = ({ pointerEvents: 'auto' }} > - {activeFileRecords.map((record, index) => { - const fileItem = recordToFileItem(record); - if (!fileItem) return null; - + {activeStirlingFileStubs.map((record, index) => { return ( ); })} diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index e82483898..2a927d012 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -8,22 +8,17 @@ import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { StirlingFileStub } from '../../types/fileContext'; import styles from './FileEditor.module.css'; import { useFileContext } from '../../contexts/FileContext'; import { FileId } from '../../types/file'; +import ToolChain from '../shared/ToolChain'; + -interface FileItem { - id: FileId; - name: string; - pageCount: number; - thumbnail: string | null; - size: number; - modifiedAt?: number | string | Date; -} interface FileEditorThumbnailProps { - file: FileItem; + file: StirlingFileStub; index: number; totalFiles: number; selectedFiles: FileId[]; @@ -44,7 +39,6 @@ const FileEditorThumbnail = ({ selectedFiles, onToggleFile, onDeleteFile, - onViewFile, onSetStatus, onReorderFiles, onDownloadFile, @@ -61,10 +55,12 @@ const FileEditorThumbnail = ({ // Resolve the actual File object for pin/unpin operations const actualFile = useMemo(() => { - return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); - }, [activeFiles, file.name, file.size]); + return activeFiles.find(f => f.fileId === file.id); + }, [activeFiles, file.id]); const isPinned = actualFile ? isFilePinned(actualFile) : false; + const pageCount = file.processedFile?.totalPages || 0; + const downloadSelectedFile = useCallback(() => { // Prefer parent-provided handler if available if (typeof onDownloadFile === 'function') { @@ -110,22 +106,21 @@ const FileEditorThumbnail = ({ const pageLabel = useMemo( () => - file.pageCount > 0 - ? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}` + pageCount > 0 + ? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}` : '', - [file.pageCount] + [pageCount] ); const dateLabel = useMemo(() => { - const d = - file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback + const d = new Date(file.lastModified); if (Number.isNaN(d.getTime())) return ''; return new Intl.DateTimeFormat(undefined, { month: 'short', day: '2-digit', year: 'numeric', }).format(d); - }, [file.modifiedAt]); + }, [file.lastModified]); // ---- Drag & drop wiring ---- const fileElementRef = useCallback((element: HTMLDivElement | null) => { @@ -351,7 +346,8 @@ const FileEditorThumbnail = ({ lineClamp={3} title={`${extUpper || 'FILE'} • ${prettySize}`} > - {/* e.g., Jan 29, 2025 - PDF file - 3 Pages */} + {/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */} + {`v${file.versionNumber} - `} {dateLabel} {extUpper ? ` - ${extUpper} file` : ''} {pageLabel ? ` - ${pageLabel}` : ''} @@ -361,9 +357,9 @@ const FileEditorThumbnail = ({ {/* Preview area */}
- {file.thumbnail && ( + {file.thumbnailUrl && ( {file.name} + + {/* Tool chain display at bottom */} + {file.toolHistory && ( +
+ +
+ )}
); diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/components/fileManager/CompactFileDetails.tsx index b1b5f0d24..fac16bb20 100644 --- a/frontend/src/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/components/fileManager/CompactFileDetails.tsx @@ -5,12 +5,12 @@ import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import { useTranslation } from 'react-i18next'; import { getFileSize } from '../../utils/fileUtils'; -import { FileMetadata } from '../../types/file'; +import { StirlingFileStub } from '../../types/fileContext'; interface CompactFileDetailsProps { - currentFile: FileMetadata | null; + currentFile: StirlingFileStub | null; thumbnail: string | null; - selectedFiles: FileMetadata[]; + selectedFiles: StirlingFileStub[]; currentFileIndex: number; numberOfFiles: number; isAnimating: boolean; @@ -72,12 +72,19 @@ const CompactFileDetails: React.FC = ({ {currentFile ? getFileSize(currentFile) : ''} {selectedFiles.length > 1 && ` • ${selectedFiles.length} files`} + {currentFile && ` • v${currentFile.versionNumber || 1}`} {hasMultipleFiles && ( {currentFileIndex + 1} of {selectedFiles.length} )} + {/* Compact tool chain for mobile */} + {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( + + {currentFile.toolHistory.map((tool: any) => tool.toolName).join(' → ')} + + )}
{/* Navigation arrows for multiple files */} diff --git a/frontend/src/components/fileManager/FileDetails.tsx b/frontend/src/components/fileManager/FileDetails.tsx index dcd460644..b1e7e93e3 100644 --- a/frontend/src/components/fileManager/FileDetails.tsx +++ b/frontend/src/components/fileManager/FileDetails.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState } from 'react'; import { Stack, Button, Box } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail'; @@ -11,27 +11,26 @@ interface FileDetailsProps { compact?: boolean; } -const FileDetails: React.FC = ({ +const FileDetails: React.FC = ({ compact = false }) => { const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext(); const { t } = useTranslation(); const [currentFileIndex, setCurrentFileIndex] = useState(0); const [isAnimating, setIsAnimating] = useState(false); - + // Get the currently displayed file const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null; const hasSelection = selectedFiles.length > 0; - const hasMultipleFiles = selectedFiles.length > 1; // Use IndexedDB hook for the current file const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile); - + // Get thumbnail for current file const getCurrentThumbnail = () => { return currentThumbnail; }; - + const handlePrevious = () => { if (isAnimating) return; setIsAnimating(true); @@ -40,7 +39,7 @@ const FileDetails: React.FC = ({ setIsAnimating(false); }, 150); }; - + const handleNext = () => { if (isAnimating) return; setIsAnimating(true); @@ -49,14 +48,14 @@ const FileDetails: React.FC = ({ setIsAnimating(false); }, 150); }; - + // Reset index when selection changes React.useEffect(() => { if (currentFileIndex >= selectedFiles.length) { setCurrentFileIndex(0); } }, [selectedFiles.length, currentFileIndex]); - + if (compact) { return ( = ({ onNext={handleNext} />
- + {/* Section 2: File Details */} - -
); diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index 1eda1f6c8..be926f85d 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; -import { Text, ActionIcon, CheckboxIndicator } from '@mantine/core'; +import { ActionIcon, CheckboxIndicator } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; @@ -44,7 +44,6 @@ const FileThumbnail = ({ selectedFiles, onToggleFile, onDeleteFile, - onViewFile, onSetStatus, onReorderFiles, onDownloadFile, @@ -61,8 +60,8 @@ const FileThumbnail = ({ // Resolve the actual File object for pin/unpin operations const actualFile = useMemo(() => { - return activeFiles.find((f: File) => f.name === file.name && f.size === file.size); - }, [activeFiles, file.name, file.size]); + return activeFiles.find(f => f.fileId === file.id); + }, [activeFiles, file.id]); const isPinned = actualFile ? isFilePinned(actualFile) : false; const downloadSelectedFile = useCallback(() => { @@ -93,40 +92,6 @@ const FileThumbnail = ({ // ---- Selection ---- const isSelected = selectedFiles.includes(file.id); - // ---- Meta formatting ---- - const prettySize = useMemo(() => { - const bytes = file.size ?? 0; - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; - }, [file.size]); - - const extUpper = useMemo(() => { - const m = /\.([a-z0-9]+)$/i.exec(file.name ?? ''); - return (m?.[1] || '').toUpperCase(); - }, [file.name]); - - const pageLabel = useMemo( - () => - file.pageCount > 0 - ? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}` - : '', - [file.pageCount] - ); - - const dateLabel = useMemo(() => { - const d = - file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback - if (Number.isNaN(d.getTime())) return ''; - return new Intl.DateTimeFormat(undefined, { - month: 'short', - day: '2-digit', - year: 'numeric', - }).format(d); - }, [file.modifiedAt]); - // ---- Drag & drop wiring ---- const fileElementRef = useCallback((element: HTMLDivElement | null) => { if (!element) return; diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 07823414a..2c85d8f81 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -1,13 +1,7 @@ -import React, { useState, useCallback, useRef, useEffect, useMemo } from "react"; -import { - Button, Text, Center, Box, - Notification, TextInput, LoadingOverlay, Modal, Alert, - Stack, Group, Portal -} from "@mantine/core"; -import { useTranslation } from "react-i18next"; -import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; -import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor"; -import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; +import { useState, useCallback, useRef, useEffect } from "react"; +import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; +import { useFileState, useFileActions } from "../../contexts/FileContext"; +import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor"; import { pdfExportService } from "../../services/pdfExportService"; import { documentManipulationService } from "../../services/documentManipulationService"; // Thumbnail generation is now handled by individual PageThumbnail components @@ -19,16 +13,11 @@ import NavigationWarningModal from '../shared/NavigationWarningModal'; import { FileId } from "../../types/file"; import { - DOMCommand, - RotatePageCommand, DeletePagesCommand, ReorderPagesCommand, SplitCommand, BulkRotateCommand, - BulkSplitCommand, - SplitAllCommand, PageBreakCommand, - BulkPageBreakCommand, UndoManager } from './commands/pageCommands'; import { GRID_CONSTANTS } from './constants'; @@ -49,35 +38,24 @@ const PageEditor = ({ // Prefer IDs + selectors to avoid array identity churn const activeFileIds = state.files.ids; - const primaryFileId = activeFileIds[0] ?? null; - const selectedFiles = selectors.getSelectedFiles(); - - // Stable signature for effects (prevents loops) - const filesSignature = selectors.getFilesSignature(); // UI state const globalProcessing = state.ui.isProcessing; - const processingProgress = state.ui.processingProgress; - const hasUnsavedChanges = state.ui.hasUnsavedChanges; // Edit state management const [editedDocument, setEditedDocument] = useState(null); - const [hasUnsavedDraft, setHasUnsavedDraft] = useState(false); - const [showResumeModal, setShowResumeModal] = useState(false); - const [foundDraft, setFoundDraft] = useState(null); - const autoSaveTimer = useRef(null); // DOM-first undo manager (replaces the old React state undo system) const undoManagerRef = useRef(new UndoManager()); // Document state management - const { document: mergedPdfDocument, isVeryLargeDocument, isLoading: documentLoading } = usePageDocument(); + const { document: mergedPdfDocument } = usePageDocument(); // UI state management const { selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading, - setSelectionMode, setSelectedPageIds, setMovingPage, setIsAnimating, setSplitPositions, setExportLoading, + setSelectionMode, setSelectedPageIds, setMovingPage, setSplitPositions, setExportLoading, togglePage, toggleSelectAll, animateReorder } = usePageEditorState(); @@ -146,12 +124,6 @@ const PageEditor = ({ }).filter(id => id !== ''); }, [displayDocument]); - // Convert selectedPageIds to numbers for components that still need numbers - const selectedPageNumbers = useMemo(() => - getPageNumbersFromIds(selectedPageIds), - [selectedPageIds, getPageNumbersFromIds] - ); - // Select all pages by default when document initially loads const hasInitializedSelection = useRef(false); useEffect(() => { diff --git a/frontend/src/components/pageEditor/PageEditorControls.tsx b/frontend/src/components/pageEditor/PageEditorControls.tsx index 2a932ae50..3e36b06b6 100644 --- a/frontend/src/components/pageEditor/PageEditorControls.tsx +++ b/frontend/src/components/pageEditor/PageEditorControls.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { Tooltip, ActionIcon, @@ -9,9 +8,7 @@ import ContentCutIcon from "@mui/icons-material/ContentCut"; import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateRightIcon from "@mui/icons-material/RotateRight"; import DeleteIcon from "@mui/icons-material/Delete"; -import CloseIcon from "@mui/icons-material/Close"; import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak"; -import DownloadIcon from "@mui/icons-material/Download"; interface PageEditorControlsProps { // Close/Reset functions @@ -46,7 +43,6 @@ interface PageEditorControlsProps { } const PageEditorControls = ({ - onClosePdf, onUndo, onRedo, canUndo, @@ -54,12 +50,7 @@ const PageEditorControls = ({ onRotate, onDelete, onSplit, - onSplitAll, onPageBreak, - onPageBreakAll, - onExportAll, - exportLoading, - selectionMode, selectedPageIds, displayDocument, splitPositions, diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 0b2782ba1..dfa098af1 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -52,16 +52,13 @@ const PageThumbnail: React.FC = ({ pageRefs, onReorderPages, onTogglePage, - onAnimateReorder, onExecuteCommand, onSetStatus, onSetMovingPage, onDeletePage, createRotateCommand, - createDeleteCommand, createSplitCommand, pdfDocument, - setPdfDocument, splitPositions, onInsertFiles, }: PageThumbnailProps) => { @@ -172,7 +169,7 @@ const PageThumbnail: React.FC = ({ type: 'page', pageNumber: page.pageNumber }), - onDrop: ({ source }) => {} + onDrop: (_) => {} }); (element as any).__dragCleanup = () => { diff --git a/frontend/src/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/components/pageEditor/hooks/usePageDocument.ts index b620c87b8..0b7aa00b4 100644 --- a/frontend/src/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -27,9 +27,9 @@ export function usePageDocument(): PageDocumentHook { const globalProcessing = state.ui.isProcessing; // Get primary file record outside useMemo to track processedFile changes - const primaryFileRecord = primaryFileId ? selectors.getFileRecord(primaryFileId) : null; - const processedFilePages = primaryFileRecord?.processedFile?.pages; - const processedFileTotalPages = primaryFileRecord?.processedFile?.totalPages; + const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null; + const processedFilePages = primaryStirlingFileStub?.processedFile?.pages; + const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages; // Compute merged document with stable signature (prevents infinite loops) const mergedPdfDocument = useMemo((): PDFDocument | null => { @@ -38,16 +38,16 @@ export function usePageDocument(): PageDocumentHook { const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null; // If we have file IDs but no file record, something is wrong - return null to show loading - if (!primaryFileRecord) { + if (!primaryStirlingFileStub) { console.log('🎬 PageEditor: No primary file record found, showing loading'); return null; } const name = activeFileIds.length === 1 - ? (primaryFileRecord.name ?? 'document.pdf') + ? (primaryStirlingFileStub.name ?? 'document.pdf') : activeFileIds - .map(id => (selectors.getFileRecord(id)?.name ?? 'file').replace(/\.pdf$/i, '')) + .map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, '')) .join(' + '); // Build page insertion map from files with insertion positions @@ -55,7 +55,7 @@ export function usePageDocument(): PageDocumentHook { const originalFileIds: FileId[] = []; activeFileIds.forEach(fileId => { - const record = selectors.getFileRecord(fileId); + const record = selectors.getStirlingFileStub(fileId); if (record?.insertAfterPageId !== undefined) { if (!insertionMap.has(record.insertAfterPageId)) { insertionMap.set(record.insertAfterPageId, []); @@ -68,16 +68,15 @@ export function usePageDocument(): PageDocumentHook { // Build pages by interleaving original pages with insertions let pages: PDFPage[] = []; - let totalPageCount = 0; // Helper function to create pages from a file const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => { - const fileRecord = selectors.getFileRecord(fileId); - if (!fileRecord) { + const stirlingFileStub = selectors.getStirlingFileStub(fileId); + if (!stirlingFileStub) { return []; } - const processedFile = fileRecord.processedFile; + const processedFile = stirlingFileStub.processedFile; let filePages: PDFPage[] = []; if (processedFile?.pages && processedFile.pages.length > 0) { @@ -144,8 +143,6 @@ export function usePageDocument(): PageDocumentHook { }); } - totalPageCount = pages.length; - if (pages.length === 0) { return null; } @@ -159,7 +156,7 @@ export function usePageDocument(): PageDocumentHook { }; return mergedDoc; - }, [activeFileIds, primaryFileId, primaryFileRecord, processedFilePages, processedFileTotalPages, selectors, filesSignature]); + }, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]); // Large document detection for smart loading const isVeryLargeDocument = useMemo(() => { diff --git a/frontend/src/components/shared/AllToolsNavButton.tsx b/frontend/src/components/shared/AllToolsNavButton.tsx index c1b5774d2..62fad704e 100644 --- a/frontend/src/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/components/shared/AllToolsNavButton.tsx @@ -4,6 +4,8 @@ import { useTranslation } from 'react-i18next'; import { Tooltip } from './Tooltip'; import AppsIcon from '@mui/icons-material/AppsRounded'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; +import { useSidebarNavigation } from '../../hooks/useSidebarNavigation'; +import { handleUnlessSpecialClick } from '../../utils/clickHandlers'; interface AllToolsNavButtonProps { activeButton: string; @@ -13,6 +15,7 @@ interface AllToolsNavButtonProps { const AllToolsNavButton: React.FC = ({ activeButton, setActiveButton }) => { const { t } = useTranslation(); const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); + const { getHomeNavigation } = useSidebarNavigation(); const handleClick = () => { setActiveButton('tools'); @@ -24,6 +27,12 @@ const AllToolsNavButton: React.FC = ({ activeButton, set // Do not highlight All Tools when a specific tool is open (indicator is shown) const isActive = activeButton === 'tools' && !selectedToolKey && leftPanelView === 'toolPicker'; + const navProps = getHomeNavigation(); + + const handleNavClick = (e: React.MouseEvent) => { + handleUnlessSpecialClick(e, handleClick); + }; + const iconNode = ( @@ -31,18 +40,21 @@ const AllToolsNavButton: React.FC = ({ activeButton, set ); return ( -
diff --git a/frontend/src/components/shared/ButtonSelector.test.tsx b/frontend/src/components/shared/ButtonSelector.test.tsx new file mode 100644 index 000000000..e3adf1def --- /dev/null +++ b/frontend/src/components/shared/ButtonSelector.test.tsx @@ -0,0 +1,216 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import ButtonSelector from './ButtonSelector'; + +// Wrapper component to provide Mantine context +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('ButtonSelector', () => { + const mockOnChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render all options as buttons', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + render( + + + + ); + + expect(screen.getByText('Test Label')).toBeInTheDocument(); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + }); + + test('should highlight selected button with filled variant', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + render( + + + + ); + + const selectedButton = screen.getByRole('button', { name: 'Option 1' }); + const unselectedButton = screen.getByRole('button', { name: 'Option 2' }); + + // Check data-variant attribute for filled/outline + expect(selectedButton).toHaveAttribute('data-variant', 'filled'); + expect(unselectedButton).toHaveAttribute('data-variant', 'outline'); + expect(screen.getByText('Selection Label')).toBeInTheDocument(); + }); + + test('should call onChange when button is clicked', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Option 2' })); + + expect(mockOnChange).toHaveBeenCalledWith('option2'); + }); + + test('should handle undefined value (no selection)', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + render( + + + + ); + + // Both buttons should be outlined when no value is selected + const button1 = screen.getByRole('button', { name: 'Option 1' }); + const button2 = screen.getByRole('button', { name: 'Option 2' }); + + expect(button1).toHaveAttribute('data-variant', 'outline'); + expect(button2).toHaveAttribute('data-variant', 'outline'); + }); + + test.each([ + { + description: 'disable buttons when disabled prop is true', + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ], + globalDisabled: true, + expectedStates: [true, true], + }, + { + description: 'disable individual options when option.disabled is true', + options: [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2', disabled: true }, + ], + globalDisabled: false, + expectedStates: [false, true], + }, + ])('should $description', ({ options, globalDisabled, expectedStates }) => { + render( + + + + ); + + options.forEach((option, index) => { + const button = screen.getByRole('button', { name: option.label }); + expect(button).toHaveProperty('disabled', expectedStates[index]); + }); + }); + + test('should not call onChange when disabled button is clicked', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2', disabled: true }, + ]; + + render( + + + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Option 2' })); + + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + test('should not apply fullWidth styling when fullWidth is false', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + render( + + + + ); + + const button = screen.getByRole('button', { name: 'Option 1' }); + expect(button).not.toHaveStyle({ flex: '1' }); + expect(screen.getByText('Layout Label')).toBeInTheDocument(); + }); + + test('should not render label element when not provided', () => { + const options = [ + { value: 'option1', label: 'Option 1' }, + { value: 'option2', label: 'Option 2' }, + ]; + + const { container } = render( + + + + ); + + // Should render buttons + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + + // Stack should only contain the Group (buttons), no Text element for label + const stackElement = container.querySelector('[class*="mantine-Stack-root"]'); + expect(stackElement?.children).toHaveLength(1); // Only the Group, no label Text + }); +}); diff --git a/frontend/src/components/shared/ButtonSelector.tsx b/frontend/src/components/shared/ButtonSelector.tsx new file mode 100644 index 000000000..bc95134d6 --- /dev/null +++ b/frontend/src/components/shared/ButtonSelector.tsx @@ -0,0 +1,59 @@ +import { Button, Group, Stack, Text } from "@mantine/core"; + +export interface ButtonOption { + value: T; + label: string; + disabled?: boolean; +} + +interface ButtonSelectorProps { + value: T | undefined; + onChange: (value: T) => void; + options: ButtonOption[]; + label?: string; + disabled?: boolean; + fullWidth?: boolean; +} + +const ButtonSelector = ({ + value, + onChange, + options, + label = undefined, + disabled = false, + fullWidth = true, +}: ButtonSelectorProps) => { + return ( + + {/* Label (if it exists) */} + {label && {label}} + + {/* Buttons */} + + {options.map((option) => ( + + ))} + + + ); +}; + +export default ButtonSelector; diff --git a/frontend/src/components/shared/CardSelector.tsx b/frontend/src/components/shared/CardSelector.tsx new file mode 100644 index 000000000..a8e4a2725 --- /dev/null +++ b/frontend/src/components/shared/CardSelector.tsx @@ -0,0 +1,99 @@ +import { Stack, Card, Text, Flex } from '@mantine/core'; +import { Tooltip } from './Tooltip'; +import { useTranslation } from 'react-i18next'; + +export interface CardOption { + value: T; + prefixKey: string; + nameKey: string; + tooltipKey?: string; + tooltipContent?: any[]; +} + +export interface CardSelectorProps> { + options: K[]; + onSelect: (value: T) => void; + disabled?: boolean; + getTooltipContent?: (option: K) => any[]; +} + +const CardSelector = >({ + options, + onSelect, + disabled = false, + getTooltipContent +}: CardSelectorProps) => { + const { t } = useTranslation(); + + const handleOptionClick = (value: T) => { + if (!disabled) { + onSelect(value); + } + }; + + const getTooltips = (option: K) => { + if (getTooltipContent) { + return getTooltipContent(option); + } + return []; + }; + + return ( + + {options.map((option) => ( + + { + if (!disabled) { + e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-3)'; + e.currentTarget.style.transform = 'translateY(-1px)'; + e.currentTarget.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)'; + } + }} + onMouseLeave={(e) => { + if (!disabled) { + e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-2)'; + e.currentTarget.style.transform = 'translateY(0px)'; + e.currentTarget.style.boxShadow = 'none'; + } + }} + onClick={() => handleOptionClick(option.value)} + > + + + {t(option.prefixKey, "Prefix")} + + + {t(option.nameKey, "Option Name")} + + + + + ))} + + ); +}; + +export default CardSelector; diff --git a/frontend/src/components/shared/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx index 73ae01dba..173cfa404 100644 --- a/frontend/src/components/shared/FileCard.tsx +++ b/frontend/src/components/shared/FileCard.tsx @@ -6,13 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage"; import VisibilityIcon from "@mui/icons-material/Visibility"; import EditIcon from "@mui/icons-material/Edit"; -import { FileRecord } from "../../types/fileContext"; +import { StirlingFileStub } from "../../types/fileContext"; import { getFileSize, getFileDate } from "../../utils/fileUtils"; import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail"; interface FileCardProps { file: File; - record?: FileRecord; + fileStub?: StirlingFileStub; onRemove: () => void; onDoubleClick?: () => void; onView?: () => void; @@ -22,12 +22,11 @@ interface FileCardProps { isSupported?: boolean; // Whether the file format is supported by the current tool } -const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => { +const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => { const { t } = useTranslation(); // Use record thumbnail if available, otherwise fall back to IndexedDB lookup - const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null; - const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata); - const thumb = record?.thumbnailUrl || indexedDBThumb; + const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileStub); + const thumb = fileStub?.thumbnailUrl || indexedDBThumb; const [isHovered, setIsHovered] = useState(false); return ( @@ -177,7 +176,7 @@ const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSel {getFileDate(file)} - {record?.id && ( + {fileStub?.id && ( ; + files: Array<{ file: File; record?: StirlingFileStub }>; onRemove?: (index: number) => void; - onDoubleClick?: (item: { file: File; record?: FileRecord }) => void; - onView?: (item: { file: File; record?: FileRecord }) => void; - onEdit?: (item: { file: File; record?: FileRecord }) => void; + onDoubleClick?: (item: { file: File; record?: StirlingFileStub }) => void; + onView?: (item: { file: File; record?: StirlingFileStub }) => void; + onEdit?: (item: { file: File; record?: StirlingFileStub }) => void; onSelect?: (fileId: FileId) => void; selectedFiles?: FileId[]; showSearch?: boolean; @@ -123,15 +123,23 @@ const FileGrid = ({ h="30rem" style={{ overflowY: "auto", width: "100%" }} > - {displayFiles.map((item, idx) => { - const fileId = item.record?.id || item.file.name as FileId /* FIX ME: This doesn't seem right */; - const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId); + {displayFiles + .filter(item => { + if (!item.record?.id) { + console.error('FileGrid: File missing StirlingFileStub with proper ID:', item.file.name); + return false; + } + return true; + }) + .map((item, idx) => { + const fileId = item.record!.id; // Safe to assert after filter + const originalIdx = files.findIndex(f => f.record?.id === fileId); const supported = isFileSupported ? isFileSupported(item.file.name) : true; return ( onRemove(originalIdx) : () => {}} onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined} onView={onView && supported ? () => onView(item) : undefined} diff --git a/frontend/src/components/shared/FilePreview.tsx b/frontend/src/components/shared/FilePreview.tsx index 06c55ee2d..eb2917029 100644 --- a/frontend/src/components/shared/FilePreview.tsx +++ b/frontend/src/components/shared/FilePreview.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Box, Center } from '@mantine/core'; import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'; -import { FileMetadata } from '../../types/file'; +import { StirlingFileStub } from '../../types/fileContext'; import DocumentThumbnail from './filePreview/DocumentThumbnail'; import DocumentStack from './filePreview/DocumentStack'; import HoverOverlay from './filePreview/HoverOverlay'; @@ -9,7 +9,7 @@ import NavigationArrows from './filePreview/NavigationArrows'; export interface FilePreviewProps { // Core file data - file: File | FileMetadata | null; + file: File | StirlingFileStub | null; thumbnail?: string | null; // Optional features @@ -22,7 +22,7 @@ export interface FilePreviewProps { isAnimating?: boolean; // Event handlers - onFileClick?: (file: File | FileMetadata | null) => void; + onFileClick?: (file: File | StirlingFileStub | null) => void; onPrevious?: () => void; onNext?: () => void; } diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 04fee9b35..a3ea42fab 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core'; +import { Container, Button, Group, useMantineColorScheme } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import LocalIcon from './LocalIcon'; import { useTranslation } from 'react-i18next'; @@ -7,7 +7,7 @@ import { useFileHandler } from '../../hooks/useFileHandler'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; const LandingPage = () => { - const { addMultipleFiles } = useFileHandler(); + const { addFiles } = useFileHandler(); const fileInputRef = React.useRef(null); const { colorScheme } = useMantineColorScheme(); const { t } = useTranslation(); @@ -15,7 +15,7 @@ const LandingPage = () => { const [isUploadHover, setIsUploadHover] = React.useState(false); const handleFileDrop = async (files: File[]) => { - await addMultipleFiles(files); + await addFiles(files); }; const handleOpenFilesModal = () => { @@ -29,7 +29,7 @@ const LandingPage = () => { const handleFileSelect = async (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); if (files.length > 0) { - await addMultipleFiles(files); + await addFiles(files); } // Reset the input so the same file can be selected again event.target.value = ''; diff --git a/frontend/src/components/shared/LanguageSelector.tsx b/frontend/src/components/shared/LanguageSelector.tsx index d3a346a8e..f2fddf212 100644 --- a/frontend/src/components/shared/LanguageSelector.tsx +++ b/frontend/src/components/shared/LanguageSelector.tsx @@ -15,7 +15,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal const { i18n } = useTranslation(); const [opened, setOpened] = useState(false); const [animationTriggered, setAnimationTriggered] = useState(false); - const [isChanging, setIsChanging] = useState(false); const [pendingLanguage, setPendingLanguage] = useState(null); const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null); @@ -36,7 +35,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal } // Start transition animation - setIsChanging(true); setPendingLanguage(value); // Simulate processing time for smooth transition @@ -44,7 +42,6 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal i18n.changeLanguage(value); setTimeout(() => { - setIsChanging(false); setPendingLanguage(null); setOpened(false); @@ -54,7 +51,7 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal }, 200); }; - const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] || + const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] || supportedLanguages['en-GB']; // Trigger animation when dropdown opens @@ -77,8 +74,8 @@ const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = fal } `} - = ({ icon, ...props }) => { // Convert our icon naming convention to the local collection format - const iconName = icon.startsWith('material-symbols:') - ? icon + const iconName = icon.startsWith('material-symbols:') + ? icon : `material-symbols:${icon}`; - + // Development logging (only in dev mode) if (process.env.NODE_ENV === 'development') { const logKey = `icon-${iconName}`; @@ -44,9 +44,9 @@ export const LocalIcon: React.FC = ({ icon, ...props }) => { sessionStorage.setItem(logKey, 'logged'); } } - + // Always render the icon - Iconify will use local if available, CDN if not return ; }; -export default LocalIcon; \ No newline at end of file +export default LocalIcon; diff --git a/frontend/src/components/shared/QuickAccessBar.tsx b/frontend/src/components/shared/QuickAccessBar.tsx index e65843fa4..2ebe45002 100644 --- a/frontend/src/components/shared/QuickAccessBar.tsx +++ b/frontend/src/components/shared/QuickAccessBar.tsx @@ -3,10 +3,11 @@ import { ActionIcon, Stack, Divider } from "@mantine/core"; import { useTranslation } from 'react-i18next'; import LocalIcon from './LocalIcon'; import { useRainbowThemeContext } from "./RainbowThemeProvider"; -import AppConfigModal from './AppConfigModal'; import { useIsOverflowing } from '../../hooks/useIsOverflowing'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; +import { useSidebarNavigation } from '../../hooks/useSidebarNavigation'; +import { handleUnlessSpecialClick } from '../../utils/clickHandlers'; import { ButtonConfig } from '../../types/sidebar'; import './quickAccessBar/QuickAccessBar.css'; import AllToolsNavButton from './AllToolsNavButton'; @@ -17,12 +18,12 @@ import { getActiveNavButton, } from './quickAccessBar/QuickAccessBar'; -const QuickAccessBar = forwardRef(({ -}, ref) => { +const QuickAccessBar = forwardRef((_, ref) => { const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const { openFilesModal, isFilesModalOpen } = useFilesModalContext(); const { handleReaderToggle, handleBackToTools, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); + const { getToolNavigation } = useSidebarNavigation(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); @@ -37,6 +38,52 @@ const QuickAccessBar = forwardRef(({ openFilesModal(); }; + // Helper function to render navigation buttons with URL support + const renderNavButton = (config: ButtonConfig, index: number) => { + const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); + + // Check if this button has URL navigation support + const navProps = config.type === 'navigation' && (config.id === 'read' || config.id === 'automate') + ? getToolNavigation(config.id) + : null; + + const handleClick = (e?: React.MouseEvent) => { + if (navProps && e) { + handleUnlessSpecialClick(e, config.onClick); + } else { + config.onClick(); + } + }; + + // Render navigation button with conditional URL support + return ( +
+ handleClick(e), + 'aria-label': config.name + } : { + onClick: () => handleClick() + })} + size={isActive ? (config.size || 'lg') : 'lg'} + variant="subtle" + style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)} + className={isActive ? 'activeIconScale' : ''} + data-testid={`${config.id}-button`} + > + + {config.icon} + + + + {config.name} + +
+ ); + }; + const buttonConfigs: ButtonConfig[] = [ { @@ -153,27 +200,7 @@ const QuickAccessBar = forwardRef(({ {buttonConfigs.slice(0, -1).map((config, index) => ( - -
- { - config.onClick(); - }} - style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)} - className={isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView) ? 'activeIconScale' : ''} - data-testid={`${config.id}-button`} - > - - {config.icon} - - - - {config.name} - -
- + {renderNavButton(config, index)} {/* Add divider after Automate button (index 1) and Files button (index 2) */} {index === 1 && ( diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index b141ec493..ee0f9b911 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -29,12 +29,11 @@ export default function RightRail() { // File state and selection const { state, selectors } = useFileState(); - const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection(); + const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection(); const { removeFiles } = useFileManagement(); const activeFiles = selectors.getFiles(); const filesSignature = selectors.getFilesSignature(); - const fileRecords = selectors.getFileRecords(); // Compute selection state and total items const getSelectionState = useCallback(() => { @@ -85,7 +84,7 @@ export default function RightRail() { if (currentView === 'fileEditor' || currentView === 'viewer') { // Download selected files (or all if none selected) const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles; - + filesToDownload.forEach(file => { const link = document.createElement('a'); link.href = URL.createObjectURL(file); @@ -206,8 +205,8 @@ export default function RightRail() { )} {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */} -
@@ -358,14 +357,14 @@ export default function RightRail() { 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All')) } position="left" offset={12} arrow>
- = ({ + toolChain, + maxWidth = '100%', + displayStyle = 'text', + size = 'xs', + color = 'var(--mantine-color-blue-7)' +}) => { + if (!toolChain || toolChain.length === 0) return null; + + const toolNames = toolChain.map(tool => tool.toolName); + + // Create full tool chain for tooltip + const fullChainDisplay = displayStyle === 'badges' ? ( + + {toolChain.map((tool, index) => ( + + + {tool.toolName} + + {index < toolChain.length - 1 && ( + + )} + + ))} + + ) : ( + {toolNames.join(' → ')} + ); + + // Create truncated display based on available space + const getTruncatedDisplay = () => { + if (toolNames.length <= 2) { + // Show all tools if 2 or fewer + return { text: toolNames.join(' → '), isTruncated: false }; + } else { + // Show first tool ... last tool for longer chains + return { + text: `${toolNames[0]} → +${toolNames.length-2} → ${toolNames[toolNames.length - 1]}`, + isTruncated: true + }; + } + }; + + const { text: truncatedText, isTruncated } = getTruncatedDisplay(); + + // Compact style for very small spaces + if (displayStyle === 'compact') { + const compactText = toolNames.length === 1 ? toolNames[0] : `${toolNames.length} tools`; + const isCompactTruncated = toolNames.length > 1; + + const compactElement = ( + + {compactText} + + ); + + return isCompactTruncated ? ( + + {compactElement} + + ) : compactElement; + } + + // Badge style for file details + if (displayStyle === 'badges') { + const isBadgesTruncated = toolChain.length > 3; + + const badgesElement = ( +
+ + {toolChain.slice(0, 3).map((tool, index) => ( + + + {tool.toolName} + + {index < Math.min(toolChain.length - 1, 2) && ( + + )} + + ))} + {toolChain.length > 3 && ( + <> + ... + + {toolChain[toolChain.length - 1].toolName} + + + )} + +
+ ); + + return isBadgesTruncated ? ( + + {badgesElement} + + ) : badgesElement; + } + + // Text style (default) for file list items + const textElement = ( + + {truncatedText} + + ); + + return isTruncated ? ( + + {textElement} + + ) : textElement; +}; + +export default ToolChain; diff --git a/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx index 56991e9d7..4f87e7f3e 100644 --- a/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/src/components/shared/filePreview/DocumentThumbnail.tsx @@ -1,10 +1,10 @@ import React from 'react'; import { Box, Center, Image } from '@mantine/core'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; -import { FileMetadata } from '../../../types/file'; +import { StirlingFileStub } from '../../../types/fileContext'; export interface DocumentThumbnailProps { - file: File | FileMetadata | null; + file: File | StirlingFileStub | null; thumbnail?: string | null; style?: React.CSSProperties; onClick?: () => void; diff --git a/frontend/src/components/shared/fitText/textFit.ts b/frontend/src/components/shared/fitText/textFit.ts index 37da2dc78..7a695ed77 100644 --- a/frontend/src/components/shared/fitText/textFit.ts +++ b/frontend/src/components/shared/fitText/textFit.ts @@ -82,8 +82,8 @@ export function adjustFontSizeToFit( return () => { cancelAnimationFrame(raf); - try { ro.disconnect(); } catch {} - try { mo.disconnect(); } catch {} + try { ro.disconnect(); } catch { /* Ignore errors */ } + try { mo.disconnect(); } catch { /* Ignore errors */ } }; } diff --git a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx index 8362c8224..0146d86e0 100644 --- a/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx +++ b/frontend/src/components/shared/quickAccessBar/ActiveToolButton.tsx @@ -1,10 +1,10 @@ /** * ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar - * + * * When a user selects a tool from the All Tools list, this component displays the tool's * icon and name at the top of the navigation bar. It provides a quick way to see which * tool is currently active and offers a back button to return to the All Tools list. - * + * * Features: * - Shows tool icon and name when a tool is selected * - Hover to reveal back arrow for returning to All Tools @@ -16,6 +16,8 @@ import React, { useEffect, useRef, useState } from 'react'; import { ActionIcon } from '@mantine/core'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; import { useToolWorkflow } from '../../../contexts/ToolWorkflowContext'; +import { useSidebarNavigation } from '../../../hooks/useSidebarNavigation'; +import { handleUnlessSpecialClick } from '../../../utils/clickHandlers'; import FitText from '../FitText'; import { Tooltip } from '../Tooltip'; @@ -26,8 +28,9 @@ interface ActiveToolButtonProps { const NAV_IDS = ['read', 'sign', 'automate']; -const ActiveToolButton: React.FC = ({ activeButton, setActiveButton }) => { +const ActiveToolButton: React.FC = ({ setActiveButton }) => { const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow(); + const { getHomeNavigation } = useSidebarNavigation(); // Determine if the indicator should be visible (do not require selectedTool to be resolved yet) const indicatorShouldShow = Boolean( @@ -38,7 +41,6 @@ const ActiveToolButton: React.FC = ({ activeButton, setAc const [indicatorTool, setIndicatorTool] = useState(null); const [indicatorVisible, setIndicatorVisible] = useState(false); const [replayAnim, setReplayAnim] = useState(false); - const [isAnimating, setIsAnimating] = useState(false); const [isBackHover, setIsBackHover] = useState(false); const prevKeyRef = useRef(null); const collapseTimeoutRef = useRef(null); @@ -71,11 +73,9 @@ const ActiveToolButton: React.FC = ({ activeButton, setAc replayRafRef.current = requestAnimationFrame(() => { setReplayAnim(true); }); - setIsAnimating(true); prevKeyRef.current = (selectedToolKey as string) || null; animTimeoutRef.current = window.setTimeout(() => { setReplayAnim(false); - setIsAnimating(false); animTimeoutRef.current = null; }, 500); } @@ -84,10 +84,8 @@ const ActiveToolButton: React.FC = ({ activeButton, setAc clearTimers(); setIndicatorTool(selectedTool); setIndicatorVisible(true); - setIsAnimating(true); prevKeyRef.current = (selectedToolKey as string) || null; animTimeoutRef.current = window.setTimeout(() => { - setIsAnimating(false); animTimeoutRef.current = null; }, 500); } @@ -95,11 +93,9 @@ const ActiveToolButton: React.FC = ({ activeButton, setAc const triggerCollapse = () => { clearTimers(); setIndicatorVisible(false); - setIsAnimating(true); collapseTimeoutRef.current = window.setTimeout(() => { setIndicatorTool(null); prevKeyRef.current = null; - setIsAnimating(false); collapseTimeoutRef.current = null; }, 500); // match CSS transition duration } @@ -142,21 +138,26 @@ const ActiveToolButton: React.FC = ({ activeButton, setAc
{ + handleUnlessSpecialClick(e, () => { + setActiveButton('tools'); + handleBackToTools(); + }); + }} size={'xl'} variant="subtle" onMouseEnter={() => setIsBackHover(true)} onMouseLeave={() => setIsBackHover(false)} - onClick={() => { - setActiveButton('tools'); - handleBackToTools(); - }} aria-label={isBackHover ? 'Back to all tools' : indicatorTool.name} style={{ backgroundColor: isBackHover ? 'var(--color-gray-300)' : 'var(--icon-tools-bg)', color: isBackHover ? '#fff' : 'var(--icon-tools-color)', border: 'none', borderRadius: '8px', - cursor: 'pointer' + cursor: 'pointer', + textDecoration: 'none' }} > diff --git a/frontend/src/components/tools/SearchResults.tsx b/frontend/src/components/tools/SearchResults.tsx index ee4a8fe5c..dc9fd6af0 100644 --- a/frontend/src/components/tools/SearchResults.tsx +++ b/frontend/src/components/tools/SearchResults.tsx @@ -1,5 +1,5 @@ -import React, { useMemo } from 'react'; -import { Box, Stack, Text } from '@mantine/core'; +import React from 'react'; +import { Box, Stack } from '@mantine/core'; import { getSubcategoryLabel, ToolRegistryEntry } from '../../data/toolsTaxonomy'; import ToolButton from './toolPicker/ToolButton'; import { useTranslation } from 'react-i18next'; @@ -40,12 +40,10 @@ const SearchResults: React.FC = ({ filteredTools, onSelect } ))} - {/* global spacer to allow scrolling past last row in search mode */} + {/* Global spacer to allow scrolling past last row in search mode */}
); }; export default SearchResults; - - diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx index de95f0b94..98d1d96f3 100644 --- a/frontend/src/components/tools/ToolPanel.tsx +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -1,5 +1,3 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import ToolPicker from './ToolPicker'; @@ -8,12 +6,11 @@ import ToolRenderer from './ToolRenderer'; import ToolSearch from './toolPicker/ToolSearch'; import { useSidebarContext } from "../../contexts/SidebarContext"; import rainbowStyles from '../../styles/rainbow.module.css'; -import { Stack, ScrollArea } from '@mantine/core'; +import { ScrollArea } from '@mantine/core'; // No props needed - component uses context export default function ToolPanel() { - const { t } = useTranslation(); const { isRainbowMode } = useRainbowThemeContext(); const { sidebarRefs } = useSidebarContext(); const { toolPanelRef } = sidebarRefs; @@ -27,7 +24,6 @@ export default function ToolPanel() { filteredTools, toolRegistry, setSearchQuery, - handleBackToTools } = useToolWorkflow(); const { selectedToolKey, handleToolSelect } = useToolWorkflow(); diff --git a/frontend/src/components/tools/addPassword/AddPasswordSettings.test.tsx b/frontend/src/components/tools/addPassword/AddPasswordSettings.test.tsx index 92373f60d..b483eca74 100644 --- a/frontend/src/components/tools/addPassword/AddPasswordSettings.test.tsx +++ b/frontend/src/components/tools/addPassword/AddPasswordSettings.test.tsx @@ -3,7 +3,6 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { MantineProvider } from '@mantine/core'; import AddPasswordSettings from './AddPasswordSettings'; import { defaultParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters'; -import type { AddPasswordParameters } from '../../../hooks/tools/addPassword/useAddPasswordParameters'; // Mock useTranslation with predictable return values const mockT = vi.fn((key: string) => `mock-${key}`); diff --git a/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx b/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx index 96ce6aad5..beb8c432c 100644 --- a/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx +++ b/frontend/src/components/tools/addPassword/AddPasswordSettings.tsx @@ -1,11 +1,10 @@ -import React from "react"; -import { Stack, Text, PasswordInput, Select } from "@mantine/core"; +import { Stack, PasswordInput, Select } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { AddPasswordParameters } from "../../../hooks/tools/addPassword/useAddPasswordParameters"; interface AddPasswordSettingsProps { parameters: AddPasswordParameters; - onParameterChange: (key: keyof AddPasswordParameters, value: any) => void; + onParameterChange: (key: K, value: AddPasswordParameters[K]) => void; disabled?: boolean; } diff --git a/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx b/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx index 84bbb296a..2c7548df8 100644 --- a/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx +++ b/frontend/src/components/tools/addWatermark/WatermarkTypeSettings.tsx @@ -1,6 +1,5 @@ -import React from "react"; -import { Button, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; +import ButtonSelector from "../../shared/ButtonSelector"; interface WatermarkTypeSettingsProps { watermarkType?: 'text' | 'image'; @@ -12,32 +11,21 @@ const WatermarkTypeSettings = ({ watermarkType, onWatermarkTypeChange, disabled const { t } = useTranslation(); return ( - -
- - -
-
+ ); }; diff --git a/frontend/src/components/tools/addWatermark/WatermarkWording.tsx b/frontend/src/components/tools/addWatermark/WatermarkWording.tsx index 5278ca332..cee909fca 100644 --- a/frontend/src/components/tools/addWatermark/WatermarkWording.tsx +++ b/frontend/src/components/tools/addWatermark/WatermarkWording.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Stack, Text, TextInput } from "@mantine/core"; +import { Stack, TextInput } from "@mantine/core"; import { useTranslation } from "react-i18next"; import { AddWatermarkParameters } from "../../../hooks/tools/addWatermark/useAddWatermarkParameters"; import { removeEmojis } from "../../../utils/textUtils"; diff --git a/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.test.tsx b/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.test.tsx new file mode 100644 index 000000000..d71655cdc --- /dev/null +++ b/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.test.tsx @@ -0,0 +1,64 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import AdjustPageScaleSettings from './AdjustPageScaleSettings'; +import { AdjustPageScaleParameters, PageSize } from '../../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters'; + +// Mock useTranslation with predictable return values +const mockT = vi.fn((key: string, fallback?: string) => fallback || `mock-${key}`); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockT }) +})); + +// Wrapper component to provide Mantine context +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('AdjustPageScaleSettings', () => { + const defaultParameters: AdjustPageScaleParameters = { + scaleFactor: 1.0, + pageSize: PageSize.KEEP, + }; + + const mockOnParameterChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render without crashing', () => { + render( + + + + ); + + // Basic render test - component renders without throwing + expect(screen.getByText('Scale Factor')).toBeInTheDocument(); + expect(screen.getByText('Target Page Size')).toBeInTheDocument(); + }); + + test('should render with custom parameters', () => { + const customParameters: AdjustPageScaleParameters = { + scaleFactor: 2.5, + pageSize: PageSize.A4, + }; + + render( + + + + ); + + // Component renders successfully with custom parameters + expect(screen.getByText('Scale Factor')).toBeInTheDocument(); + expect(screen.getByText('Target Page Size')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx b/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx new file mode 100644 index 000000000..9262bcba4 --- /dev/null +++ b/frontend/src/components/tools/adjustPageScale/AdjustPageScaleSettings.tsx @@ -0,0 +1,55 @@ +import { Stack, NumberInput, Select } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { AdjustPageScaleParameters, PageSize } from "../../../hooks/tools/adjustPageScale/useAdjustPageScaleParameters"; + +interface AdjustPageScaleSettingsProps { + parameters: AdjustPageScaleParameters; + onParameterChange: (key: K, value: AdjustPageScaleParameters[K]) => void; + disabled?: boolean; +} + +const AdjustPageScaleSettings = ({ parameters, onParameterChange, disabled = false }: AdjustPageScaleSettingsProps) => { + const { t } = useTranslation(); + + const pageSizeOptions = [ + { value: PageSize.KEEP, label: t('adjustPageScale.pageSize.keep', 'Keep Original Size') }, + { value: PageSize.A0, label: 'A0' }, + { value: PageSize.A1, label: 'A1' }, + { value: PageSize.A2, label: 'A2' }, + { value: PageSize.A3, label: 'A3' }, + { value: PageSize.A4, label: 'A4' }, + { value: PageSize.A5, label: 'A5' }, + { value: PageSize.A6, label: 'A6' }, + { value: PageSize.LETTER, label: t('adjustPageScale.pageSize.letter', 'Letter') }, + { value: PageSize.LEGAL, label: t('adjustPageScale.pageSize.legal', 'Legal') }, + ]; + + return ( + + onParameterChange('scaleFactor', typeof value === 'number' ? value : 1.0)} + min={0.1} + max={10.0} + step={0.1} + decimalScale={2} + disabled={disabled} + /> + + onParameterChange('pdfaOptions', { - ...parameters.pdfaOptions, - outputFormat: value || 'pdfa-1' + onChange={(value) => onParameterChange('pdfaOptions', { + ...parameters.pdfaOptions, + outputFormat: value || 'pdfa-1' })} data={pdfaFormatOptions} disabled={disabled || isChecking} @@ -57,4 +58,4 @@ const ConvertToPdfaSettings = ({ ); }; -export default ConvertToPdfaSettings; \ No newline at end of file +export default ConvertToPdfaSettings; diff --git a/frontend/src/components/tools/flatten/FlattenSettings.tsx b/frontend/src/components/tools/flatten/FlattenSettings.tsx new file mode 100644 index 000000000..8386ad493 --- /dev/null +++ b/frontend/src/components/tools/flatten/FlattenSettings.tsx @@ -0,0 +1,35 @@ +import { Stack, Text, Checkbox } from "@mantine/core"; +import { useTranslation } from "react-i18next"; +import { FlattenParameters } from "../../../hooks/tools/flatten/useFlattenParameters"; + +interface FlattenSettingsProps { + parameters: FlattenParameters; + onParameterChange: (key: K, value: FlattenParameters[K]) => void; + disabled?: boolean; +} + +const FlattenSettings = ({ parameters, onParameterChange, disabled = false }: FlattenSettingsProps) => { + const { t } = useTranslation(); + + return ( + + + onParameterChange('flattenOnlyForms', event.currentTarget.checked)} + disabled={disabled} + label={ +
+ {t('flatten.options.flattenOnlyForms', 'Flatten only forms')} + + {t('flatten.options.flattenOnlyForms.desc', 'Only flatten form fields, leaving other interactive elements intact')} + +
+ } + /> +
+
+ ); +}; + +export default FlattenSettings; \ No newline at end of file diff --git a/frontend/src/components/tools/merge/MergeFileSorter.test.tsx b/frontend/src/components/tools/merge/MergeFileSorter.test.tsx new file mode 100644 index 000000000..302777261 --- /dev/null +++ b/frontend/src/components/tools/merge/MergeFileSorter.test.tsx @@ -0,0 +1,182 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import MergeFileSorter from './MergeFileSorter'; + +// Mock useTranslation with predictable return values +const mockT = vi.fn((key: string) => `mock-${key}`); +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: mockT }) +})); + +// Wrapper component to provide Mantine context +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('MergeFileSorter', () => { + const mockOnSortFiles = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should render sort options dropdown, direction toggle, and sort button', () => { + render( + + + + ); + + // Should have a select dropdown (Mantine Select uses textbox role) + expect(screen.getByRole('textbox')).toBeInTheDocument(); + + // Should have direction toggle button + const buttons = screen.getAllByRole('button'); + expect(buttons).toHaveLength(2); // ActionIcon + Sort Button + + // Should have sort button with text + expect(screen.getByText('mock-merge.sortBy.sort')).toBeInTheDocument(); + }); + + test('should render description text', () => { + render( + + + + ); + + expect(screen.getByText('mock-merge.sortBy.description')).toBeInTheDocument(); + }); + + test('should have filename selected by default', () => { + render( + + + + ); + + const select = screen.getByRole('textbox'); + expect(select).toHaveValue('mock-merge.sortBy.filename'); + }); + + test('should show ascending direction by default', () => { + render( + + + + ); + + // Should show ascending arrow icon + const directionButton = screen.getAllByRole('button')[0]; + expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending'); + }); + + test('should toggle direction when direction button is clicked', () => { + render( + + + + ); + + const directionButton = screen.getAllByRole('button')[0]; + + // Initially ascending + expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending'); + + // Click to toggle to descending + fireEvent.click(directionButton); + expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.descending'); + + // Click again to toggle back to ascending + fireEvent.click(directionButton); + expect(directionButton).toHaveAttribute('title', 'mock-merge.sortBy.ascending'); + }); + + test('should call onSortFiles with correct parameters when sort button is clicked', () => { + render( + + + + ); + + const sortButton = screen.getByText('mock-merge.sortBy.sort'); + fireEvent.click(sortButton); + + // Should be called with default values (filename, ascending) + expect(mockOnSortFiles).toHaveBeenCalledWith('filename', true); + }); + + test('should call onSortFiles with dateModified when dropdown is changed', () => { + render( + + + + ); + + // Open the dropdown by clicking on the current selected value + const currentSelection = screen.getByText('mock-merge.sortBy.filename'); + fireEvent.mouseDown(currentSelection); + + // Click on the dateModified option + const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified'); + fireEvent.click(dateModifiedOption); + + const sortButton = screen.getByText('mock-merge.sortBy.sort'); + fireEvent.click(sortButton); + + expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true); + }); + + test('should call onSortFiles with descending direction when toggled', () => { + render( + + + + ); + + const directionButton = screen.getAllByRole('button')[0]; + const sortButton = screen.getByText('mock-merge.sortBy.sort'); + + // Toggle to descending + fireEvent.click(directionButton); + + // Click sort + fireEvent.click(sortButton); + + expect(mockOnSortFiles).toHaveBeenCalledWith('filename', false); + }); + + test('should handle complex user interaction sequence', () => { + render( + + + + ); + + const directionButton = screen.getAllByRole('button')[0]; + const sortButton = screen.getByText('mock-merge.sortBy.sort'); + + // 1. Change to dateModified + const currentSelection = screen.getByText('mock-merge.sortBy.filename'); + fireEvent.mouseDown(currentSelection); + const dateModifiedOption = screen.getByText('mock-merge.sortBy.dateModified'); + fireEvent.click(dateModifiedOption); + + // 2. Toggle to descending + fireEvent.click(directionButton); + + // 3. Click sort + fireEvent.click(sortButton); + + expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', false); + + // 4. Toggle back to ascending + fireEvent.click(directionButton); + + // 5. Sort again + fireEvent.click(sortButton); + + expect(mockOnSortFiles).toHaveBeenCalledWith('dateModified', true); + }); +}); diff --git a/frontend/src/components/tools/merge/MergeFileSorter.tsx b/frontend/src/components/tools/merge/MergeFileSorter.tsx new file mode 100644 index 000000000..2b21afc64 --- /dev/null +++ b/frontend/src/components/tools/merge/MergeFileSorter.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react'; +import { Group, Button, Text, ActionIcon, Stack, Select } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import SortIcon from '@mui/icons-material/Sort'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; + +interface MergeFileSorterProps { + onSortFiles: (sortType: 'filename' | 'dateModified', ascending: boolean) => void; + disabled?: boolean; +} + +const MergeFileSorter: React.FC = ({ + onSortFiles, + disabled = false, +}) => { + const { t } = useTranslation(); + const [sortType, setSortType] = useState<'filename' | 'dateModified'>('filename'); + const [ascending, setAscending] = useState(true); + + const sortOptions = [ + { value: 'filename', label: t('merge.sortBy.filename', 'File Name') }, + { value: 'dateModified', label: t('merge.sortBy.dateModified', 'Date Modified') }, + ]; + + const handleSort = () => { + onSortFiles(sortType, ascending); + }; + + const handleDirectionToggle = () => { + setAscending(!ascending); + }; + + return ( + + + {t('merge.sortBy.description', "Files will be merged in the order they're selected. Drag to reorder or sort below.")} + + + + v && onParameterChange('splitType', v)} - disabled={disabled} - data={[ - { value: SPLIT_TYPES.SIZE, label: t("split-by-size-or-count.type.size", "By Size") }, - { value: SPLIT_TYPES.PAGES, label: t("split-by-size-or-count.type.pageCount", "By Page Count") }, - { value: SPLIT_TYPES.DOCS, label: t("split-by-size-or-count.type.docCount", "By Document Count") }, - ]} - /> + const renderSplitValueForm = () => { + let label, placeholder; + + switch (parameters.method) { + case SPLIT_METHODS.BY_SIZE: + label = t("split.value.fileSize.label", "File Size"); + placeholder = t("split.value.fileSize.placeholder", "e.g. 10MB, 500KB"); + break; + case SPLIT_METHODS.BY_PAGE_COUNT: + label = t("split.value.pageCount.label", "Pages per File"); + placeholder = t("split.value.pageCount.placeholder", "e.g. 5, 10"); + break; + case SPLIT_METHODS.BY_DOC_COUNT: + label = t("split.value.docCount.label", "Number of Files"); + placeholder = t("split.value.docCount.placeholder", "e.g. 3, 5"); + break; + default: + label = t("split-by-size-or-count.value.label", "Split Value"); + placeholder = t("split-by-size-or-count.value.placeholder", "e.g. 10MB or 5 pages"); + } + + return ( onParameterChange('splitValue', e.target.value)} disabled={disabled} /> - - ); + ); + }; const renderByChaptersForm = () => ( @@ -104,28 +114,48 @@ const SplitSettings = ({ ); + const renderByPageDividerForm = () => ( + + + + {t("autoSplitPDF.dividerDownload2", "Download 'Auto Splitter Divider (with instructions).pdf'")} + + + onParameterChange('duplexMode', e.currentTarget.checked)} + disabled={disabled} + /> + + ); + + // Don't render anything if no method is selected + if (!parameters.method) { + return ( + + + {t("split.settings.selectMethodFirst", "Please select a split method first")} + + + ); + } + return ( - {/* Mode Selector */} -