diff --git a/frontend/.prettierignore b/frontend/.prettierignore index ad5dab21a..74f37221b 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -4,6 +4,7 @@ public/vendor/ public/pdfjs*/ public/js/thirdParty/ public/css/cookieconsent.css +src-tauri/target/ *.min.* *.md *.wxs diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 000000000..58bc87563 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,6 @@ +{ + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "endOfLine": "lf" +} diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index de728c277..11fbbef99 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -10,7 +10,10 @@ const nodeGlobs = ["scripts/**/*.{js,ts,mjs}", "*.config.{js,ts,mjs}"]; const baseRestrictedImportPatterns = [ { regex: "^\\.", message: "Use @app/* imports instead of relative imports." }, - { regex: "^src/", message: "Use @app/* imports instead of absolute src/ imports." }, + { + regex: "^src/", + message: "Use @app/* imports instead of absolute src/ imports.", + }, ]; export default defineConfig( @@ -64,7 +67,8 @@ export default defineConfig( ...baseRestrictedImportPatterns, { regex: "^@tauri-apps/", - message: "Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.", + message: + "Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.", }, ], }, diff --git a/frontend/index.html b/frontend/index.html index 465841285..98b620b40 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -6,7 +6,10 @@ - + diff --git a/frontend/scripts/build-provisioner.mjs b/frontend/scripts/build-provisioner.mjs index 511e383a0..8cd5c5cfc 100644 --- a/frontend/scripts/build-provisioner.mjs +++ b/frontend/scripts/build-provisioner.mjs @@ -10,9 +10,19 @@ const frontendDir = process.cwd(); const tauriDir = resolve(frontendDir, "src-tauri"); const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml"); -execFileSync("cargo", ["build", "--release", "--manifest-path", provisionerManifest], { stdio: "inherit" }); +execFileSync( + "cargo", + ["build", "--release", "--manifest-path", provisionerManifest], + { stdio: "inherit" }, +); -const provisionerExe = join(tauriDir, "provisioner", "target", "release", "stirling-provisioner.exe"); +const provisionerExe = join( + tauriDir, + "provisioner", + "target", + "release", + "stirling-provisioner.exe", +); if (!existsSync(provisionerExe)) { throw new Error(`Provisioner binary not found at ${provisionerExe}`); } @@ -26,9 +36,19 @@ copyFileSync(provisionerExe, destExe); // --- Thumbnail handler DLL --- const thumbManifest = join(tauriDir, "thumbnail-handler", "Cargo.toml"); -execFileSync("cargo", ["build", "--release", "--manifest-path", thumbManifest], { stdio: "inherit" }); +execFileSync( + "cargo", + ["build", "--release", "--manifest-path", thumbManifest], + { stdio: "inherit" }, +); -const thumbDll = join(tauriDir, "thumbnail-handler", "target", "release", "stirling_thumbnail_handler.dll"); +const thumbDll = join( + tauriDir, + "thumbnail-handler", + "target", + "release", + "stirling_thumbnail_handler.dll", +); if (!existsSync(thumbDll)) { throw new Error(`Thumbnail handler DLL not found at ${thumbDll}`); } diff --git a/frontend/scripts/generate-icons.js b/frontend/scripts/generate-icons.js index d15c53393..fb9ecd71e 100644 --- a/frontend/scripts/generate-icons.js +++ b/frontend/scripts/generate-icons.js @@ -5,7 +5,8 @@ const fs = require("fs"); const path = require("path"); // Check for verbose flag -const isVerbose = process.argv.includes("--verbose") || process.argv.includes("-v"); +const isVerbose = + process.argv.includes("--verbose") || process.argv.includes("-v"); // Logging functions const info = (message) => console.log(message); @@ -41,62 +42,82 @@ function scanForUsedIcons() { const content = fs.readFileSync(filePath, "utf8"); // Match LocalIcon usage: - const localIconMatches = content.match(/]*icon="([^"]+)"/g); + const localIconMatches = content.match( + /]*icon="([^"]+)"/g, + ); if (localIconMatches) { localIconMatches.forEach((match) => { const iconMatch = match.match(/icon="([^"]+)"/); if (iconMatch) { usedIcons.add(iconMatch[1]); - debug(` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`); + debug( + ` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`, + ); } }); } // Match LocalIcon usage: - const localIconSingleQuoteMatches = content.match(/]*icon='([^']+)'/g); + const localIconSingleQuoteMatches = content.match( + /]*icon='([^']+)'/g, + ); if (localIconSingleQuoteMatches) { localIconSingleQuoteMatches.forEach((match) => { const iconMatch = match.match(/icon='([^']+)'/); if (iconMatch) { usedIcons.add(iconMatch[1]); - debug(` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`); + debug( + ` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`, + ); } }); } // Match old material-symbols-rounded spans: icon-name - const spanMatches = content.match(/]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g); + const spanMatches = content.match( + /]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g, + ); if (spanMatches) { spanMatches.forEach((match) => { const iconMatch = match.match(/>([^<]+)<\/span>/); if (iconMatch && iconMatch[1].trim()) { const iconName = iconMatch[1].trim(); usedIcons.add(iconName); - debug(` Found (legacy): ${iconName} in ${path.relative(srcDir, filePath)}`); + debug( + ` Found (legacy): ${iconName} in ${path.relative(srcDir, filePath)}`, + ); } }); } // Match Icon component usage: - const iconMatches = content.match(/]*icon="material-symbols:([^"]+)"/g); + const iconMatches = content.match( + /]*icon="material-symbols:([^"]+)"/g, + ); if (iconMatches) { iconMatches.forEach((match) => { const iconMatch = match.match(/icon="material-symbols:([^"]+)"/); if (iconMatch) { usedIcons.add(iconMatch[1]); - debug(` Found (Icon): ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`); + debug( + ` Found (Icon): ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`, + ); } }); } // Match icon config usage: icon: 'icon-name' or icon: "icon-name" - const iconPropertyMatches = content.match(/icon:\s*(['"])([a-z0-9-]+)\1/g); + const iconPropertyMatches = content.match( + /icon:\s*(['"])([a-z0-9-]+)\1/g, + ); if (iconPropertyMatches) { iconPropertyMatches.forEach((match) => { const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/); if (iconMatch) { usedIcons.add(iconMatch[2]); - debug(` Found (config): ${iconMatch[2]} in ${path.relative(srcDir, filePath)}`); + debug( + ` Found (config): ${iconMatch[2]} in ${path.relative(srcDir, filePath)}`, + ); } }); } @@ -118,7 +139,13 @@ async function main() { const usedIcons = scanForUsedIcons(); // Check if we need to regenerate (compare with existing) - const outputPath = path.join(__dirname, "..", "src", "assets", "material-symbols-icons.json"); + const outputPath = path.join( + __dirname, + "..", + "src", + "assets", + "material-symbols-icons.json", + ); let needsRegeneration = true; if (fs.existsSync(outputPath)) { @@ -159,11 +186,17 @@ async function main() { // Check for missing icons const extractedIconNames = Object.keys(extractedIcons.icons || {}); - const missingIcons = usedIcons.filter((icon) => !extractedIconNames.includes(icon)); + const missingIcons = usedIcons.filter( + (icon) => !extractedIconNames.includes(icon), + ); if (missingIcons.length > 0) { - info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`); - info("💡 These icons don't exist in Material Symbols. Please use available alternatives."); + info( + `⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`, + ); + info( + "💡 These icons don't exist in Material Symbols. Please use available alternatives.", + ); } // Create output directory @@ -175,8 +208,12 @@ async function main() { // Write the extracted icon set to a file (outputPath already defined above) fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2)); - info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`); - info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`); + info( + `✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`, + ); + info( + `📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`, + ); info(`💾 Saved to: ${outputPath}`); // Generate TypeScript types diff --git a/frontend/scripts/generate-licenses.js b/frontend/scripts/generate-licenses.js index 339e20832..bfeb84855 100644 --- a/frontend/scripts/generate-licenses.js +++ b/frontend/scripts/generate-licenses.js @@ -1,7 +1,12 @@ #!/usr/bin/env node const { execSync } = require("node:child_process"); -const { existsSync, mkdirSync, writeFileSync, readFileSync } = require("node:fs"); +const { + existsSync, + mkdirSync, + writeFileSync, + readFileSync, +} = require("node:fs"); const path = require("node:path"); const { argv } = require("node:process"); @@ -16,7 +21,13 @@ const POSTPROCESS_ONLY = !!INPUT_FILE; * This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json */ -const OUTPUT_FILE = path.join(__dirname, "..", "src", "assets", "3rdPartyLicenses.json"); +const OUTPUT_FILE = path.join( + __dirname, + "..", + "src", + "assets", + "3rdPartyLicenses.json", +); const PACKAGE_JSON = path.join(__dirname, "..", "package.json"); // Ensure the output directory exists @@ -30,7 +41,9 @@ console.log("🔍 Generating frontend license report..."); try { // Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK) if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) { - console.error("Fork PR detected: only --input (postprocess-only) mode is allowed."); + console.error( + "Fork PR detected: only --input (postprocess-only) mode is allowed.", + ); process.exit(2); } @@ -90,13 +103,21 @@ try { licenseType = "Unknown"; } - if ("posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) { - licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE"; + if ( + "posthog-js" === dep.name && + licenseType.startsWith("SEE LICENSE IN LICENSE") + ) { + licenseType = + "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE"; } return { name: dep.name, - version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || "unknown", + version: + dep.installedVersion || + dep.definedVersion || + dep.remoteVersion || + "unknown", licenseType: licenseType, repository: dep.link, url: dep.link, @@ -107,12 +128,17 @@ try { // Transform to match Java backend format const transformedData = { dependencies: licenseArray.map((dep) => { - const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown"; + const licenseType = Array.isArray(dep.licenseType) + ? dep.licenseType.join(", ") + : dep.licenseType || "Unknown"; const licenseUrl = dep.link || getLicenseUrl(licenseType); return { moduleName: dep.name, - moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`, + moduleUrl: + dep.repository || + dep.url || + `https://www.npmjs.com/package/${dep.name}`, moduleVersion: dep.version, moduleLicense: licenseType, moduleLicenseUrl: licenseUrl, @@ -122,7 +148,9 @@ try { // Log summary of license types found const licenseSummary = licenseArray.reduce((acc, dep) => { - const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown"; + const license = Array.isArray(dep.licenseType) + ? dep.licenseType.join(", ") + : dep.licenseType || "Unknown"; acc[license] = (acc[license] || 0) + 1; return acc; }, {}); @@ -150,7 +178,10 @@ try { } // Check for potentially problematic licenses - const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray); + const problematicLicenses = checkLicenseCompatibility( + licenseSummary, + licenseArray, + ); if (problematicLicenses.length > 0) { console.log("\n⚠️ License compatibility warnings:"); problematicLicenses.forEach((warning) => { @@ -158,7 +189,13 @@ try { }); // Write license warnings to a separate file for CI/CD - const warningsFile = path.join(__dirname, "..", "src", "assets", "license-warnings.json"); + const warningsFile = path.join( + __dirname, + "..", + "src", + "assets", + "license-warnings.json", + ); writeFileSync( warningsFile, JSON.stringify( @@ -257,15 +294,21 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) { // Copyleft licenses "GPL-2.0": "Strong copyleft license - requires derivative works to be GPL", "GPL-3.0": "Strong copyleft license - requires derivative works to be GPL", - "LGPL-2.1": "Weak copyleft license - may require source disclosure for modifications", - "LGPL-3.0": "Weak copyleft license - may require source disclosure for modifications", - "AGPL-3.0": "Network copyleft license - requires source disclosure for network use", - "AGPL-1.0": "Network copyleft license - requires source disclosure for network use", + "LGPL-2.1": + "Weak copyleft license - may require source disclosure for modifications", + "LGPL-3.0": + "Weak copyleft license - may require source disclosure for modifications", + "AGPL-3.0": + "Network copyleft license - requires source disclosure for network use", + "AGPL-1.0": + "Network copyleft license - requires source disclosure for network use", // Other potentially problematic licenses WTFPL: "Potentially problematic license - legal uncertainty", - "CC-BY-SA-4.0": "ShareAlike license - requires derivative works to use same license", - "CC-BY-SA-3.0": "ShareAlike license - requires derivative works to use same license", + "CC-BY-SA-4.0": + "ShareAlike license - requires derivative works to use same license", + "CC-BY-SA-3.0": + "ShareAlike license - requires derivative works to use same license", "CC-BY-NC-4.0": "Non-commercial license - prohibits commercial use", "CC-BY-NC-3.0": "Non-commercial license - prohibits commercial use", "OSL-3.0": "Copyleft license - requires derivative works to be OSL", @@ -323,7 +366,9 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) { // Check if this license only affects our own packages const affectedPackages = licenseArray.filter((dep) => { - const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType; + const depLicense = Array.isArray(dep.licenseType) + ? dep.licenseType.join(", ") + : dep.licenseType; return depLicense === license; }); @@ -335,7 +380,10 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) { dep.name.toLowerCase().includes("stirlingpdf"), ); - if (isOnlyOurPackages && (license === "UNLICENSED" || license.startsWith("SEE LICENSE IN"))) { + if ( + isOnlyOurPackages && + (license === "UNLICENSED" || license.startsWith("SEE LICENSE IN")) + ) { return; // Skip warnings for our own Stirling-PDF packages } @@ -361,18 +409,25 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) { } // For AND licenses or OR licenses with no good options, check for problematic components - const hasProblematicComponent = Object.keys(problematicLicenses).some((problematic) => license.includes(problematic)); + const hasProblematicComponent = Object.keys(problematicLicenses).some( + (problematic) => license.includes(problematic), + ); if (hasProblematicComponent) { const affectedPackages = licenseArray .filter((dep) => { - const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType; + const depLicense = Array.isArray(dep.licenseType) + ? dep.licenseType.join(", ") + : dep.licenseType; return depLicense === license; }) .map((dep) => ({ name: dep.name, version: dep.version, - url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`, + url: + dep.repository || + dep.url || + `https://www.npmjs.com/package/${dep.name}`, })); const licenseType = license.includes("AND") ? "AND" : "OR"; @@ -397,21 +452,30 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) { if (problematicLicenses[license]) { const affectedPackages = licenseArray .filter((dep) => { - const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType; + const depLicense = Array.isArray(dep.licenseType) + ? dep.licenseType.join(", ") + : dep.licenseType; return depLicense === license; }) .map((dep) => ({ name: dep.name, version: dep.version, - url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`, + url: + dep.repository || + dep.url || + `https://www.npmjs.com/package/${dep.name}`, })); const packageList = affectedPackages .map((pkg) => pkg.name) .slice(0, 5) - .join(", ") + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : ""); - const licenseUrl = getLicenseUrl(license) || "https://opensource.org/licenses"; + .join(", ") + + (affectedPackages.length > 5 + ? `, and ${affectedPackages.length - 5} more` + : ""); + const licenseUrl = + getLicenseUrl(license) || "https://opensource.org/licenses"; warnings.push({ message: `⚠️ This PR contains ${count} package${count > 1 ? "s" : ""} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`, @@ -425,13 +489,18 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) { // Unknown license type - flag for manual review const affectedPackages = licenseArray .filter((dep) => { - const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType; + const depLicense = Array.isArray(dep.licenseType) + ? dep.licenseType.join(", ") + : dep.licenseType; return depLicense === license; }) .map((dep) => ({ name: dep.name, version: dep.version, - url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`, + url: + dep.repository || + dep.url || + `https://www.npmjs.com/package/${dep.name}`, })); warnings.push({ diff --git a/frontend/scripts/sample-pdf/styles.css b/frontend/scripts/sample-pdf/styles.css index 7f34b95e8..f4cb87ac6 100644 --- a/frontend/scripts/sample-pdf/styles.css +++ b/frontend/scripts/sample-pdf/styles.css @@ -21,8 +21,8 @@ /* Font Stack */ --font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", - "Helvetica Neue", sans-serif; + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; } * { diff --git a/frontend/scripts/sample-pdf/template.html b/frontend/scripts/sample-pdf/template.html index aea5e4cb1..20733766f 100644 --- a/frontend/scripts/sample-pdf/template.html +++ b/frontend/scripts/sample-pdf/template.html @@ -10,15 +10,39 @@
- - - - - + + + + +
- +

The Free Adobe Acrobat Alternative

@@ -40,21 +64,31 @@

What is Stirling PDF?

- Stirling PDF is a robust, web-based PDF manipulation tool. It enables you to carry out various operations on PDF - files, including splitting, merging, converting, rearranging, adding images, rotating, compressing, and more. + Stirling PDF is a robust, web-based PDF manipulation tool. It enables + you to carry out various operations on PDF files, including splitting, + merging, converting, rearranging, adding images, rotating, + compressing, and more.

- +

50+ PDF Operations

-

Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.

+

+ Comprehensive toolkit covering all your PDF needs. From basic + operations to advanced processing. +

@@ -66,24 +100,42 @@

Workflow Automation

-

Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.

+

+ Chain multiple operations together and save them as reusable + workflows. Perfect for recurring tasks. +

- + - +

Multi-Language Support

-

Available in over 30 languages with community-contributed translations. Accessible to users worldwide.

+

+ Available in over 30 languages with community-contributed + translations. Accessible to users worldwide. +

- + @@ -91,30 +143,47 @@

Privacy First

- Self-hosted solution means your data stays on your infrastructure. You have full control over your documents. + Self-hosted solution means your data stays on your infrastructure. + You have full control over your documents.

- +

Open Source

-

Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.

+

+ Transparent, community-driven development. Inspect the code, + contribute features, and adapt as needed. +

- +

API Access

-

RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.

+

+ RESTful API for integration with external tools and scripts. + Automate PDF operations programmatically. +

@@ -129,8 +198,15 @@
- - + + @@ -151,7 +227,12 @@
- + diff --git a/frontend/scripts/setup-env.ts b/frontend/scripts/setup-env.ts index 508a3ee19..14c6aef82 100644 --- a/frontend/scripts/setup-env.ts +++ b/frontend/scripts/setup-env.ts @@ -20,7 +20,9 @@ const args = process.argv.slice(2); const isDesktop = args.includes("--desktop"); const isSaas = args.includes("--saas"); -console.log("setup-env: see frontend/README.md#environment-variables for documentation"); +console.log( + "setup-env: see frontend/README.md#environment-variables for documentation", +); function getExampleKeys(exampleFile: string): string[] { const examplePath = join(root, exampleFile); @@ -44,7 +46,9 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean { config({ path: envPath }); - const missing = getExampleKeys(exampleFile).filter((k) => !(k in process.env)); + const missing = getExampleKeys(exampleFile).filter( + (k) => !(k in process.env), + ); if (missing.length > 0) { console.error( @@ -62,7 +66,8 @@ let failed = false; failed = ensureEnvFile(".env", "config/.env.example") || failed; if (isDesktop) { - failed = ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed; + failed = + ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed; } if (isSaas) { @@ -75,7 +80,9 @@ const allExampleKeys = new Set([ ...getExampleKeys("config/.env.desktop.example"), ...getExampleKeys("config/.env.saas.example"), ]); -const unknownViteVars = Object.keys(process.env).filter((k) => k.startsWith("VITE_") && !allExampleKeys.has(k)); +const unknownViteVars = Object.keys(process.env).filter( + (k) => k.startsWith("VITE_") && !allExampleKeys.has(k), +); if (unknownViteVars.length > 0) { console.warn( "setup-env: the following VITE_ vars are set but not listed in any example file:\n" + diff --git a/frontend/src/core/components/AppLayout.tsx b/frontend/src/core/components/AppLayout.tsx index 328758cf3..ca2637434 100644 --- a/frontend/src/core/components/AppLayout.tsx +++ b/frontend/src/core/components/AppLayout.tsx @@ -21,7 +21,9 @@ export function AppLayout({ children }: AppLayoutProps) { height: 100% !important; } `} -
+
{banner}
{children}
diff --git a/frontend/src/core/components/AppProviders.tsx b/frontend/src/core/components/AppProviders.tsx index 3080f946c..c32b00c40 100644 --- a/frontend/src/core/components/AppProviders.tsx +++ b/frontend/src/core/components/AppProviders.tsx @@ -7,7 +7,10 @@ import { FilesModalProvider } from "@app/contexts/FilesModalContext"; import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; import { HotkeyProvider } from "@app/contexts/HotkeyContext"; import { SidebarProvider } from "@app/contexts/SidebarContext"; -import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext"; +import { + PreferencesProvider, + usePreferences, +} from "@app/contexts/PreferencesContext"; import { AppConfigProvider, AppConfigProviderProps, @@ -67,7 +70,10 @@ function BrandingAssetManager() { } // Avoid requirement to have props which are required in app providers anyway -type AppConfigProviderOverrides = Omit; +type AppConfigProviderOverrides = Omit< + AppConfigProviderProps, + "children" | "retryOptions" +>; export interface AppProvidersProps { children: ReactNode; @@ -84,7 +90,8 @@ function ServerDefaultsSync() { if (config) { const serverDefaults = { hideUnavailableTools: config.defaultHideUnavailableTools ?? false, - hideUnavailableConversions: config.defaultHideUnavailableConversions ?? false, + hideUnavailableConversions: + config.defaultHideUnavailableConversions ?? false, }; updateServerDefaults(serverDefaults); } @@ -97,17 +104,27 @@ function ServerDefaultsSync() { * Core application providers * Contains all providers needed for the core */ -export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) { +export function AppProviders({ + children, + appConfigRetryOptions, + appConfigProviderProps, +}: AppProvidersProps) { return ( - + - + @@ -124,7 +141,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide - {children} + + {children} + diff --git a/frontend/src/core/components/FileManager.tsx b/frontend/src/core/components/FileManager.tsx index fe140ab17..0b56ddbb6 100644 --- a/frontend/src/core/components/FileManager.tsx +++ b/frontend/src/core/components/FileManager.tsx @@ -11,7 +11,10 @@ import DesktopLayout from "@app/components/fileManager/DesktopLayout"; import DragOverlay from "@app/components/fileManager/DragOverlay"; import { FileManagerProvider } from "@app/contexts/FileManagerContext"; import { Z_INDEX_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; -import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from "@app/services/googleDrivePickerService"; +import { + isGoogleDriveConfigured, + extractGoogleDriveBackendConfig, +} from "@app/services/googleDrivePickerService"; import { loadScript } from "@app/utils/scriptLoader"; import { useAllFiles } from "@app/contexts/FileContext"; @@ -20,7 +23,12 @@ interface FileManagerProps { } const FileManager: React.FC = ({ selectedTool }) => { - const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext(); + const { + isFilesModalOpen, + closeFilesModal, + onFileUpload, + onRecentFileSelect, + } = useFilesModalContext(); const { config } = useAppConfig(); const [recentFiles, setRecentFiles] = useState([]); const [isDragging, setIsDragging] = useState(false); @@ -101,7 +109,9 @@ const FileManager: React.FC = ({ selectedTool }) => { return () => { // 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"); + console.log( + "FileManager unmounting - FileContext handles blob URL cleanup", + ); }; }, []); @@ -109,7 +119,12 @@ const FileManager: React.FC = ({ selectedTool }) => { // Use useMemo to only track Google Drive config changes, not all config updates const googleDriveBackendConfig = useMemo( () => extractGoogleDriveBackendConfig(config), - [config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId], + [ + config?.googleDriveEnabled, + config?.googleDriveClientId, + config?.googleDriveApiKey, + config?.googleDriveAppId, + ], ); useEffect(() => { diff --git a/frontend/src/core/components/StorageStatsCard.tsx b/frontend/src/core/components/StorageStatsCard.tsx index bc71845bb..468d24fe3 100644 --- a/frontend/src/core/components/StorageStatsCard.tsx +++ b/frontend/src/core/components/StorageStatsCard.tsx @@ -14,7 +14,12 @@ interface StorageStatsCardProps { onReloadFiles: () => void; } -const StorageStatsCard: React.FC = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => { +const StorageStatsCard: React.FC = ({ + storageStats, + filesCount, + onClearAll, + onReloadFiles, +}) => { const { t } = useTranslation(); if (!storageStats) return null; @@ -27,19 +32,27 @@ const StorageStatsCard: React.FC = ({ storageStats, files
- {t("fileManager.storage", "Storage")}: {formatFileSize(storageStats.used)} + {t("fileManager.storage", "Storage")}:{" "} + {formatFileSize(storageStats.used)} {storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`} {storageStats.quota && ( 80 ? "red" : storageUsagePercent > 60 ? "yellow" : "blue"} + color={ + storageUsagePercent > 80 + ? "red" + : storageUsagePercent > 60 + ? "yellow" + : "blue" + } size="sm" mt={4} /> )} - {storageStats.fileCount} {t("fileManager.filesStored", "files stored")} + {storageStats.fileCount}{" "} + {t("fileManager.filesStored", "files stored")}
@@ -54,7 +67,12 @@ const StorageStatsCard: React.FC = ({ storageStats, files {t("fileManager.clearAll", "Clear All")} )} - diff --git a/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx b/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx index d1c8f5c08..bbc6484f2 100644 --- a/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx +++ b/frontend/src/core/components/annotation/providers/PDFAnnotationProvider.tsx @@ -26,7 +26,9 @@ interface PDFAnnotationContextValue { setSignatureConfig: (config: any | null) => void; } -const PDFAnnotationContext = createContext(undefined); +const PDFAnnotationContext = createContext< + PDFAnnotationContextValue | undefined +>(undefined); interface PDFAnnotationProviderProps { children: ReactNode; @@ -75,13 +77,19 @@ export const PDFAnnotationProvider: React.FC = ({ setSignatureConfig, }; - return {children}; + return ( + + {children} + + ); }; export const usePDFAnnotation = (): PDFAnnotationContextValue => { const context = useContext(PDFAnnotationContext); if (context === undefined) { - throw new Error("usePDFAnnotation must be used within a PDFAnnotationProvider"); + throw new Error( + "usePDFAnnotation must be used within a PDFAnnotationProvider", + ); } return context; }; diff --git a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx index 07a441abd..4c0862a01 100644 --- a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx +++ b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx @@ -34,7 +34,10 @@ export const BaseAnnotationTool: React.FC = ({ const [selectedColor, setSelectedColor] = useState("#000000"); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [signatureData, setSignatureData] = useState(null); - const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false }); + const [historyAvailability, setHistoryAvailability] = useState({ + canUndo: false, + canRedo: false, + }); const historyApiInstance = historyApiRef.current; useEffect(() => { @@ -77,7 +80,9 @@ export const BaseAnnotationTool: React.FC = ({ onRedo={redo} canUndo={historyAvailability.canUndo} canRedo={historyAvailability.canRedo} - onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined} + onPlaceSignature={ + config.showPlaceButton ? handlePlaceSignature : undefined + } hasSignatureData={!!signatureData} disabled={disabled} showPlaceButton={config.showPlaceButton} @@ -94,8 +99,13 @@ export const BaseAnnotationTool: React.FC = ({ })} {/* Instructions for placing signature */} - - Click anywhere on the PDF to place your annotation. + + + Click anywhere on the PDF to place your annotation. + {/* Color Picker Modal */} diff --git a/frontend/src/core/components/annotation/shared/ColorControl.tsx b/frontend/src/core/components/annotation/shared/ColorControl.tsx index 66ff868fd..ff2a3ff45 100644 --- a/frontend/src/core/components/annotation/shared/ColorControl.tsx +++ b/frontend/src/core/components/annotation/shared/ColorControl.tsx @@ -1,10 +1,19 @@ -import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from "@mantine/core"; +import { + ActionIcon, + Tooltip, + Popover, + Stack, + ColorSwatch, + ColorPicker as MantineColorPicker, + Group, +} from "@mantine/core"; import { useState, useCallback, useEffect } from "react"; import ColorizeIcon from "@mui/icons-material/Colorize"; // safari and firefox do not support the eye dropper API, only edge, chrome and opera do. // the button is hidden in the UI if the API is not supported. -const supportsEyeDropper = typeof window !== "undefined" && "EyeDropper" in window; +const supportsEyeDropper = + typeof window !== "undefined" && "EyeDropper" in window; interface EyeDropper { open(): Promise<{ sRGBHex: string }>; @@ -18,7 +27,12 @@ interface ColorControlProps { disabled?: boolean; } -export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) { +export function ColorControl({ + value, + onChange, + label, + disabled = false, +}: ColorControlProps) { const [opened, setOpened] = useState(false); // Buffer the colour locally so the picker stays responsive during drag. // Only propagate to the parent (which triggers expensive annotation updates) @@ -40,7 +54,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color }, [onChange]); return ( - + = ({ }) => { const { t } = useTranslation(); const resolvedTitle = title ?? t("colorPicker.title", "Choose colour"); - const resolvedOpacityLabel = opacityLabel ?? t("annotation.opacity", "Opacity"); + const resolvedOpacityLabel = + opacityLabel ?? t("annotation.opacity", "Opacity"); return ( - + = ({ color, onClick, size = 24 }) => { - return ; +export const ColorSwatchButton: React.FC = ({ + color, + onClick, + size = 24, +}) => { + return ( + + ); }; diff --git a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index 080a05c83..de6348f62 100644 --- a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -47,7 +47,9 @@ export const DrawingCanvas: React.FC = ({ const modalCanvasRef = useRef(null); const padRef = useRef(null); const [modalOpen, setModalOpen] = useState(false); - const [savedSignatureData, setSavedSignatureData] = useState(null); + const [savedSignatureData, setSavedSignatureData] = useState( + null, + ); const initPad = (canvas: HTMLCanvasElement) => { if (!padRef.current) { @@ -125,7 +127,17 @@ export const DrawingCanvas: React.FC = ({ trimmedCanvas.height = trimHeight; const trimmedCtx = trimmedCanvas.getContext("2d"); if (trimmedCtx) { - trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight); + trimmedCtx.drawImage( + canvas, + minX, + minY, + trimWidth, + trimHeight, + 0, + 0, + trimWidth, + trimHeight, + ); } return trimmedCanvas.toDataURL("image/png"); @@ -140,7 +152,10 @@ export const DrawingCanvas: React.FC = ({ const img = new Image(); img.onload = () => { ctx.clearRect(0, 0, canvas.width, canvas.height); - const scale = Math.min(canvas.width / img.width, canvas.height / img.height); + const scale = Math.min( + canvas.width / img.width, + canvas.height / img.height, + ); const scaledWidth = img.width * scale; const scaledHeight = img.height * scale; const x = (canvas.width - scaledWidth) / 2; @@ -181,7 +196,12 @@ export const DrawingCanvas: React.FC = ({ if (previewCanvasRef.current) { const ctx = previewCanvasRef.current.getContext("2d"); if (ctx) { - ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height); + ctx.clearRect( + 0, + 0, + previewCanvasRef.current.width, + previewCanvasRef.current.height, + ); } } setSavedSignatureData(null); // Clear saved signature @@ -230,7 +250,9 @@ export const DrawingCanvas: React.FC = ({ - {t("sign.canvas.heading", "Draw your signature")} + + {t("sign.canvas.heading", "Draw your signature")} + = ({ {t("sign.canvas.colorLabel", "Colour")} - + diff --git a/frontend/src/core/components/annotation/shared/DrawingControls.tsx b/frontend/src/core/components/annotation/shared/DrawingControls.tsx index 9de5e45b9..7118a817e 100644 --- a/frontend/src/core/components/annotation/shared/DrawingControls.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingControls.tsx @@ -44,7 +44,12 @@ export const DrawingControls: React.FC = ({ disabled={undoDisabled} color={undoDisabled ? "gray" : "blue"} > - + )} @@ -58,7 +63,12 @@ export const DrawingControls: React.FC = ({ disabled={redoDisabled} color={redoDisabled ? "gray" : "blue"} > - + )} @@ -67,7 +77,13 @@ export const DrawingControls: React.FC = ({ {/* Place Signature Button */} {showPlaceButton && onPlaceSignature && ( - )} diff --git a/frontend/src/core/components/annotation/shared/ImageUploader.tsx b/frontend/src/core/components/annotation/shared/ImageUploader.tsx index 86bbbe7b9..00802893c 100644 --- a/frontend/src/core/components/annotation/shared/ImageUploader.tsx +++ b/frontend/src/core/components/annotation/shared/ImageUploader.tsx @@ -27,22 +27,33 @@ export const ImageUploader: React.FC = ({ const { t } = useTranslation(); const [removeBackground, setRemoveBackground] = useState(false); const [currentFile, setCurrentFile] = useState(null); - const [originalImageData, setOriginalImageData] = useState(null); + const [originalImageData, setOriginalImageData] = useState( + null, + ); const [isProcessing, setIsProcessing] = useState(false); - const processImage = async (imageSource: File | string, shouldRemoveBackground: boolean): Promise => { + const processImage = async ( + imageSource: File | string, + shouldRemoveBackground: boolean, + ): Promise => { if (shouldRemoveBackground && allowBackgroundRemoval) { setIsProcessing(true); try { - const transparentImageDataUrl = await removeWhiteBackground(imageSource, { - autoDetectCorner: true, - tolerance: 15, - }); + const transparentImageDataUrl = await removeWhiteBackground( + imageSource, + { + autoDetectCorner: true, + tolerance: 15, + }, + ); onProcessedImageData?.(transparentImageDataUrl); } catch (error) { console.error("Error removing background:", error); alert({ - title: t("sign.image.backgroundRemovalFailedTitle", "Background removal failed"), + title: t( + "sign.image.backgroundRemovalFailedTitle", + "Background removal failed", + ), body: t( "sign.image.backgroundRemovalFailedMessage", "Could not remove the background from the image. Using original image instead.", @@ -72,7 +83,10 @@ export const ImageUploader: React.FC = ({ if (file && !disabled) { try { // Validate that it's actually an image file or SVG - if (!file.type.startsWith("image/") && !file.name.toLowerCase().endsWith(".svg")) { + if ( + !file.type.startsWith("image/") && + !file.name.toLowerCase().endsWith(".svg") + ) { console.error("Selected file is not an image or SVG"); return; } @@ -83,7 +97,9 @@ export const ImageUploader: React.FC = ({ let dataUrlToProcess: string; // Check if file is SVG - const isSvg = file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg"); + const isSvg = + file.type === "image/svg+xml" || + file.name.toLowerCase().endsWith(".svg"); if (isSvg) { // For SVG, convert to PNG so it can be embedded in PDF @@ -129,7 +145,10 @@ export const ImageUploader: React.FC = ({ let width = 800; // Default width let height = 600; // Default height - if (svgElement.hasAttribute("width") && svgElement.hasAttribute("height")) { + if ( + svgElement.hasAttribute("width") && + svgElement.hasAttribute("height") + ) { width = parseFloat(svgElement.getAttribute("width") || "800"); height = parseFloat(svgElement.getAttribute("height") || "600"); } else if (svgElement.hasAttribute("viewBox")) { @@ -141,7 +160,12 @@ export const ImageUploader: React.FC = ({ } // Ensure reasonable dimensions - if (width === 0 || height === 0 || !isFinite(width) || !isFinite(height)) { + if ( + width === 0 || + height === 0 || + !isFinite(width) || + !isFinite(height) + ) { width = 800; height = 600; } @@ -158,7 +182,9 @@ export const ImageUploader: React.FC = ({ // Create an image element to render SVG const img = new Image(); - const blob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" }); + const blob = new Blob([svgText], { + type: "image/svg+xml;charset=utf-8", + }); const url = URL.createObjectURL(blob); img.onload = () => { @@ -239,7 +265,9 @@ export const ImageUploader: React.FC = ({ = ({ {allowBackgroundRemoval && ( handleBackgroundRemovalChange(event.currentTarget.checked)} + onChange={(event) => + handleBackgroundRemovalChange(event.currentTarget.checked) + } disabled={disabled || !currentFile || isProcessing} /> )} diff --git a/frontend/src/core/components/annotation/shared/OpacityControl.tsx b/frontend/src/core/components/annotation/shared/OpacityControl.tsx index 914d4f038..496b5c407 100644 --- a/frontend/src/core/components/annotation/shared/OpacityControl.tsx +++ b/frontend/src/core/components/annotation/shared/OpacityControl.tsx @@ -1,4 +1,11 @@ -import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core"; +import { + ActionIcon, + Tooltip, + Popover, + Stack, + Slider, + Text, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useState } from "react"; import OpacityIcon from "@mui/icons-material/Opacity"; @@ -9,7 +16,11 @@ interface OpacityControlProps { disabled?: boolean; } -export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) { +export function OpacityControl({ + value, + onChange, + disabled = false, +}: OpacityControlProps) { const { t } = useTranslation(); const [opened, setOpened] = useState(false); @@ -46,7 +57,13 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon {t("annotation.opacity", "Opacity")} - `${val}%`} /> + `${val}%`} + /> diff --git a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx index 8828f3b93..5f23b81ed 100644 --- a/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx +++ b/frontend/src/core/components/annotation/shared/PropertiesPopover.tsx @@ -1,4 +1,13 @@ -import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from "@mantine/core"; +import { + ActionIcon, + Tooltip, + Popover, + Stack, + Slider, + Text, + Group, + Button, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useState } from "react"; import type { TrackedAnnotation } from "@embedpdf/plugin-annotation"; @@ -18,7 +27,12 @@ interface PropertiesPopoverProps { disabled?: boolean; } -export function PropertiesPopover({ annotationType, annotation, onUpdate, disabled = false }: PropertiesPopoverProps) { +export function PropertiesPopover({ + annotationType, + annotation, + onUpdate, + disabled = false, +}: PropertiesPopoverProps) { const { t } = useTranslation(); const [opened, setOpened] = useState(false); @@ -30,7 +44,9 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl strokeWidth?: number; } - const obj = annotation?.object as (PdfAnnotationObject & AnnotationObjectProps) | undefined; + const obj = annotation?.object as + | (PdfAnnotationObject & AnnotationObjectProps) + | undefined; // Get current values const fontSize = obj?.fontSize ?? 14; @@ -171,7 +187,9 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl }); }} > - {borderVisible ? t("annotation.borderOn", "Border: On") : t("annotation.borderOff", "Border: Off")} + {borderVisible + ? t("annotation.borderOn", "Border: On") + : t("annotation.borderOff", "Border: Off")}
@@ -207,7 +225,8 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl - {(annotationType === "text" || annotationType === "note") && renderTextNoteControls()} + {(annotationType === "text" || annotationType === "note") && + renderTextNoteControls()} {annotationType === "shape" && renderShapeControls()} diff --git a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index 59bcc5756..a1a07a95b 100644 --- a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -1,5 +1,14 @@ import React, { useState, useEffect } from "react"; -import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from "@mantine/core"; +import { + Stack, + TextInput, + Select, + Combobox, + useCombobox, + Group, + Box, + SegmentedControl, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; import { ColorPicker } from "@app/components/annotation/shared/ColorPicker"; diff --git a/frontend/src/core/components/annotation/shared/WidthControl.tsx b/frontend/src/core/components/annotation/shared/WidthControl.tsx index 58553e64b..d4f063f60 100644 --- a/frontend/src/core/components/annotation/shared/WidthControl.tsx +++ b/frontend/src/core/components/annotation/shared/WidthControl.tsx @@ -1,4 +1,11 @@ -import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core"; +import { + ActionIcon, + Tooltip, + Popover, + Stack, + Slider, + Text, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useState } from "react"; import LineWeightIcon from "@mui/icons-material/LineWeight"; @@ -11,7 +18,13 @@ interface WidthControlProps { disabled?: boolean; } -export function WidthControl({ value, onChange, min, max, disabled = false }: WidthControlProps) { +export function WidthControl({ + value, + onChange, + min, + max, + disabled = false, +}: WidthControlProps) { const { t } = useTranslation(); const [opened, setOpened] = useState(false); @@ -48,7 +61,13 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi {t("annotation.width", "Width")} - `${val}pt`} /> + `${val}pt`} + /> diff --git a/frontend/src/core/components/annotation/tools/DrawingTool.tsx b/frontend/src/core/components/annotation/tools/DrawingTool.tsx index 2699418ee..e35f329b7 100644 --- a/frontend/src/core/components/annotation/tools/DrawingTool.tsx +++ b/frontend/src/core/components/annotation/tools/DrawingTool.tsx @@ -8,7 +8,10 @@ interface DrawingToolProps { disabled?: boolean; } -export const DrawingTool: React.FC = ({ onDrawingChange, disabled = false }) => { +export const DrawingTool: React.FC = ({ + onDrawingChange, + disabled = false, +}) => { const [selectedColor] = useState("#000000"); const [penSize, setPenSize] = useState(2); const [penSizeInput, setPenSizeInput] = useState("2"); @@ -20,7 +23,11 @@ export const DrawingTool: React.FC = ({ onDrawingChange, disab }; return ( - + = ({ onImageChange, disabled = false }) => { +export const ImageTool: React.FC = ({ + onImageChange, + disabled = false, +}) => { const [, setImageData] = useState(null); const handleImageUpload = async (file: File | null) => { @@ -45,7 +48,11 @@ export const ImageTool: React.FC = ({ onImageChange, disabled = }; return ( - + { +const AddFileCard = ({ + onFileSelect, + accept, + multiple = true, +}: AddFileCardProps) => { const { t } = useTranslation(); const fileInputRef = useRef(null); const { openFilesModal } = useFilesModalContext(); @@ -84,7 +88,9 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
-
{t("fileEditor.addFiles", "Add Files")}
+
+ {t("fileEditor.addFiles", "Add Files")} +
@@ -131,8 +137,15 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps onClick={handleOpenFilesModal} onMouseEnter={() => setIsUploadHover(false)} > - - {!isUploadHover && {t("landing.addFiles", "Add Files")}} + + {!isUploadHover && ( + {t("landing.addFiles", "Add Files")} + )}
{/* Instruction Text */} {terminology.dropFilesHere} diff --git a/frontend/src/core/components/fileEditor/FileEditor.tsx b/frontend/src/core/components/fileEditor/FileEditor.tsx index 64a64b6c4..ec34d0f0c 100644 --- a/frontend/src/core/components/fileEditor/FileEditor.tsx +++ b/frontend/src/core/components/fileEditor/FileEditor.tsx @@ -1,7 +1,13 @@ import { useState, useCallback, useRef, useMemo, useEffect } from "react"; import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { Dropzone } from "@mantine/dropzone"; -import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from "@app/contexts/FileContext"; +import { + useFileSelection, + useFileState, + useFileManagement, + useFileActions, + useFileContext, +} from "@app/contexts/FileContext"; import { useNavigationActions } from "@app/contexts/NavigationContext"; import { useViewer } from "@app/contexts/ViewerContext"; import { zipFileService } from "@app/services/zipFileService"; @@ -22,7 +28,10 @@ interface FileEditorProps { supportedExtensions?: string[]; } -const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEditorProps) => { +const FileEditor = ({ + toolMode = false, + supportedExtensions = ["pdf"], +}: FileEditorProps) => { // Utility function to check if a file extension is supported const isFileSupported = useCallback( (fileName: string): boolean => { @@ -40,7 +49,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi const { clearAllFileErrors } = fileContextActions; // Extract needed values from state (memoized to prevent infinite loops) - const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [state.files.byId, state.files.ids]); + const activeStirlingFileStubs = useMemo( + () => selectors.getStirlingFileStubs(), + [state.files.byId, state.files.ids], + ); const selectedFileIds = state.ui.selectedFileIds; const totalItems = state.files.ids.length; const selectedCount = selectedFileIds.length; @@ -58,11 +70,27 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi const [_error, _setError] = useState(null); // Toast helpers - const showStatus = useCallback((message: string, type: "neutral" | "success" | "warning" | "error" = "neutral") => { - alert({ alertType: type, title: message, expandable: false, durationMs: 4000 }); - }, []); + const showStatus = useCallback( + ( + message: string, + type: "neutral" | "success" | "warning" | "error" = "neutral", + ) => { + alert({ + alertType: type, + title: message, + expandable: false, + durationMs: 4000, + }); + }, + [], + ); const showError = useCallback((message: string) => { - alert({ alertType: "error", title: "Error", body: message, expandable: true }); + alert({ + alertType: "error", + title: "Error", + body: message, + expandable: true, + }); }, []); const [selectionMode, setSelectionMode] = useState(toolMode); @@ -83,7 +111,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi }, [toolMode]); const [showFilePickerModal, setShowFilePickerModal] = useState(false); // Get selected file IDs from context (defensive programming) - const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; + const contextSelectedIds = Array.isArray(selectedFileIds) + ? selectedFileIds + : []; // Create refs for frequently changing values to stabilize callbacks const contextSelectedIdsRef = useRef([]); @@ -95,7 +125,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi const handleSelectAllFiles = useCallback(() => { // Respect maxAllowed: if limited, select the last N files const allIds = state.files.ids; - const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds; + const idsToSelect = Number.isFinite(maxAllowed) + ? allIds.slice(-maxAllowed) + : allIds; setSelectedFiles(idsToSelect); try { clearAllFileErrors(); @@ -147,7 +179,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi await addFiles(uploadedFiles, { selectFiles: true }); // After auto-selection, enforce maxAllowed if needed if (Number.isFinite(maxAllowed)) { - const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map((r) => r.id); + const nowSelectedIds = selectors + .getSelectedStirlingFileStubs() + .map((r) => r.id); if (nowSelectedIds.length > maxAllowed) { setSelectedFiles(nowSelectedIds.slice(-maxAllowed)); } @@ -155,7 +189,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi showStatus(`Added ${uploadedFiles.length} file(s)`, "success"); } } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Failed to process files"; + const errorMessage = + err instanceof Error ? err.message : "Failed to process files"; showError(errorMessage); console.error("File processing error:", err); } @@ -182,14 +217,18 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi // Add file to selection // Determine max files allowed from the active tool (negative or undefined means unlimited) const rawMax = selectedTool?.maxFiles; - const maxAllowed = !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax; + const maxAllowed = + !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax; if (maxAllowed === 1) { // Only one file allowed -> replace selection with the new file newSelection = [contextFileId]; } else { // If at capacity, drop the oldest selected and append the new one - if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) { + if ( + Number.isFinite(maxAllowed) && + currentSelectedIds.length >= maxAllowed + ) { newSelection = [...currentSelectedIds.slice(1), contextFileId]; } else { newSelection = [...currentSelectedIds, contextFileId]; @@ -200,7 +239,13 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi // Update context (this automatically updates tool selection since they use the same action) setSelectedFiles(newSelection); }, - [setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles], + [ + setSelectedFiles, + toolMode, + _setStatus, + activeStirlingFileStubs, + selectedTool?.maxFiles, + ], ); // Enforce maxAllowed when tool changes or when an external action sets too many selected files @@ -226,13 +271,17 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi // Handle multi-file selection reordering const filesToMove = - selectedFileIds.length > 1 ? selectedFileIds.filter((id) => currentIds.includes(id)) : [sourceFileId]; + selectedFileIds.length > 1 + ? selectedFileIds.filter((id) => currentIds.includes(id)) + : [sourceFileId]; // Create new order const newOrder = [...currentIds]; // Remove files to move from their current positions (in reverse order to maintain indices) - const sourceIndices = filesToMove.map((id) => newOrder.findIndex((nId) => nId === id)).sort((a, b) => b - a); // Sort descending + const sourceIndices = filesToMove + .map((id) => newOrder.findIndex((nId) => nId === id)) + .sort((a, b) => b - a); // Sort descending sourceIndices.forEach((index) => { newOrder.splice(index, 1); @@ -278,11 +327,19 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi removeFiles([contextFileId], false); // Remove from context selections - const currentSelected = selectedFileIds.filter((id) => id !== contextFileId); + const currentSelected = selectedFileIds.filter( + (id) => id !== contextFileId, + ); setSelectedFiles(currentSelected); } }, - [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds], + [ + activeStirlingFileStubs, + selectors, + removeFiles, + setSelectedFiles, + selectedFileIds, + ], ); const handleDownloadFile = useCallback( @@ -315,7 +372,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi isDirty: false, }); } else { - console.log("[FileEditor] Skipping clean mark:", { savedPath: result.savedPath, isDirty: record.isDirty }); + console.log("[FileEditor] Skipping clean mark:", { + savedPath: result.savedPath, + isDirty: record.isDirty, + }); } } }, @@ -329,7 +389,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi if (record && file) { try { // Extract and store files using shared service method - const result = await zipFileService.extractAndStoreFilesWithHistory(file, record); + const result = await zipFileService.extractAndStoreFilesWithHistory( + file, + record, + ); if (result.success && result.extractedStubs.length > 0) { // Add extracted file stubs to FileContext @@ -376,7 +439,12 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi navActions.setWorkbench("viewer"); } }, - [activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench], + [ + activeStirlingFileStubs, + setActiveFileId, + setActiveFileIndex, + navActions.setWorkbench, + ], ); const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { @@ -417,7 +485,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi No files loaded - Upload PDF files, ZIP archives, or load from storage to get started + Upload PDF files, ZIP archives, or load from storage to get + started @@ -432,7 +501,12 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi }} > {/* Add File Card - only show when files exist */} - {activeStirlingFileStubs.length > 0 && } + {activeStirlingFileStubs.length > 0 && ( + + )} {activeStirlingFileStubs.map((record, index) => { return ( diff --git a/frontend/src/core/components/fileEditor/FileEditorFileName.tsx b/frontend/src/core/components/fileEditor/FileEditorFileName.tsx index 82911a933..b526e8561 100644 --- a/frontend/src/core/components/fileEditor/FileEditorFileName.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorFileName.tsx @@ -6,6 +6,8 @@ interface FileEditorFileNameProps { file: StirlingFileStub; } -const FileEditorFileName = ({ file }: FileEditorFileNameProps) => {file.name}; +const FileEditorFileName = ({ file }: FileEditorFileNameProps) => ( + {file.name} +); export default FileEditorFileName; diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index 307b498f2..dab8fd7b5 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -1,5 +1,15 @@ import React, { useState, useCallback, useRef, useMemo } from "react"; -import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from "@mantine/core"; +import { + Text, + ActionIcon, + CheckboxIndicator, + Tooltip, + Modal, + Button, + Group, + Stack, + Loader, +} from "@mantine/core"; import { useIsMobile } from "@app/hooks/useIsMobile"; import { alert } from "@app/components/toast"; import { useTranslation } from "react-i18next"; @@ -14,7 +24,10 @@ import PushPinIcon from "@mui/icons-material/PushPin"; import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined"; import LockOpenIcon from "@mui/icons-material/LockOpen"; import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; -import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { + draggable, + dropTargetForElements, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { StirlingFileStub } from "@app/types/fileContext"; import { zipFileService } from "@app/services/zipFileService"; @@ -24,7 +37,9 @@ import { useFileState } from "@app/contexts/file/fileHooks"; import { FileId } from "@app/types/file"; import { formatFileSize } from "@app/utils/fileUtils"; import ToolChain from "@app/components/shared/ToolChain"; -import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu"; +import HoverActionMenu, { + HoverAction, +} from "@app/components/shared/HoverActionMenu"; import { downloadFile } from "@app/services/downloadService"; import { PrivateContent } from "@app/components/shared/PrivateContent"; import UploadToServerModal from "@app/components/shared/UploadToServerModal"; @@ -42,7 +57,11 @@ interface FileEditorThumbnailProps { onCloseFile: (fileId: FileId) => void; onViewFile: (fileId: FileId) => void; _onSetStatus: (status: string) => void; - onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void; + onReorderFiles?: ( + sourceFileId: FileId, + targetFileId: FileId, + selectedFileIds: FileId[], + ) => void; onDownloadFile: (fileId: FileId) => void; onUnzipFile?: (fileId: FileId) => void; toolMode?: boolean; @@ -67,7 +86,14 @@ const FileEditorThumbnail = ({ const terminology = useFileActionTerminology(); const icons = useFileActionIcons(); const DownloadOutlinedIcon = icons.download; - const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions, openEncryptedUnlockPrompt } = useFileContext(); + const { + pinFile, + unpinFile, + isFilePinned, + activeFiles, + actions: fileActions, + openEncryptedUnlockPrompt, + } = useFileContext(); const { state, selectors } = useFileState(); const hasError = state.ui.errorFileIds.includes(file.id); @@ -117,18 +143,29 @@ const FileEditorThumbnail = ({ const isCBZ = extLower === "cbz"; const isCBR = extLower === "cbr"; const uploadEnabled = config?.storageEnabled === true; - const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; - const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; + const sharingEnabled = + uploadEnabled && config?.storageSharingEnabled === true; + const shareLinksEnabled = + sharingEnabled && config?.storageShareLinksEnabled === true; const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false; - const isSharedFile = file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink; + const isSharedFile = + file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink; const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0; const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0; const isUploaded = Boolean(file.remoteStorageId); const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; - const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate); + const canUpload = + uploadEnabled && + isOwnedOrLocal && + file.isLeaf && + (!isUploaded || !isUpToDate); const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf; - const pageLabel = useMemo(() => (pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : ""), [pageCount]); + const pageLabel = useMemo( + () => + pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : "", + [pageCount], + ); const dateLabel = useMemo(() => { const d = new Date(file.lastModified); @@ -198,7 +235,12 @@ const FileEditorThumbnail = ({ const handleConfirmClose = useCallback(() => { onCloseFile(file.id); - alert({ alertType: "neutral", title: `Closed ${file.name}`, expandable: false, durationMs: 3500 }); + alert({ + alertType: "neutral", + title: `Closed ${file.name}`, + expandable: false, + durationMs: 3500, + }); setShowCloseModal(false); }, [file.id, file.name, onCloseFile]); @@ -222,16 +264,33 @@ const FileEditorThumbnail = ({ } } catch (error) { console.error(`Failed to save ${file.name}:`, error); - alert({ alertType: "error", title: "Save failed", body: `Could not save ${file.name}`, expandable: true }); + alert({ + alertType: "error", + title: "Save failed", + body: `Could not save ${file.name}`, + expandable: true, + }); setShowCloseModal(false); return; } } // Then close onCloseFile(file.id); - alert({ alertType: "success", title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 }); + alert({ + alertType: "success", + title: `Saved and closed ${file.name}`, + expandable: false, + durationMs: 3500, + }); setShowCloseModal(false); - }, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]); + }, [ + file.id, + file.name, + file.localFilePath, + onCloseFile, + selectors, + fileActions, + ]); const handleCancelClose = useCallback(() => { setShowCloseModal(false); @@ -298,7 +357,12 @@ const FileEditorThumbnail = ({ e.stopPropagation(); if (onUnzipFile) { onUnzipFile(file.id); - alert({ alertType: "success", title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); + alert({ + alertType: "success", + title: `Unzipping ${file.name}`, + expandable: false, + durationMs: 2500, + }); } }, hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR, @@ -381,7 +445,10 @@ const FileEditorThumbnail = ({ onDoubleClick={handleCardDoubleClick} > {/* Header bar */} -
+
{/* Logo/checkbox area */}
{hasError ? ( @@ -402,16 +469,27 @@ const FileEditorThumbnail = ({
{/* Centered index */} -
+
{index + 1}
{/* Action buttons group */}
{isEncrypted && ( - + { @@ -426,7 +504,9 @@ const FileEditorThumbnail = ({ {/* Pin/Unpin icon */} - {isPinned ? : } + {isPinned ? ( + + ) : ( + + )}
@@ -470,7 +564,13 @@ const FileEditorThumbnail = ({ {truncateCenter(file.name, 40)} - + {/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */} {`v${file.versionNumber} - `} {dateLabel} @@ -482,7 +582,11 @@ const FileEditorThumbnail = ({ {/* Preview area */}
{file.thumbnailUrl ? ( @@ -513,7 +617,12 @@ const FileEditorThumbnail = ({ /> ) : file.type?.startsWith("application/pdf") ? ( - + Loading thumbnail... @@ -554,7 +663,11 @@ const FileEditorThumbnail = ({
{/* Hover Menu */} - + {/* Close Confirmation Modal */} {file.isDirty && file.localFilePath ? ( <> - {t("confirmCloseUnsaved", "This file has unsaved changes.")} + + {t("confirmCloseUnsaved", "This file has unsaved changes.")} + {file.name} @@ -575,7 +690,11 @@ const FileEditorThumbnail = ({ - - @@ -623,8 +751,20 @@ const FileEditorThumbnail = ({ - {canUpload && setShowUploadModal(false)} file={file} />} - {canShare && setShowShareModal(false)} file={file} />} + {canUpload && ( + setShowUploadModal(false)} + file={file} + /> + )} + {canShare && ( + setShowShareModal(false)} + file={file} + /> + )}
); }; diff --git a/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx b/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx index 95de66590..8c5383457 100644 --- a/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx +++ b/frontend/src/core/components/fileEditor/fileEditorRightRailButtons.tsx @@ -1,6 +1,9 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons"; +import { + useRightRailButtons, + RightRailButtonWithAction, +} from "@app/hooks/useRightRailButtons"; import LocalIcon from "@app/components/shared/LocalIcon"; interface FileEditorRightRailButtonsParams { @@ -26,7 +29,10 @@ export function useFileEditorRightRailButtons({ id: "file-select-all", icon: , tooltip: t("rightRail.selectAll", "Select All"), - ariaLabel: typeof t === "function" ? t("rightRail.selectAll", "Select All") : "Select All", + ariaLabel: + typeof t === "function" + ? t("rightRail.selectAll", "Select All") + : "Select All", section: "top" as const, order: 10, disabled: totalItems === 0 || selectedCount === totalItems, @@ -35,9 +41,18 @@ export function useFileEditorRightRailButtons({ }, { id: "file-deselect-all", - icon: , + icon: ( + + ), tooltip: t("rightRail.deselectAll", "Deselect All"), - ariaLabel: typeof t === "function" ? t("rightRail.deselectAll", "Deselect All") : "Deselect All", + ariaLabel: + typeof t === "function" + ? t("rightRail.deselectAll", "Deselect All") + : "Deselect All", section: "top" as const, order: 20, disabled: selectedCount === 0, @@ -48,7 +63,10 @@ export function useFileEditorRightRailButtons({ id: "file-close-selected", icon: , tooltip: t("rightRail.closeSelected", "Close Selected Files"), - ariaLabel: typeof t === "function" ? t("rightRail.closeSelected", "Close Selected Files") : "Close Selected Files", + ariaLabel: + typeof t === "function" + ? t("rightRail.closeSelected", "Close Selected Files") + : "Close Selected Files", section: "top" as const, order: 30, disabled: selectedCount === 0, @@ -56,7 +74,15 @@ export function useFileEditorRightRailButtons({ onClick: onCloseSelected, }, ], - [t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected], + [ + t, + i18n.language, + totalItems, + selectedCount, + onSelectAll, + onDeselectAll, + onCloseSelected, + ], ); useRightRailButtons(buttons); diff --git a/frontend/src/core/components/fileManager/CompactFileDetails.tsx b/frontend/src/core/components/fileManager/CompactFileDetails.tsx index 9e60b8d8e..d8717ee7e 100644 --- a/frontend/src/core/components/fileManager/CompactFileDetails.tsx +++ b/frontend/src/core/components/fileManager/CompactFileDetails.tsx @@ -35,9 +35,14 @@ const CompactFileDetails: React.FC = ({ const hasSelection = selectedFiles.length > 0; const hasMultipleFiles = numberOfFiles > 1; const showOwner = Boolean( - currentFile && (currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink), + currentFile && + (currentFile.remoteOwnedByCurrentUser === false || + currentFile.remoteSharedViaLink), ); - const ownerLabel = currentFile ? currentFile.remoteOwnerUsername || t("fileManager.ownerUnknown", "Unknown") : ""; + const ownerLabel = currentFile + ? currentFile.remoteOwnerUsername || + t("fileManager.ownerUnknown", "Unknown") + : ""; return ( @@ -78,7 +83,9 @@ const CompactFileDetails: React.FC = ({ borderRadius: 4, }} > - + ) : null} @@ -86,7 +93,9 @@ const CompactFileDetails: React.FC = ({ {/* File info */} - {currentFile ? currentFile.name : "No file selected"} + + {currentFile ? currentFile.name : "No file selected"} + {currentFile ? getFileSize(currentFile) : ""} @@ -101,7 +110,9 @@ const CompactFileDetails: React.FC = ({ {/* Compact tool chain for mobile */} {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( - {currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(" → ")} + {currentFile.toolHistory + .map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)) + .join(" → ")} )} {currentFile && showOwner && ( @@ -114,10 +125,20 @@ const CompactFileDetails: React.FC = ({ {/* Navigation arrows for multiple files */} {hasMultipleFiles && ( - + - + @@ -131,7 +152,9 @@ const CompactFileDetails: React.FC = ({ disabled={!hasSelection} fullWidth style={{ - backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)", + backgroundColor: hasSelection + ? "var(--btn-open-file)" + : "var(--mantine-color-gray-4)", color: "white", }} > diff --git a/frontend/src/core/components/fileManager/DesktopLayout.tsx b/frontend/src/core/components/fileManager/DesktopLayout.tsx index 057b70c02..46376421f 100644 --- a/frontend/src/core/components/fileManager/DesktopLayout.tsx +++ b/frontend/src/core/components/fileManager/DesktopLayout.tsx @@ -12,7 +12,12 @@ const DesktopLayout: React.FC = () => { const { activeSource, recentFiles, modalHeight } = useFileManagerContext(); return ( - + {/* Column 1: File Sources */} {
0 ? `calc(${modalHeight} - 7rem)` : "100%"} + scrollAreaHeight={ + activeSource === "recent" && recentFiles.length > 0 + ? `calc(${modalHeight} - 7rem)` + : "100%" + } scrollAreaStyle={{ - height: activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%", + height: + activeSource === "recent" && recentFiles.length > 0 + ? `calc(${modalHeight} - 7rem)` + : "100%", backgroundColor: "transparent", border: "none", borderRadius: 0, diff --git a/frontend/src/core/components/fileManager/DragOverlay.tsx b/frontend/src/core/components/fileManager/DragOverlay.tsx index de3409d90..023bb59d1 100644 --- a/frontend/src/core/components/fileManager/DragOverlay.tsx +++ b/frontend/src/core/components/fileManager/DragOverlay.tsx @@ -32,7 +32,9 @@ const DragOverlay: React.FC = ({ isVisible }) => { }} > - + {t("fileManager.dropFilesHere", "Drop files here to upload")} diff --git a/frontend/src/core/components/fileManager/EmptyFilesState.tsx b/frontend/src/core/components/fileManager/EmptyFilesState.tsx index b774f0604..f1a892239 100644 --- a/frontend/src/core/components/fileManager/EmptyFilesState.tsx +++ b/frontend/src/core/components/fileManager/EmptyFilesState.tsx @@ -1,5 +1,11 @@ import React, { useState } from "react"; -import { Button, Group, Text, Stack, useMantineColorScheme } from "@mantine/core"; +import { + Button, + Group, + Text, + Stack, + useMantineColorScheme, +} from "@mantine/core"; import HistoryIcon from "@mui/icons-material/History"; import { useTranslation } from "react-i18next"; import { useFileManagerContext } from "@app/contexts/FileManagerContext"; @@ -49,7 +55,9 @@ const EmptyFilesState: React.FC = () => { > {/* No Recent Files Message */} - + {t("fileManager.noRecentFiles", "No recent files")} @@ -91,7 +99,8 @@ const EmptyFilesState: React.FC = () => { display: "flex", alignItems: "center", justifyContent: "center", - transition: "width .5s ease, padding .5s ease, border-radius .5s ease", + transition: + "width .5s ease, padding .5s ease, border-radius .5s ease", }} onClick={handleUploadClick} onMouseEnter={() => setIsUploadHover(true)} @@ -102,12 +111,19 @@ const EmptyFilesState: React.FC = () => { height="1.25rem" style={{ color: "var(--accent-interactive)" }} /> - {isUploadHover && {terminology.uploadFromComputer}} + {isUploadHover && ( + + {terminology.uploadFromComputer} + + )}
{/* Instruction Text */} - + {terminology.dropFilesHere}
diff --git a/frontend/src/core/components/fileManager/FileActions.tsx b/frontend/src/core/components/fileManager/FileActions.tsx index e39113fe6..e228bc945 100644 --- a/frontend/src/core/components/fileManager/FileActions.tsx +++ b/frontend/src/core/components/fileManager/FileActions.tsx @@ -1,5 +1,11 @@ import React, { useEffect } from "react"; -import { Group, Text, ActionIcon, Tooltip, SegmentedControl } from "@mantine/core"; +import { + Group, + Text, + ActionIcon, + Tooltip, + SegmentedControl, +} from "@mantine/core"; import SelectAllIcon from "@mui/icons-material/SelectAll"; import DeleteIcon from "@mui/icons-material/Delete"; import CloudUploadIcon from "@mui/icons-material/CloudUpload"; @@ -33,33 +39,51 @@ const FileActions: React.FC = () => { onStorageFilterChange, } = useFileManagerContext(); const uploadEnabled = config?.storageEnabled === true; - const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; - const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; + const sharingEnabled = + uploadEnabled && config?.storageSharingEnabled === true; + const shareLinksEnabled = + sharingEnabled && config?.storageShareLinksEnabled === true; const showStorageFilter = uploadEnabled; const storageFilterOptions = sharingEnabled ? [ { value: "all", label: t("fileManager.filterAll", "All") }, { value: "local", label: t("fileManager.filterLocal", "Local") }, - { value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") }, - { value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") }, + { + value: "sharedWithMe", + label: t("fileManager.filterSharedWithMe", "Shared with me"), + }, + { + value: "sharedByMe", + label: t("fileManager.filterSharedByMe", "Shared by me"), + }, ] : [ { value: "all", label: t("fileManager.filterAll", "All") }, { value: "local", label: t("fileManager.filterLocal", "Local") }, ]; useEffect(() => { - if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) { + if ( + !sharingEnabled && + (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe") + ) { onStorageFilterChange("all"); } }, [sharingEnabled, storageFilter, onStorageFilterChange]); const hasSelection = selectedFileIds.length > 0; - const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false); + const hasOnlyOwnedSelection = selectedFiles.every( + (file) => file.remoteOwnedByCurrentUser !== false, + ); const hasDownloadAccess = selectedFiles.every((file) => { - const role = (file.remoteOwnedByCurrentUser !== false ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase(); + const role = ( + file.remoteOwnedByCurrentUser !== false + ? "editor" + : (file.remoteAccessRole ?? "viewer") + ).toLowerCase(); return role === "editor" || role === "commenter" || role === "viewer"; }); const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection; - const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection; + const canBulkShare = + shareLinksEnabled && hasSelection && hasOnlyOwnedSelection; const handleSelectAll = () => { onSelectAll(); @@ -82,7 +106,8 @@ const FileActions: React.FC = () => { return null; } - const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; + const allFilesSelected = + filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; return (
{ {/* Left: Select All + Filter */} { onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")} + onChange={(value) => + onStorageFilterChange( + value as "all" | "local" | "sharedWithMe" | "sharedByMe", + ) + } data={storageFilterOptions} /> )} @@ -132,7 +165,9 @@ const FileActions: React.FC = () => { > {hasSelection && ( - {t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })} + {t("fileManager.selectedCount", "{{count}} selected", { + count: selectedFileIds.length, + })} )}
diff --git a/frontend/src/core/components/fileManager/FileDetails.tsx b/frontend/src/core/components/fileManager/FileDetails.tsx index 969b98e69..dd6b3e714 100644 --- a/frontend/src/core/components/fileManager/FileDetails.tsx +++ b/frontend/src/core/components/fileManager/FileDetails.tsx @@ -18,7 +18,8 @@ const FileDetails: React.FC = ({ compact = false }) => { const [isAnimating, setIsAnimating] = useState(false); // Get the currently displayed file - const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null; + const currentFile = + selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null; const hasSelection = selectedFiles.length > 0; // Use IndexedDB hook for the current file @@ -33,7 +34,9 @@ const FileDetails: React.FC = ({ compact = false }) => { if (isAnimating) return; setIsAnimating(true); setTimeout(() => { - setCurrentFileIndex((prev) => (prev > 0 ? prev - 1 : selectedFiles.length - 1)); + setCurrentFileIndex((prev) => + prev > 0 ? prev - 1 : selectedFiles.length - 1, + ); setIsAnimating(false); }, 150); }; @@ -42,7 +45,9 @@ const FileDetails: React.FC = ({ compact = false }) => { if (isAnimating) return; setIsAnimating(true); setTimeout(() => { - setCurrentFileIndex((prev) => (prev < selectedFiles.length - 1 ? prev + 1 : 0)); + setCurrentFileIndex((prev) => + prev < selectedFiles.length - 1 ? prev + 1 : 0, + ); setIsAnimating(false); }, 150); }; @@ -73,7 +78,14 @@ const FileDetails: React.FC = ({ compact = false }) => { return ( {/* Section 1: Thumbnail Preview */} - + = ({ compact = false }) => { disabled={!hasSelection} fullWidth style={{ - backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)", + backgroundColor: hasSelection + ? "var(--btn-open-file)" + : "var(--mantine-color-gray-4)", color: "white", }} > diff --git a/frontend/src/core/components/fileManager/FileHistoryGroup.tsx b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx index 58edbc46c..cda54edf7 100644 --- a/frontend/src/core/components/fileManager/FileHistoryGroup.tsx +++ b/frontend/src/core/components/fileManager/FileHistoryGroup.tsx @@ -39,7 +39,8 @@ const FileHistoryGroup: React.FC = ({ - {t("fileManager.fileHistory", "File History")} ({sortedHistory.length}) + {t("fileManager.fileHistory", "File History")} ( + {sortedHistory.length}) diff --git a/frontend/src/core/components/fileManager/FileInfoCard.tsx b/frontend/src/core/components/fileManager/FileInfoCard.tsx index 1c47c71bd..411e05e96 100644 --- a/frontend/src/core/components/fileManager/FileInfoCard.tsx +++ b/frontend/src/core/components/fileManager/FileInfoCard.tsx @@ -1,5 +1,15 @@ import React, { useMemo, useState } from "react"; -import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from "@mantine/core"; +import { + Stack, + Card, + Box, + Text, + Badge, + Group, + Divider, + ScrollArea, + Button, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; import { detectFileExtension, getFileSize } from "@app/utils/fileUtils"; import { StirlingFileStub } from "@app/types/fileContext"; @@ -14,31 +24,43 @@ interface FileInfoCardProps { modalHeight: string; } -const FileInfoCard: React.FC = ({ currentFile, modalHeight }) => { +const FileInfoCard: React.FC = ({ + currentFile, + modalHeight, +}) => { const { t } = useTranslation(); const { config } = useAppConfig(); const { onMakeCopy } = useFileManagerContext(); const [showShareManageModal, setShowShareManageModal] = useState(false); const isSharedWithYou = useMemo(() => { if (!currentFile) return false; - return currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink; + return ( + currentFile.remoteOwnedByCurrentUser === false || + currentFile.remoteSharedViaLink + ); }, [currentFile]); const isOwnedRemote = useMemo(() => { if (!currentFile) return false; - return Boolean(currentFile.remoteStorageId) && currentFile.remoteOwnedByCurrentUser !== false; + return ( + Boolean(currentFile.remoteStorageId) && + currentFile.remoteOwnedByCurrentUser !== false + ); }, [currentFile]); - const localUpdatedAt = currentFile?.createdAt ?? currentFile?.lastModified ?? 0; + const localUpdatedAt = + currentFile?.createdAt ?? currentFile?.lastModified ?? 0; const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0; const isUploaded = Boolean(currentFile?.remoteStorageId); const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote; - const isLocalOnly = !currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink; + const isLocalOnly = + !currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink; const isSharedByYou = useMemo(() => { if (!currentFile) return false; return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks); }, [currentFile, isOwnedRemote]); const uploadEnabled = config?.storageEnabled === true; - const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; + const sharingEnabled = + uploadEnabled && config?.storageSharingEnabled === true; const ownerLabel = useMemo(() => { if (!currentFile) return ""; if (currentFile.remoteOwnerUsername) { @@ -56,7 +78,12 @@ const FileInfoCard: React.FC = ({ currentFile, modalHeight }) withBorder p={0} mah={`calc(${modalHeight} * 0.45)`} - style={{ overflow: "hidden", flexShrink: 1, display: "flex", flexDirection: "column" }} + style={{ + overflow: "hidden", + flexShrink: 1, + display: "flex", + flexDirection: "column", + }} > = ({ currentFile, modalHeight }) - {t("fileManager.fileName", "Name")} + + {t("fileManager.fileName", "Name")} + - + {currentFile ? currentFile.name : ""} @@ -112,7 +146,9 @@ const FileInfoCard: React.FC = ({ currentFile, modalHeight }) {t("fileManager.lastModified", "Last modified")} - {currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ""} + {currentFile + ? new Date(currentFile.lastModified).toLocaleDateString() + : ""} @@ -122,7 +158,11 @@ const FileInfoCard: React.FC = ({ currentFile, modalHeight }) {t("fileManager.fileVersion", "Version")} {currentFile && ( - + v{currentFile ? currentFile.versionNumber || 1 : ""} )} @@ -154,7 +194,11 @@ const FileInfoCard: React.FC = ({ currentFile, modalHeight }) {t("fileManager.toolChain", "Tools Applied")} - + )} @@ -162,7 +206,12 @@ const FileInfoCard: React.FC = ({ currentFile, modalHeight }) {currentFile && isSharedWithYou && ( <> - @@ -177,7 +226,10 @@ const FileInfoCard: React.FC = ({ currentFile, modalHeight }) {uploadEnabled && isOutOfSync ? ( - {t("fileManager.changesNotUploaded", "Changes not uploaded")} + {t( + "fileManager.changesNotUploaded", + "Changes not uploaded", + )} ) : uploadEnabled ? ( @@ -206,7 +258,12 @@ const FileInfoCard: React.FC = ({ currentFile, modalHeight }) {t("fileManager.sharedByYou", "Shared by you")} - diff --git a/frontend/src/core/components/fileManager/FileListArea.tsx b/frontend/src/core/components/fileManager/FileListArea.tsx index b088a55d5..467ac398e 100644 --- a/frontend/src/core/components/fileManager/FileListArea.tsx +++ b/frontend/src/core/components/fileManager/FileListArea.tsx @@ -12,7 +12,10 @@ interface FileListAreaProps { scrollAreaStyle?: React.CSSProperties; } -const FileListArea: React.FC = ({ scrollAreaHeight, scrollAreaStyle = {} }) => { +const FileListArea: React.FC = ({ + scrollAreaHeight, + scrollAreaStyle = {}, +}) => { const { activeSource, recentFiles, @@ -94,9 +97,14 @@ const FileListArea: React.FC = ({ scrollAreaHeight, scrollAre return (
- + - {t("fileManager.googleDriveNotAvailable", "Google Drive integration coming soon")} + {t( + "fileManager.googleDriveNotAvailable", + "Google Drive integration coming soon", + )}
diff --git a/frontend/src/core/components/fileManager/FileListItem.tsx b/frontend/src/core/components/fileManager/FileListItem.tsx index 02c3c07f7..d8c7cb5dd 100644 --- a/frontend/src/core/components/fileManager/FileListItem.tsx +++ b/frontend/src/core/components/fileManager/FileListItem.tsx @@ -1,5 +1,14 @@ import React, { useCallback, useMemo, useState } from "react"; -import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from "@mantine/core"; +import { + Group, + Box, + Text, + ActionIcon, + Checkbox, + Divider, + Menu, + Badge, +} from "@mantine/core"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import DeleteIcon from "@mui/icons-material/Delete"; import DownloadIcon from "@mui/icons-material/Download"; @@ -60,14 +69,21 @@ const FileListItem: React.FC = ({ const [showShareManageModal, setShowShareManageModal] = useState(false); const { t } = useTranslation(); const { config } = useAppConfig(); - const { expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext(); + const { + expandedFileIds, + onToggleExpansion, + onUnzipFile, + refreshRecentFiles, + } = useFileManagerContext(); const { removeFiles } = useFileManagement(); // Check if this is a ZIP file const isZipFile = zipFileService.isZipFileStub(file); // Check file extension - const extLower = (file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || "").toLowerCase(); + const extLower = ( + file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || "" + ).toLowerCase(); const isCBZ = extLower === "cbz"; const isCBR = extLower === "cbr"; @@ -75,33 +91,55 @@ const FileListItem: React.FC = ({ const shouldShowHovered = isHovered || isMenuOpen; // Get version information for this file - const leafFileId = (isLatestVersion ? file.id : file.originalFileId || file.id) as FileId; + const leafFileId = ( + isLatestVersion ? file.id : file.originalFileId || file.id + ) as FileId; const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+) const currentVersion = file.versionNumber || 1; // Display original files as v1 const isExpanded = expandedFileIds.has(leafFileId); const uploadEnabled = config?.storageEnabled === true; - const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; - const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; + const sharingEnabled = + uploadEnabled && config?.storageSharingEnabled === true; + const shareLinksEnabled = + sharingEnabled && config?.storageShareLinksEnabled === true; const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false; - const isSharedWithYou = sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink); + const isSharedWithYou = + sharingEnabled && + (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink); const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0; const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0; const isUploaded = Boolean(file.remoteStorageId); const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal; const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink; - const accessRole = (isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase(); - const hasReadAccess = isOwnedOrLocal || accessRole === "editor" || accessRole === "commenter" || accessRole === "viewer"; - const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate); + const accessRole = ( + isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer") + ).toLowerCase(); + const hasReadAccess = + isOwnedOrLocal || + accessRole === "editor" || + accessRole === "commenter" || + accessRole === "viewer"; + const canUpload = + uploadEnabled && + isOwnedOrLocal && + isLatestVersion && + (!isUploaded || !isUpToDate); const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion; - const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId); - const canCopyShareLink = shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId); + const canManageShare = + sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId); + const canCopyShareLink = + shareLinksEnabled && + Boolean(file.remoteHasShareLinks) && + Boolean(file.remoteStorageId); const canDownloadFile = Boolean(onDownload) && hasReadAccess; const shareBaseUrl = useMemo(() => { const frontendUrl = (config?.frontendUrl || "").trim(); if (frontendUrl) { - const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl; + const normalized = frontendUrl.endsWith("/") + ? frontendUrl.slice(0, -1) + : frontendUrl; return `${normalized}/share/`; } return absoluteWithBasePath("/share/"); @@ -110,10 +148,11 @@ const FileListItem: React.FC = ({ const handleCopyShareLink = useCallback(async () => { if (!file.remoteStorageId) return; try { - const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>( - `/api/v1/storage/files/${file.remoteStorageId}`, - { suppressErrorToast: true } as any, - ); + const response = await apiClient.get<{ + shareLinks?: Array<{ token?: string }>; + }>(`/api/v1/storage/files/${file.remoteStorageId}`, { + suppressErrorToast: true, + } as any); const links = response.data?.shareLinks ?? []; const token = links[links.length - 1]?.token; if (!token) { @@ -163,9 +202,13 @@ const FileListItem: React.FC = ({ MozUserSelect: "none", msUserSelect: "none", paddingLeft: isHistoryFile ? "2rem" : "0.75rem", // Indent history files - borderLeft: isHistoryFile ? "3px solid var(--mantine-color-blue-4)" : "none", // Visual indicator for history + borderLeft: isHistoryFile + ? "3px solid var(--mantine-color-blue-4)" + : "none", // Visual indicator for history }} - onClick={isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)} + onClick={ + isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey) + } onDoubleClick={onDoubleClick} onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -217,7 +260,10 @@ const FileListItem: React.FC = ({ {t("fileManager.sharedWithYou", "Shared with you")} ) : null} - {sharingEnabled && isSharedWithYou && accessRole && accessRole !== "editor" ? ( + {sharingEnabled && + isSharedWithYou && + accessRole && + accessRole !== "editor" ? ( {accessRole === "commenter" ? t("storageShare.roleCommenter", "Commenter") @@ -228,19 +274,31 @@ const FileListItem: React.FC = ({ {t("fileManager.localOnly", "Local only")} ) : uploadEnabled && isOutOfSync ? ( - }> + } + > {t("fileManager.changesNotUploaded", "Changes not uploaded")} ) : uploadEnabled && isUploaded ? ( - }> + } + > {t("fileManager.synced", "Synced")} ) : null} - {sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && ( - - {t("fileManager.sharedByYou", "Shared by you")} - - )} + {sharingEnabled && + file.remoteOwnedByCurrentUser !== false && + file.remoteHasShareLinks && ( + + {t("fileManager.sharedByYou", "Shared by you")} + + )} @@ -249,7 +307,12 @@ const FileListItem: React.FC = ({ {/* Tool chain for processed files */} {file.toolHistory && file.toolHistory.length > 0 && ( - + )}
@@ -368,7 +431,9 @@ const FileListItem: React.FC = ({ onToggleExpansion(leafFileId); }} > - {isExpanded ? t("fileManager.hideHistory", "Hide History") : t("fileManager.showHistory", "Show History")} + {isExpanded + ? t("fileManager.hideHistory", "Hide History") + : t("fileManager.showHistory", "Show History")} @@ -436,7 +501,11 @@ const FileListItem: React.FC = ({ /> )} {canManageShare && ( - setShowShareManageModal(false)} file={file} /> + setShowShareManageModal(false)} + file={file} + /> )} ); diff --git a/frontend/src/core/components/fileManager/FileSourceButtons.tsx b/frontend/src/core/components/fileManager/FileSourceButtons.tsx index 51076ee95..f6551132c 100644 --- a/frontend/src/core/components/fileManager/FileSourceButtons.tsx +++ b/frontend/src/core/components/fileManager/FileSourceButtons.tsx @@ -32,10 +32,19 @@ const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => ( /> ); -const FileSourceButtons: React.FC = ({ horizontal = false }) => { - const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext(); +const FileSourceButtons: React.FC = ({ + horizontal = false, +}) => { + const { + activeSource, + onSourceChange, + onLocalFileClick, + onGoogleDriveSelect, + onNewFilesSelect, + } = useFileManagerContext(); const { t } = useTranslation(); - const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker(); + const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = + useGoogleDrivePicker(); const terminology = useFileActionTerminology(); const icons = useFileActionIcons(); const UploadIcon = icons.upload; @@ -66,21 +75,29 @@ const FileSourceButtons: React.FC = ({ horizontal = fals }; // Determine visibility of Google Drive button - const shouldHideGoogleDrive = !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive; + const shouldHideGoogleDrive = + !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive; // Determine visibility of Mobile QR Scanner button - const shouldHideMobileQR = !isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner; + const shouldHideMobileQR = + !isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner; const buttonProps = { - variant: (source: string) => (activeSource === source ? "filled" : "subtle"), - getColor: (source: string) => (activeSource === source ? "var(--mantine-color-gray-2)" : undefined), + variant: (source: string) => + activeSource === source ? "filled" : "subtle", + getColor: (source: string) => + activeSource === source ? "var(--mantine-color-gray-2)" : undefined, getStyles: (source: string) => ({ root: { backgroundColor: activeSource === source ? undefined : "transparent", - color: activeSource === source ? "var(--mantine-color-gray-9)" : "var(--mantine-color-gray-6)", + color: + activeSource === source + ? "var(--mantine-color-gray-9)" + : "var(--mantine-color-gray-6)", border: "none", "&:hover": { - backgroundColor: activeSource === source ? undefined : "var(--mantine-color-gray-0)", + backgroundColor: + activeSource === source ? undefined : "var(--mantine-color-gray-0)", }, }, }), @@ -97,7 +114,9 @@ const FileSourceButtons: React.FC = ({ horizontal = fals color={buttonProps.getColor("recent")} styles={buttonProps.getStyles("recent")} > - {horizontal ? t("fileManager.recent", "Recent") : t("fileManager.recent", "Recent")} + {horizontal + ? t("fileManager.recent", "Recent") + : t("fileManager.recent", "Recent")} )} @@ -165,13 +191,24 @@ const FileSourceButtons: React.FC = ({ horizontal = fals backgroundColor: "transparent", border: "none", "&:hover": { - backgroundColor: isMobileUploadEnabled ? "var(--mantine-color-gray-0)" : "transparent", + backgroundColor: isMobileUploadEnabled + ? "var(--mantine-color-gray-0)" + : "transparent", }, }, }} - title={!isMobileUploadEnabled ? t("fileManager.mobileUploadNotAvailable", "Mobile upload not available") : undefined} + title={ + !isMobileUploadEnabled + ? t( + "fileManager.mobileUploadNotAvailable", + "Mobile upload not available", + ) + : undefined + } > - {horizontal ? t("fileManager.mobileShort", "Mobile") : t("fileManager.mobileUpload", "Mobile Upload")} + {horizontal + ? t("fileManager.mobileShort", "Mobile") + : t("fileManager.mobileUpload", "Mobile Upload")} )} @@ -195,7 +232,14 @@ const FileSourceButtons: React.FC = ({ horizontal = fals return ( <> - + {t("fileManager.myFiles", "My Files")} {buttons} diff --git a/frontend/src/core/components/fileManager/MobileLayout.tsx b/frontend/src/core/components/fileManager/MobileLayout.tsx index 759fb2c7f..2545a9d60 100644 --- a/frontend/src/core/components/fileManager/MobileLayout.tsx +++ b/frontend/src/core/components/fileManager/MobileLayout.tsx @@ -27,7 +27,11 @@ const MobileLayout: React.FC = () => { }; return ( - + {/* Section 1: File Sources - Fixed at top */} diff --git a/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx b/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx index 357b4a2f4..514149998 100644 --- a/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx +++ b/frontend/src/core/components/hotkeys/HotkeyDisplay.tsx @@ -23,7 +23,11 @@ const baseKeyStyle: React.CSSProperties = { color: "var(--mantine-color-text)", }; -export const HotkeyDisplay: React.FC = ({ binding, size = "sm", muted = false }) => { +export const HotkeyDisplay: React.FC = ({ + binding, + size = "sm", + muted = false, +}) => { const { getDisplayParts } = useHotkeys(); const parts = getDisplayParts(binding); @@ -31,7 +35,10 @@ export const HotkeyDisplay: React.FC = ({ binding, size = "s return null; } - const keyStyle = size === "md" ? { ...baseKeyStyle, fontSize: "0.85rem", padding: "0.2rem 0.5rem" } : baseKeyStyle; + const keyStyle = + size === "md" + ? { ...baseKeyStyle, fontSize: "0.85rem", padding: "0.2rem 0.5rem" } + : baseKeyStyle; return ( view.workbenchId === currentView && view.data != null); + const customView = customWorkbenchViews.find( + (view) => view.workbenchId === currentView && view.data != null, + ); if (customView) { const CustomComponent = customView.component; return ; @@ -148,7 +154,15 @@ export default function Workbench() {
{pageEditorFunctions && ( -
+
{/* Top Controls */} - {activeFiles.length > 0 && !customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && ( - { - const stub = selectors.getStirlingFileStub(f.fileId); - return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber }; - })} - currentFileIndex={activeFileIndex} - onFileSelect={handleFileSelect} - onFileRemove={handleFileRemove} - /> - )} + {activeFiles.length > 0 && + !customWorkbenchViews.find((v) => v.workbenchId === currentView) + ?.hideTopControls && ( + { + const stub = selectors.getStirlingFileStub(f.fileId); + return { + fileId: f.fileId, + name: f.name, + versionNumber: stub?.versionNumber, + }; + })} + currentFileIndex={activeFileIndex} + onFileSelect={handleFileSelect} + onFileRemove={handleFileRemove} + /> + )} {/* Dismiss All Errors Button */} diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx index e0a71a3e6..fc7914db5 100644 --- a/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/renderButtons.tsx @@ -2,7 +2,10 @@ import React from "react"; import { Button, Group, ActionIcon } from "@mantine/core"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import { useTranslation } from "react-i18next"; -import { ButtonDefinition, type FlowState } from "@app/components/onboarding/onboardingFlowConfig"; +import { + ButtonDefinition, + type FlowState, +} from "@app/components/onboarding/onboardingFlowConfig"; import type { LicenseNotice } from "@app/types/types"; import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig"; @@ -16,10 +19,19 @@ interface SlideButtonsProps { onAction: (action: ButtonAction) => void; } -export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) { +export function SlideButtons({ + slideDefinition, + licenseNotice, + flowState, + onAction, +}: SlideButtonsProps) { const { t } = useTranslation(); - const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left"); - const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right"); + const leftButtons = slideDefinition.buttons.filter( + (btn) => btn.group === "left", + ); + const rightButtons = slideDefinition.buttons.filter( + (btn) => btn.group === "right", + ); const buttonStyles = (variant: ButtonDefinition["variant"]) => variant === "primary" @@ -76,7 +88,9 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi }, }} > - {button.icon === "chevron-left" && } + {button.icon === "chevron-left" && ( + + )} ); } @@ -85,7 +99,12 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi const label = resolveButtonLabel(button); return ( - ); diff --git a/frontend/src/core/components/onboarding/Onboarding.tsx b/frontend/src/core/components/onboarding/Onboarding.tsx index e62ae9ced..d215c0e73 100644 --- a/frontend/src/core/components/onboarding/Onboarding.tsx +++ b/frontend/src/core/components/onboarding/Onboarding.tsx @@ -6,11 +6,21 @@ import { isAuthRoute } from "@app/constants/routes"; import { dispatchTourState } from "@app/constants/events"; import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator"; import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding"; -import OnboardingTour, { type AdvanceArgs, type CloseArgs } from "@app/components/onboarding/OnboardingTour"; +import OnboardingTour, { + type AdvanceArgs, + type CloseArgs, +} from "@app/components/onboarding/OnboardingTour"; import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide"; -import { useServerLicenseRequest, useTourRequest } from "@app/components/onboarding/useOnboardingEffects"; +import { + useServerLicenseRequest, + useTourRequest, +} from "@app/components/onboarding/useOnboardingEffects"; import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload"; -import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from "@app/components/onboarding/onboardingFlowConfig"; +import { + SLIDE_DEFINITIONS, + type SlideId, + type ButtonAction, +} from "@app/components/onboarding/onboardingFlowConfig"; import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt"; import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; @@ -36,9 +46,18 @@ export default function Onboarding() { const onAuthRoute = isAuthRoute(location.pathname); const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state; - const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload(); - const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest(); - const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest(); + const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = + useOnboardingDownload(); + const { + showLicenseSlide, + licenseNotice: externalLicenseNotice, + closeLicenseSlide, + } = useServerLicenseRequest(); + const { + tourRequested: externalTourRequested, + requestedTourType, + clearTourRequest, + } = useTourRequest(); const { config, refetch: refetchConfig } = useAppConfig(); const [analyticsError, setAnalyticsError] = useState(null); const [analyticsLoading, setAnalyticsLoading] = useState(false); @@ -75,10 +94,20 @@ export default function Onboarding() { // Check if we should show analytics modal before onboarding useEffect(() => { - if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) { + if ( + !isLoading && + !analyticsModalDismissed && + serverExperience.effectiveIsAdmin && + config?.enableAnalytics == null + ) { setShowAnalyticsModal(true); } - }, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]); + }, [ + isLoading, + analyticsModalDismissed, + serverExperience.effectiveIsAdmin, + config?.enableAnalytics, + ]); const handleAnalyticsChoice = useCallback( async (enableAnalytics: boolean) => { @@ -90,12 +119,17 @@ export default function Onboarding() { formData.append("enabled", enableAnalytics.toString()); try { - await apiClient.post("/api/v1/settings/update-enable-analytics", formData); + await apiClient.post( + "/api/v1/settings/update-enable-analytics", + formData, + ); await refetchConfig(); setShowAnalyticsModal(false); setAnalyticsModalDismissed(true); } catch (error) { - setAnalyticsError(error instanceof Error ? error.message : "Unknown error"); + setAnalyticsError( + error instanceof Error ? error.message : "Unknown error", + ); } finally { setAnalyticsLoading(false); } @@ -137,7 +171,11 @@ export default function Onboarding() { setIsTourOpen(true); break; case "launch-auto": { - const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === "admin" ? "admin" : "whatsnew"; + const tourType = + serverExperience.effectiveIsAdmin || + runtimeState.selectedRole === "admin" + ? "admin" + : "whatsnew"; actions.updateRuntimeState({ tourType }); setIsTourOpen(true); break; @@ -170,7 +208,10 @@ export default function Onboarding() { ], ); - const isRTL = typeof document !== "undefined" ? document.documentElement.dir === "rtl" : false; + const isRTL = + typeof document !== "undefined" + ? document.documentElement.dir === "rtl" + : false; const [isTourOpen, setIsTourOpen] = useState(false); useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]); @@ -241,7 +282,12 @@ export default function Onboarding() { default: return Object.values(userStepsConfig); } - }, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]); + }, [ + adminStepsConfig, + runtimeState.tourType, + userStepsConfig, + whatsNewStepsConfig, + ]); useEffect(() => { if (externalTourRequested) { @@ -290,7 +336,12 @@ export default function Onboarding() { const handleAdvanceTour = useCallback( (args: AdvanceArgs) => { - const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args; + const { + setCurrentStep, + currentStep: tourCurrentStep, + steps, + setIsOpen, + } = args; if (steps && tourCurrentStep === steps.length - 1) { setIsOpen(false); finishTour(); @@ -310,7 +361,11 @@ export default function Onboarding() { ); const currentSlideDefinition = useMemo(() => { - if (!currentStep || currentStep.type !== "modal-slide" || !currentStep.slideId) { + if ( + !currentStep || + currentStep.type !== "modal-slide" || + !currentStep.slideId + ) { return null; } return SLIDE_DEFINITIONS[currentStep.slideId as SlideId]; @@ -356,7 +411,9 @@ export default function Onboarding() { const currentModalSlideIndex = useMemo(() => { if (!currentStep || currentStep.type !== "modal-slide") return 0; - const modalSlides = activeFlow.filter((step) => step.type === "modal-slide"); + const modalSlides = activeFlow.filter( + (step) => step.type === "modal-slide", + ); return modalSlides.findIndex((step) => step.id === currentStep.id); }, [activeFlow, currentStep]); @@ -464,9 +521,12 @@ export default function Onboarding() { // Remove back button for external license notice const slideDefinition = { ...baseSlideDefinition, - buttons: baseSlideDefinition.buttons.filter((btn) => btn.key !== "license-back"), + buttons: baseSlideDefinition.buttons.filter( + (btn) => btn.key !== "license-back", + ), }; - const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice; + const effectiveLicenseNotice = + externalLicenseNotice || runtimeState.licenseNotice; const slideContent = slideDefinition.createSlide({ osLabel: "", osUrl: "", @@ -482,7 +542,10 @@ export default function Onboarding() { ; + return ( + + ); case "modal-slide": if (!currentSlideDefinition || !currentSlideContent) return null; diff --git a/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx b/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx index 1c2fa9f1e..b1bc731d5 100644 --- a/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx +++ b/frontend/src/core/components/onboarding/OnboardingModalSlide.tsx @@ -10,7 +10,10 @@ import { Modal, Stack, ActionIcon } from "@mantine/core"; import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined"; import CloseIcon from "@mui/icons-material/Close"; -import type { SlideDefinition, ButtonAction } from "@app/components/onboarding/onboardingFlowConfig"; +import type { + SlideDefinition, + ButtonAction, +} from "@app/components/onboarding/onboardingFlowConfig"; import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig"; import type { SlideConfig } from "@app/types/types"; import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; @@ -47,7 +50,11 @@ export default function OnboardingModalSlide({ return (
- Stirling icon + Stirling icon
); @@ -56,20 +63,45 @@ export default function OnboardingModalSlide({ return (
{slideDefinition.hero.type === "rocket" && ( - + )} {slideDefinition.hero.type === "shield" && ( - + )} {slideDefinition.hero.type === "lock" && ( - + )} {slideDefinition.hero.type === "analytics" && ( - + + )} + {slideDefinition.hero.type === "diamond" && ( + )} - {slideDefinition.hero.type === "diamond" && } {slideDefinition.hero.type === "logo" && ( - Stirling logo + Stirling logo )}
); @@ -88,7 +120,12 @@ export default function OnboardingModalSlide({ zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} styles={{ body: { padding: 0, maxHeight: "90vh", overflow: "hidden" }, - content: { overflow: "hidden", border: "none", background: "var(--bg-surface)", maxHeight: "90vh" }, + content: { + overflow: "hidden", + border: "none", + background: "var(--bg-surface)", + maxHeight: "90vh", + }, }} > @@ -129,20 +166,34 @@ export default function OnboardingModalSlide({
-
+
-
+
{slideContent.title}
-
+
{slideContent.body}
- {modalSlideCount > 1 && } + {modalSlideCount > 1 && ( + + )}
index); return ( @@ -26,7 +30,9 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard {items.map((index) => { const isActive = index === activeStep; const baseStyles: React.CSSProperties = { - background: isActive ? "var(--onboarding-step-active)" : "var(--onboarding-step-inactive)", + background: isActive + ? "var(--onboarding-step-active)" + : "var(--onboarding-step-inactive)", }; return ( diff --git a/frontend/src/core/components/onboarding/OnboardingTour.tsx b/frontend/src/core/components/onboarding/OnboardingTour.tsx index 9ddb4e4c0..1f3ca6769 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/src/core/components/onboarding/OnboardingTour.tsx @@ -57,7 +57,15 @@ interface OnboardingTourProps { onClose: (args: CloseArgs) => void; } -export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, onAdvance, onClose }: OnboardingTourProps) { +export default function OnboardingTour({ + tourSteps, + tourType, + isRTL, + t, + isOpen, + onAdvance, + onClose, +}: OnboardingTourProps) { if (!isOpen) return null; return ( @@ -111,15 +119,31 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, disableInteraction={true} disableDotsNavigation={false} prevButton={() => null} - nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => { + nextButton={({ + currentStep: tourCurrentStep, + stepsLength, + setCurrentStep, + setIsOpen, + }) => { const isLast = tourCurrentStep === stepsLength - 1; const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon; return ( onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })} + onClick={() => + onAdvance({ + setCurrentStep, + currentStep: tourCurrentStep, + steps: tourSteps, + setIsOpen, + }) + } variant="subtle" size="lg" - aria-label={isLast ? t("onboarding.finish", "Finish") : t("onboarding.next", "Next")} + aria-label={ + isLast + ? t("onboarding.finish", "Finish") + : t("onboarding.next", "Next") + } > {isLast ? : } @@ -127,10 +151,17 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, }} components={{ Close: ({ onClick }) => ( - + ), Content: ({ content }: { content: string }) => ( -
+
), }} > diff --git a/frontend/src/core/components/onboarding/adminStepsConfig.ts b/frontend/src/core/components/onboarding/adminStepsConfig.ts index 7946c9fec..4d33abb57 100644 --- a/frontend/src/core/components/onboarding/adminStepsConfig.ts +++ b/frontend/src/core/components/onboarding/adminStepsConfig.ts @@ -1,6 +1,9 @@ import type { StepType } from "@reactour/tour"; import type { TFunction } from "i18next"; -import { addGlowToElements, removeAllGlows } from "@app/components/onboarding/tourGlow"; +import { + addGlowToElements, + removeAllGlows, +} from "@app/components/onboarding/tourGlow"; export enum AdminTourStep { WELCOME, @@ -26,8 +29,16 @@ interface CreateAdminStepsConfigArgs { actions: AdminStepActions; } -export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record { - const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions; +export function createAdminStepsConfig({ + t, + actions, +}: CreateAdminStepsConfigArgs): Record { + const { + saveAdminState, + openConfigModal, + navigateToSection, + scrollNavToSection, + } = actions; return { [AdminTourStep.WELCOME]: { @@ -120,7 +131,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg }, [AdminTourStep.DATABASE_SECTION]: { selector: '[data-tour="admin-adminDatabase-nav"]', - highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'], + highlightedSelectors: [ + '[data-tour="admin-adminDatabase-nav"]', + '[data-tour="settings-content-area"]', + ], content: t( "adminOnboarding.databaseSection", "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure.", @@ -131,13 +145,19 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg removeAllGlows(); navigateToSection("adminDatabase"); setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']); + addGlowToElements([ + '[data-tour="admin-adminDatabase-nav"]', + '[data-tour="settings-content-area"]', + ]); }, 100); }, }, [AdminTourStep.CONNECTIONS_SECTION]: { selector: '[data-tour="admin-adminConnections-nav"]', - highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'], + highlightedSelectors: [ + '[data-tour="admin-adminConnections-nav"]', + '[data-tour="settings-content-area"]', + ], content: t( "adminOnboarding.connectionsSection", "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.", @@ -148,7 +168,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg removeAllGlows(); navigateToSection("adminConnections"); setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']); + addGlowToElements([ + '[data-tour="admin-adminConnections-nav"]', + '[data-tour="settings-content-area"]', + ]); }, 100); }, actionAfter: async () => { diff --git a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts index c569954cd..5f5a9be40 100644 --- a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts +++ b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts @@ -20,7 +20,14 @@ export type SlideId = | "analytics-choice" | "mfa-setup"; -export type HeroType = "rocket" | "dual-icon" | "shield" | "diamond" | "logo" | "lock" | "analytics"; +export type HeroType = + | "rocket" + | "dual-icon" + | "shield" + | "diamond" + | "logo" + | "lock" + | "analytics"; export type ButtonAction = | "next" @@ -91,7 +98,11 @@ export interface SlideDefinition { export const SLIDE_DEFINITIONS: Record = { "first-login": { id: "first-login", - createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) => + createSlide: ({ + firstLoginUsername, + onPasswordChanged, + usingDefaultCredentials, + }) => FirstLoginSlide({ username: firstLoginUsername || "", onPasswordChanged: onPasswordChanged || (() => {}), @@ -148,7 +159,8 @@ export const SLIDE_DEFINITIONS: Record = { }, "security-check": { id: "security-check", - createSlide: ({ selectedRole, onRoleSelect }) => SecurityCheckSlide({ selectedRole, onRoleSelect }), + createSlide: ({ selectedRole, onRoleSelect }) => + SecurityCheckSlide({ selectedRole, onRoleSelect }), hero: { type: "shield" }, buttons: [ { @@ -172,7 +184,8 @@ export const SLIDE_DEFINITIONS: Record = { }, "admin-overview": { id: "admin-overview", - createSlide: ({ licenseNotice, loginEnabled }) => PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }), + createSlide: ({ licenseNotice, loginEnabled }) => + PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }), hero: { type: "diamond" }, buttons: [ { @@ -262,7 +275,8 @@ export const SLIDE_DEFINITIONS: Record = { }, "analytics-choice": { id: "analytics-choice", - createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }), + createSlide: ({ analyticsError }) => + AnalyticsChoiceSlide({ analyticsError }), hero: { type: "analytics" }, buttons: [ { @@ -285,7 +299,8 @@ export const SLIDE_DEFINITIONS: Record = { }, "mfa-setup": { id: "mfa-setup", - createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }), + createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => + MFASetupSlide({ onMfaSetupComplete }), hero: { type: "lock" }, buttons: [], // Form has its own submit button }, diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts index a4f06e4c7..6e10ec97f 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingConfig.ts @@ -115,13 +115,15 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [ id: "tour-overview", type: "modal-slide", slideId: "tour-overview", - condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp, + condition: (ctx) => + !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp, }, { id: "server-license", type: "modal-slide", slideId: "server-license", - condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense, + condition: (ctx) => + ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense, }, { id: "mfa-setup", diff --git a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts index 08a8049a1..73f45cb1e 100644 --- a/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts +++ b/frontend/src/core/components/onboarding/orchestrator/onboardingStorage.ts @@ -16,7 +16,10 @@ export function markOnboardingCompleted(): void { try { localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true"); } catch (error) { - console.error("[onboardingStorage] Error marking onboarding as completed:", error); + console.error( + "[onboardingStorage] Error marking onboarding as completed:", + error, + ); } } @@ -25,7 +28,10 @@ export function resetOnboardingProgress(): void { try { localStorage.removeItem(ONBOARDING_COMPLETED_KEY); } catch (error) { - console.error("[onboardingStorage] Error resetting onboarding progress:", error); + console.error( + "[onboardingStorage] Error resetting onboarding progress:", + error, + ); } } @@ -43,7 +49,10 @@ export function markToursTooltipShown(): void { try { localStorage.setItem(TOURS_TOOLTIP_KEY, "true"); } catch (error) { - console.error("[onboardingStorage] Error marking tours tooltip as shown:", error); + console.error( + "[onboardingStorage] Error marking tours tooltip as shown:", + error, + ); } } @@ -61,7 +70,10 @@ export function migrateFromLegacyPreferences(): void { const prefs = JSON.parse(prefsRaw) as Record; // If user had completed onboarding in old system, mark new system as complete - if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) { + if ( + prefs.hasCompletedOnboarding === true || + prefs.hasSeenIntroOnboarding === true + ) { markOnboardingCompleted(); } } diff --git a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts index 4fade3a3f..44052636d 100644 --- a/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts +++ b/frontend/src/core/components/onboarding/orchestrator/useOnboardingOrchestrator.ts @@ -31,19 +31,27 @@ function hasAuthToken(): boolean { } // Get initial runtime state from session storage (survives remounts) -function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState { +function getInitialRuntimeState( + baseState: OnboardingRuntimeState, +): OnboardingRuntimeState { if (typeof window === "undefined") { return baseState; } try { - const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true"; + const tourRequested = + sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true"; const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE); const tourType = - sessionTourType === "admin" || sessionTourType === "tools" || sessionTourType === "whatsnew" + sessionTourType === "admin" || + sessionTourType === "tools" || + sessionTourType === "whatsnew" ? sessionTourType : "whatsnew"; - const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as "admin" | "user" | null; + const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as + | "admin" + | "user" + | null; return { ...baseState, @@ -61,7 +69,10 @@ function persistRuntimeState(state: Partial): void { try { if (state.tourRequested !== undefined) { - sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? "true" : "false"); + sessionStorage.setItem( + SESSION_TOUR_REQUESTED, + state.tourRequested ? "true" : "false", + ); } if (state.tourType !== undefined) { sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType); @@ -74,7 +85,10 @@ function persistRuntimeState(state: Partial): void { } } } catch (error) { - console.error("[useOnboardingOrchestrator] Error persisting runtime state:", error); + console.error( + "[useOnboardingOrchestrator] Error persisting runtime state:", + error, + ); } } @@ -97,7 +111,10 @@ function parseMfaRequired(settings: string | null | undefined): boolean { const parsed = JSON.parse(settings) as { mfaRequired?: string }; return parsed.mfaRequired?.toLowerCase() === "true"; } catch (error) { - console.warn("[useOnboardingOrchestrator] Failed to parse account settings JSON:", error); + console.warn( + "[useOnboardingOrchestrator] Failed to parse account settings JSON:", + error, + ); return false; } } @@ -152,14 +169,18 @@ export interface UseOnboardingOrchestratorOptions { defaultRuntimeState?: OnboardingRuntimeState; } -export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOptions): UseOnboardingOrchestratorResult { +export function useOnboardingOrchestrator( + options?: UseOnboardingOrchestratorOptions, +): UseOnboardingOrchestratorResult { const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE; const serverExperience = useServerExperience(); const { config, loading: configLoading } = useAppConfig(); const location = useLocation(); const bypassOnboarding = useBypassOnboarding(); - const [runtimeState, setRuntimeState] = useState(() => getInitialRuntimeState(defaultState)); + const [runtimeState, setRuntimeState] = useState(() => + getInitialRuntimeState(defaultState), + ); const [isPaused, setIsPaused] = useState(false); const [isInitialized, setIsInitialized] = useState(false); const [currentStepIndex, setCurrentStepIndex] = useState(-1); @@ -186,7 +207,8 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt requiresLicense: !serverExperience.hasPaidLicense && (serverExperience.overFreeTierLimit === true || - (serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)), + (serverExperience.effectiveIsAdmin && + serverExperience.userCountResolved)), }, })); }, [ @@ -217,7 +239,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt requiresMfaSetup: parseMfaRequired(accountData.settings), })); } catch (error) { - console.log("[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:", error); + console.log( + "[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:", + error, + ); // Account endpoint failed - user not logged in or security disabled } }; @@ -227,17 +252,25 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt } }, [config?.enableLogin, configLoading]); - const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route)); + const isOnAuthRoute = AUTH_ROUTES.some((route) => + location.pathname.startsWith(route), + ); const loginEnabled = config?.enableLogin === true; const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken(); - const shouldBlockOnboarding = bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled; + const shouldBlockOnboarding = + bypassOnboarding || + isOnAuthRoute || + configLoading || + isUnauthenticatedWithLoginEnabled; const conditionContext = useMemo( () => ({ ...serverExperience, ...runtimeState, effectiveIsAdmin: - serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === "admin"), + serverExperience.effectiveIsAdmin || + (!serverExperience.loginEnabled && + runtimeState.selectedRole === "admin"), }), [serverExperience, runtimeState], ); @@ -248,7 +281,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt // Wait for config AND admin status before calculating initial step const adminStatusResolved = - !configLoading && (config?.enableLogin === false || config?.enableLogin === undefined || config?.isAdmin !== undefined); + !configLoading && + (config?.enableLogin === false || + config?.enableLogin === undefined || + config?.isAdmin !== undefined); useEffect(() => { if (configLoading || !adminStatusResolved) return; @@ -276,9 +312,21 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt const totalSteps = activeFlow.length; - const isComplete = isInitialized && (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted()); - const currentStep = currentStepIndex >= 0 && currentStepIndex < totalSteps ? activeFlow[currentStepIndex] : null; - const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null; + const isComplete = + isInitialized && + (totalSteps === 0 || + currentStepIndex >= totalSteps || + isOnboardingCompleted()); + const currentStep = + currentStepIndex >= 0 && currentStepIndex < totalSteps + ? activeFlow[currentStepIndex] + : null; + const isActive = + !shouldBlockOnboarding && + !isPaused && + !isComplete && + isInitialized && + currentStep !== null; const isLoading = configLoading || !adminStatusResolved || @@ -322,10 +370,13 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt setCurrentStepIndex(nextIndex); }, [currentStepIndex, totalSteps]); - const updateRuntimeState = useCallback((updates: Partial) => { - persistRuntimeState(updates); - setRuntimeState((prev) => ({ ...prev, ...updates })); - }, []); + const updateRuntimeState = useCallback( + (updates: Partial) => { + persistRuntimeState(updates); + setRuntimeState((prev) => ({ ...prev, ...updates })); + }, + [], + ); const refreshFlow = useCallback(() => { initialIndexSet.current = false; diff --git a/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx b/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx index 9521933cb..6960b88c2 100644 --- a/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/AnalyticsChoiceSlide.tsx @@ -11,10 +11,15 @@ interface AnalyticsChoiceSlideProps { analyticsError?: string | null; } -export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig { +export default function AnalyticsChoiceSlide({ + analyticsError, +}: AnalyticsChoiceSlideProps): SlideConfig { return { key: "analytics-choice", - title: i18n.t("analytics.title", "Do you want to help make Stirling PDF better?"), + title: i18n.t( + "analytics.title", + "Do you want to help make Stirling PDF better?", + ), body: (
window.open("https://docs.stirlingpdf.com/analytics-telemetry/", "_blank")} + onClick={() => + window.open( + "https://docs.stirlingpdf.com/analytics-telemetry/", + "_blank", + ) + } rightSection={} > {i18n.t("analytics.learnMore", "Learn more about our analytics")}
- {analyticsError &&
{analyticsError}
} + {analyticsError && ( +
+ {analyticsError} +
+ )}
), background: { diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css index 5e5799ec2..e27870395 100644 --- a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css +++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css @@ -65,6 +65,10 @@ transform: translate3d(0, 0, 0); } 100% { - transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0); + transform: translate3d( + var(--circle-move-x, 40px), + var(--circle-move-y, 24px), + 0 + ); } } diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx index 11bd671c5..56cbb0dd1 100644 --- a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx +++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx @@ -14,9 +14,16 @@ interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundP slideKey: string; } -export default function AnimatedSlideBackground({ gradientStops, circles, isActive }: AnimatedSlideBackgroundComponentProps) { - const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null); - const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops); +export default function AnimatedSlideBackground({ + gradientStops, + circles, + isActive, +}: AnimatedSlideBackgroundComponentProps) { + const [prevGradient, setPrevGradient] = React.useState< + [string, string] | null + >(null); + const [currentGradient, setCurrentGradient] = + React.useState<[string, string]>(gradientStops); const [isTransitioning, setIsTransitioning] = React.useState(false); const isFirstMount = React.useRef(true); @@ -29,7 +36,10 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi } // Only transition if gradient actually changed - if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) { + if ( + currentGradient[0] !== gradientStops[0] || + currentGradient[1] !== gradientStops[1] + ) { // Store previous gradient and start transition setPrevGradient(currentGradient); setIsTransitioning(true); @@ -69,10 +79,20 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi style={currentGradientStyle} /> {circles.map((circle, index) => { - const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle; + const { + position, + size, + color, + opacity, + blur, + amplitude = 48, + duration = 15, + delay = 0, + } = circle; const moveX = position === "bottom-left" ? amplitude : -amplitude; - const moveY = position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6; + const moveY = + position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6; const circleStyle: CircleStyles = { width: size, @@ -98,7 +118,13 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi circleStyle.top = `${defaultOffset + offsetY}px`; } - return
; + return ( +
+ ); })}
); diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx index 0ad0eebc2..eb3e74352 100644 --- a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx @@ -2,7 +2,10 @@ import React from "react"; import { useTranslation } from "react-i18next"; import { SlideConfig } from "@app/types/types"; import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; -import { DesktopInstallTitle, type OSOption } from "@app/components/onboarding/slides/DesktopInstallTitle"; +import { + DesktopInstallTitle, + type OSOption, +} from "@app/components/onboarding/slides/DesktopInstallTitle"; export type { OSOption }; diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx index b69b1b6b8..736277401 100644 --- a/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx +++ b/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx @@ -43,7 +43,9 @@ export const DesktopInstallTitle: React.FC = ({ const displayLabel = currentOsOption.label || osLabel; const title = displayLabel - ? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", { osLabel: displayLabel }) + ? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", { + osLabel: displayLabel, + }) : t("onboarding.desktopInstall.title", "Download"); // If only one option or no options, don't show dropdown @@ -52,7 +54,15 @@ export const DesktopInstallTitle: React.FC = ({ } return ( -
+
{title} @@ -80,7 +90,9 @@ export const DesktopInstallTitle: React.FC = ({ backgroundColor: isSelected ? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))" : "transparent", - color: isSelected ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" : "inherit", + color: isSelected + ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" + : "inherit", }} > {option.label} diff --git a/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx b/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx index df7d5ff54..4898c5a82 100644 --- a/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/FirstLoginSlide.tsx @@ -16,10 +16,16 @@ interface FirstLoginSlideProps { const DEFAULT_PASSWORD = "stirling"; -function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) { +function FirstLoginForm({ + username, + onPasswordChanged, + usingDefaultCredentials = false, +}: FirstLoginSlideProps) { const { t } = useTranslation(); // If using default credentials, pre-fill with "stirling" - user won't see this field - const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : ""); + const [currentPassword, setCurrentPassword] = useState( + usingDefaultCredentials ? DEFAULT_PASSWORD : "", + ); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); const [loading, setLoading] = useState(false); @@ -27,23 +33,39 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = const handleSubmit = async () => { // Validation - if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) { + if ( + (!usingDefaultCredentials && !currentPassword) || + !newPassword || + !confirmPassword + ) { setError(t("firstLogin.allFieldsRequired", "All fields are required")); return; } if (newPassword !== confirmPassword) { - setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match")); + setError( + t("firstLogin.passwordsDoNotMatch", "New passwords do not match"), + ); return; } if (newPassword.length < 8) { - setError(t("firstLogin.passwordTooShort", "Password must be at least 8 characters")); + setError( + t( + "firstLogin.passwordTooShort", + "Password must be at least 8 characters", + ), + ); return; } if (newPassword === currentPassword) { - setError(t("firstLogin.passwordMustBeDifferent", "New password must be different from current password")); + setError( + t( + "firstLogin.passwordMustBeDifferent", + "New password must be different from current password", + ), + ); return; } @@ -51,11 +73,18 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = setLoading(true); setError(""); - await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword); + await accountService.changePasswordOnLogin( + currentPassword, + newPassword, + confirmPassword, + ); showToast({ alertType: "success", - title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."), + title: t( + "firstLogin.passwordChangedSuccess", + "Password changed successfully! Please log in again.", + ), }); // Clear form @@ -73,7 +102,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = const axiosError = err as { response?: { data?: { message?: string } } }; setError( axiosError.response?.data?.message || - t("firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password."), + t( + "firstLogin.passwordChangeFailed", + "Failed to change password. Please check your current password.", + ), ); } finally { setLoading(false); @@ -85,18 +117,33 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
- + - {t("firstLogin.welcomeMessage", "For security reasons, you must change your password on your first login.")} + {t( + "firstLogin.welcomeMessage", + "For security reasons, you must change your password on your first login.", + )}
- {t("firstLogin.loggedInAs", "Logged in as")}: {username} + {t("firstLogin.loggedInAs", "Logged in as")}:{" "} + {username} {error && ( - } color="red" variant="light"> + + } + color="red" + variant="light" + > {error} )} @@ -105,7 +152,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = {!usingDefaultCredentials && ( setCurrentPassword(e.currentTarget.value)} required @@ -117,7 +167,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = setNewPassword(e.currentTarget.value)} minLength={8} @@ -129,7 +182,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = setConfirmPassword(e.currentTarget.value)} required @@ -143,7 +199,12 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = fullWidth onClick={handleSubmit} loading={loading} - disabled={!newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8} + disabled={ + !newPassword || + !confirmPassword || + newPassword.length < 8 || + confirmPassword.length < 8 + } size="md" mt="xs" > diff --git a/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx b/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx index b565af0f9..94bba3010 100644 --- a/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx +++ b/frontend/src/core/components/onboarding/slides/MFASetupSlide.tsx @@ -1,5 +1,20 @@ -import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; -import { Alert, Box, Button, Group, Loader, Stack, Text, TextInput } from "@mantine/core"; +import { + useCallback, + useEffect, + useRef, + useState, + type FormEvent, +} from "react"; +import { + Alert, + Box, + Button, + Group, + Loader, + Stack, + Text, + TextInput, +} from "@mantine/core"; import { QRCodeSVG } from "qrcode.react"; import { SlideConfig } from "@app/types/types"; import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; @@ -16,7 +31,9 @@ interface MFASetupSlideProps { } function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { - const [mfaSetupData, setMfaSetupData] = useState(null); + const [mfaSetupData, setMfaSetupData] = useState( + null, + ); const [mfaSetupCode, setMfaSetupCode] = useState(""); const [mfaError, setMfaError] = useState(""); const [mfaLoading, setMfaLoading] = useState(false); @@ -27,7 +44,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { const accountLogout = useAccountLogout(); const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`; - const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, "").slice(0, 6), []); + const normalizeMfaCode = useCallback( + (value: string) => value.replace(/\D/g, "").slice(0, 6), + [], + ); const fetchMfaSetup = useCallback(async () => { try { @@ -38,7 +58,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { setMfaSetupData(data); } catch (err) { const axiosError = err as { response?: { data?: { error?: string } } }; - setMfaError(axiosError.response?.data?.error || "Unable to start two-factor setup. Please try again."); + setMfaError( + axiosError.response?.data?.error || + "Unable to start two-factor setup. Please try again.", + ); } finally { setMfaLoading(false); } @@ -84,7 +107,8 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { } catch (err) { const axiosError = err as { response?: { data?: { error?: string } } }; setMfaError( - axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again.", + axiosError.response?.data?.error || + "Unable to enable two-factor authentication. Check the code and try again.", ); } finally { setSubmitting(false); @@ -137,12 +161,17 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
- Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the - 6-digit code to finish. + Secure your account by linking an authenticator app. Scan the QR + code or enter the setup key, then confirm the 6-digit code to + finish. {mfaError && ( - } color="red" variant="light"> + } + color="red" + variant="light" + > {mfaError} )} @@ -163,7 +192,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { label="Authentication code" placeholder="123456" value={mfaSetupCode} - onChange={(event) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))} + onChange={(event) => + setMfaSetupCode(normalizeMfaCode(event.currentTarget.value)) + } inputMode="numeric" maxLength={6} minLength={6} @@ -179,7 +210,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { > Regenerate QR code - diff --git a/frontend/src/core/components/pageEditor/PageEditor.tsx b/frontend/src/core/components/pageEditor/PageEditor.tsx index e567e258b..ea78c7a72 100644 --- a/frontend/src/core/components/pageEditor/PageEditor.tsx +++ b/frontend/src/core/components/pageEditor/PageEditor.tsx @@ -1,7 +1,10 @@ import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { useFileState, useFileActions } from "@app/contexts/FileContext"; -import { useNavigationGuard, useNavigationState } from "@app/contexts/NavigationContext"; +import { + useNavigationGuard, + useNavigationState, +} from "@app/contexts/NavigationContext"; import { usePageEditor } from "@app/contexts/PageEditorContext"; import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor"; // Thumbnail generation is now handled by individual PageThumbnail components @@ -35,8 +38,11 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { const { actions } = useFileActions(); // Navigation guard for unsaved changes - const { setHasUnsavedChanges, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } = - useNavigationGuard(); + const { + setHasUnsavedChanges, + registerNavigationWarningHandlers, + unregisterNavigationWarningHandlers, + } = useNavigationGuard(); const navigationState = useNavigationState(); // Get PageEditor coordination functions @@ -56,7 +62,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { const handleVisibleItemsChange = useCallback((items: PDFPage[]) => { setVisiblePageIds((prev) => { const ids = items.map((item) => item.id); - if (prev.length === ids.length && prev.every((id, index) => id === ids[index])) { + if ( + prev.length === ids.length && + prev.every((id, index) => id === ids[index]) + ) { return prev; } return ids; @@ -97,7 +106,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { const filesSignature = selectors.getFilesSignature(); const fileObjectsRef = useRef(new Map()); - const gridItemRefsRef = useRef> | null>(null); + const gridItemRefsRef = useRef + > | null>(null); const pageEditorFiles = useMemo(() => { const cache = fileObjectsRef.current; @@ -163,14 +174,15 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { const initialDocument = useInitialPageDocument(); const { document: mergedPdfDocument } = usePageDocument(); - const { setEditedDocument, displayDocument, getEditedDocument } = useEditedDocumentState({ - initialDocument, - mergedPdfDocument, - reorderedPages, - clearReorderedPages, - fileOrder, - updateCurrentPages, - }); + const { setEditedDocument, displayDocument, getEditedDocument } = + useEditedDocumentState({ + initialDocument, + mergedPdfDocument, + reorderedPages, + clearReorderedPages, + fileOrder, + updateCurrentPages, + }); const displayDocumentRef = useRef(displayDocument); useEffect(() => { @@ -184,7 +196,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { const loadedCount = doc.pages.filter((p) => p.thumbnail).length; const pending = thumbnailRequestsRef.current.size; - const MAX_CONCURRENT_THUMBNAILS = loadedCount < 8 ? 1 : doc.totalPages < 20 ? 3 : doc.totalPages < 50 ? 5 : 8; + const MAX_CONCURRENT_THUMBNAILS = + loadedCount < 8 + ? 1 + : doc.totalPages < 20 + ? 3 + : doc.totalPages < 50 + ? 5 + : 8; const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending); if (available === 0) return; @@ -214,7 +233,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { if (pageIndex === -1) return prev; const updated = [...prev.pages]; - updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache }; + updated[pageIndex] = { + ...prev.pages[pageIndex], + thumbnail: cache, + }; return { ...prev, pages: updated }; }); }) @@ -230,7 +252,11 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { if (!file) return; thumbnailRequestsRef.current.add(pageId); - requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber) + requestThumbnail( + pageId, + file, + page.originalPageNumber || page.pageNumber, + ) .then((thumbnail) => { if (thumbnail) { setEditedDocument((prev) => { @@ -277,11 +303,17 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { } const INITIAL_VISIBLE_PAGE_COUNT = 8; - const initialIds = displayDocument.pages.slice(0, INITIAL_VISIBLE_PAGE_COUNT).map((page) => page.id); + const initialIds = displayDocument.pages + .slice(0, INITIAL_VISIBLE_PAGE_COUNT) + .map((page) => page.id); queueThumbnailRequestsForPages(initialIds); lastInitialDocumentSignatureRef.current = signature; - }, [displayDocumentId, displayDocumentLength, queueThumbnailRequestsForPages]); + }, [ + displayDocumentId, + displayDocumentLength, + queueThumbnailRequestsForPages, + ]); useEffect(() => { setVisiblePageIds([]); @@ -340,7 +372,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { // Grid container ref for positioning split indicators const gridContainerRef = useRef(null); - const { canUndo, canRedo, executeCommandWithTracking, handleUndo, handleRedo, clearUndoHistory } = useUndoManagerState({ + const { + canUndo, + canRedo, + executeCommandWithTracking, + handleUndo, + handleRedo, + clearUndoHistory, + } = useUndoManagerState({ setHasUnsavedChanges, }); @@ -402,7 +441,12 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { }, }); return () => unregisterNavigationWarningHandlers(); - }, [applyChanges, onExportAll, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers]); + }, [ + applyChanges, + onExportAll, + registerNavigationWarningHandlers, + unregisterNavigationWarningHandlers, + ]); // Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument) const selectedPageCount = selectedPageIds.length; @@ -556,7 +600,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { zoomLevelParam?: number, ) => { gridItemRefsRef.current = refs; - const fileColorIndex = page.originalFileId ? (fileColorIndexMap.get(page.originalFileId) ?? 0) : 0; + const fileColorIndex = page.originalFileId + ? (fileColorIndexMap.get(page.originalFileId) ?? 0) + : 0; const isBoxSelected = boxSelectedIds.includes(page.id); return ( { > - {!initialDocument && !globalProcessing && selectedFileIds.length === 0 && ( -
- - - 📄 - - No PDF files loaded - - Add files to start editing pages - - -
- )} + {!initialDocument && + !globalProcessing && + selectedFileIds.length === 0 && ( +
+ + + 📄 + + No PDF files loaded + + Add files to start editing pages + + +
+ )} {!initialDocument && globalProcessing && ( @@ -653,7 +701,13 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { )} {displayDocument && ( - + {/* Split Lines Overlay */}
{ } const containerRect = containerEl.getBoundingClientRect(); - const splitIndexes = convertSplitPageIdsToIndexes(displayDocument, splitPositions); + const splitIndexes = convertSplitPageIdsToIndexes( + displayDocument, + splitPositions, + ); return Array.from(splitIndexes).map((position) => { const currentPage = displayedPages[position]; @@ -695,7 +752,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => { const nextEl = refsMap.get(nextPage.id); if (nextEl) { const nextRect = nextEl.getBoundingClientRect(); - const sameRow = Math.abs(nextRect.top - currentRect.top) < currentRect.height / 2; + const sameRow = + Math.abs(nextRect.top - currentRect.top) < + currentRect.height / 2; if (sameRow) { lineLeft = (currentRect.right + nextRect.left) / 2; } else { diff --git a/frontend/src/core/components/pageEditor/PageEditorControls.tsx b/frontend/src/core/components/pageEditor/PageEditorControls.tsx index 4d0fb8fa5..7fba94268 100644 --- a/frontend/src/core/components/pageEditor/PageEditorControls.tsx +++ b/frontend/src/core/components/pageEditor/PageEditorControls.tsx @@ -60,22 +60,31 @@ const PageEditorControls = ({ const totalPages = displayDocument.pages.length; const selectedValidPageIds = displayDocument.pages - .filter((page, index) => selectedPageIds.includes(page.id) && index < totalPages - 1) + .filter( + (page, index) => + selectedPageIds.includes(page.id) && index < totalPages - 1, + ) .map((page) => page.id); if (selectedValidPageIds.length === 0) { return "Split Selected"; } - const existingSplitsCount = selectedValidPageIds.filter((id) => splitPositions.has(id)).length; + const existingSplitsCount = selectedValidPageIds.filter((id) => + splitPositions.has(id), + ).length; const noSplitsCount = selectedValidPageIds.length - existingSplitsCount; const willRemoveSplits = existingSplitsCount > noSplitsCount; if (willRemoveSplits) { - return existingSplitsCount === selectedValidPageIds.length ? "Remove All Selected Splits" : "Remove Selected Splits"; + return existingSplitsCount === selectedValidPageIds.length + ? "Remove All Selected Splits" + : "Remove Selected Splits"; } else { - return existingSplitsCount === 0 ? "Split Selected" : "Complete Selected Splits"; + return existingSplitsCount === 0 + ? "Split Selected" + : "Complete Selected Splits"; } }; @@ -128,7 +137,11 @@ const PageEditorControls = ({ onClick={onUndo} disabled={!canUndo} variant="subtle" - style={{ color: canUndo ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }} + style={{ + color: canUndo + ? "var(--right-rail-icon)" + : "var(--right-rail-icon-disabled)", + }} radius="md" size="lg" > @@ -140,7 +153,11 @@ const PageEditorControls = ({ onClick={onRedo} disabled={!canRedo} variant="subtle" - style={{ color: canRedo ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }} + style={{ + color: canRedo + ? "var(--right-rail-icon)" + : "var(--right-rail-icon-disabled)", + }} radius="md" size="lg" > @@ -148,7 +165,14 @@ const PageEditorControls = ({ -
+
{/* Page Operations */} @@ -156,7 +180,12 @@ const PageEditorControls = ({ onClick={() => onRotate("left")} disabled={selectedPageIds.length === 0} variant="subtle" - style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }} + style={{ + color: + selectedPageIds.length > 0 + ? "var(--right-rail-icon)" + : "var(--right-rail-icon-disabled)", + }} radius="md" size="lg" > @@ -168,7 +197,12 @@ const PageEditorControls = ({ onClick={() => onRotate("right")} disabled={selectedPageIds.length === 0} variant="subtle" - style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }} + style={{ + color: + selectedPageIds.length > 0 + ? "var(--right-rail-icon)" + : "var(--right-rail-icon-disabled)", + }} radius="md" size="lg" > @@ -180,7 +214,12 @@ const PageEditorControls = ({ onClick={onDelete} disabled={selectedPageIds.length === 0} variant="subtle" - style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }} + style={{ + color: + selectedPageIds.length > 0 + ? "var(--right-rail-icon)" + : "var(--right-rail-icon-disabled)", + }} radius="md" size="lg" > @@ -192,7 +231,12 @@ const PageEditorControls = ({ onClick={onSplit} disabled={selectedPageIds.length === 0} variant="subtle" - style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }} + style={{ + color: + selectedPageIds.length > 0 + ? "var(--right-rail-icon)" + : "var(--right-rail-icon-disabled)", + }} radius="md" size="lg" > @@ -204,7 +248,12 @@ const PageEditorControls = ({ onClick={onPageBreak} disabled={selectedPageIds.length === 0} variant="subtle" - style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }} + style={{ + color: + selectedPageIds.length > 0 + ? "var(--right-rail-icon)" + : "var(--right-rail-icon-disabled)", + }} radius="md" size="lg" > diff --git a/frontend/src/core/components/pageEditor/PageSelectByNumberButton.tsx b/frontend/src/core/components/pageEditor/PageSelectByNumberButton.tsx index fcbf41591..1b35bed99 100644 --- a/frontend/src/core/components/pageEditor/PageSelectByNumberButton.tsx +++ b/frontend/src/core/components/pageEditor/PageSelectByNumberButton.tsx @@ -25,7 +25,13 @@ export default function PageSelectByNumberButton({ updatePagesFromCSV, }: PageSelectByNumberButtonProps) { return ( - +
diff --git a/frontend/src/core/components/pageEditor/PageThumbnail.tsx b/frontend/src/core/components/pageEditor/PageThumbnail.tsx index 764dc1b6c..189bdac01 100644 --- a/frontend/src/core/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/core/components/pageEditor/PageThumbnail.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useState, useEffect, useRef, useMemo } from "react"; +import React, { + useCallback, + useState, + useEffect, + useRef, + useMemo, +} from "react"; import { Text, Checkbox } from "@mantine/core"; import { useIsMobile } from "@app/hooks/useIsMobile"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; @@ -12,7 +18,9 @@ import { PDFPage, PDFDocument } from "@app/types/pageEditor"; import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors"; import styles from "@app/components/pageEditor/PageEditor.module.css"; -import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu"; +import HoverActionMenu, { + HoverAction, +} from "@app/components/shared/HoverActionMenu"; import { StirlingFileStub } from "@app/types/fileContext"; import { PrivateContent } from "@app/components/shared/PrivateContent"; @@ -31,20 +39,34 @@ interface PageThumbnailProps { justMoved?: boolean; pageRefs: React.MutableRefObject>; dragHandleProps?: any; - onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; + onReorderPages: ( + sourcePageNumber: number, + targetIndex: number, + selectedPageIds?: string[], + ) => void; onTogglePage: (pageId: string) => void; onAnimateReorder: () => void; onExecuteCommand: (command: { execute: () => void }) => void; onSetStatus: (status: string) => void; onSetMovingPage: (page: number | null) => void; onDeletePage: (pageNumber: number) => void; - createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void }; + createRotateCommand: ( + pageIds: string[], + rotation: number, + ) => { execute: () => void }; createDeleteCommand: (pageIds: string[]) => { execute: () => void }; - createSplitCommand: (pageId: string, pageNumber: number) => { execute: () => void }; + createSplitCommand: ( + pageId: string, + pageNumber: number, + ) => { execute: () => void }; pdfDocument: PDFDocument; setPdfDocument: (doc: PDFDocument) => void; splitPositions: Set; - onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void; + onInsertFiles?: ( + files: File[] | StirlingFileStub[], + insertAfterPage: number, + isFromStorage?: boolean, + ) => void; zoomLevel?: number; } @@ -77,15 +99,22 @@ const PageThumbnail: React.FC = ({ justMoved = false, }: PageThumbnailProps) => { const pageIndex = page.pageNumber - 1; - const isSelected = Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false; + const isSelected = Array.isArray(selectedPageIds) + ? selectedPageIds.includes(page.id) + : false; const [isMouseDown, setIsMouseDown] = useState(false); - const [mouseStartPos, setMouseStartPos] = useState<{ x: number; y: number } | null>(null); + const [mouseStartPos, setMouseStartPos] = useState<{ + x: number; + y: number; + } | null>(null); const [isHovered, setIsHovered] = useState(false); const isMobile = useIsMobile(); const lastClickTimeRef = useRef(0); - const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); + const [thumbnailUrl, setThumbnailUrl] = useState( + page.thumbnail, + ); const elementRef = useRef(null); const { openFilesModal } = useFilesModalContext(); @@ -95,7 +124,9 @@ const PageThumbnail: React.FC = ({ // Calculate document aspect ratio from first non-blank page const getDocumentAspectRatio = useCallback(() => { // Find first non-blank page with a thumbnail to get aspect ratio - const firstRealPage = pdfDocument.pages.find((p) => !p.isBlankPage && p.thumbnail); + const firstRealPage = pdfDocument.pages.find( + (p) => !p.isBlankPage && p.thumbnail, + ); if (firstRealPage?.thumbnail) { // Try to get aspect ratio from an actual thumbnail image // For now, default to A4 but could be enhanced to measure image dimensions @@ -139,7 +170,13 @@ const PageThumbnail: React.FC = ({ onExecuteCommand(command); onSetStatus(`Rotated page ${page.pageNumber} left`); }, - [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand], + [ + page.id, + page.pageNumber, + onExecuteCommand, + onSetStatus, + createRotateCommand, + ], ); const handleRotateRight = useCallback( @@ -150,7 +187,13 @@ const PageThumbnail: React.FC = ({ onExecuteCommand(command); onSetStatus(`Rotated page ${page.pageNumber} right`); }, - [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand], + [ + page.id, + page.pageNumber, + onExecuteCommand, + onSetStatus, + createRotateCommand, + ], ); const handleDelete = useCallback( @@ -174,7 +217,13 @@ const PageThumbnail: React.FC = ({ const action = hasSplit ? "removed" : "added"; onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`); }, - [pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand], + [ + pageIndex, + splitPositions, + onExecuteCommand, + onSetStatus, + createSplitCommand, + ], ); const handleInsertFileAfter = useCallback( @@ -185,7 +234,11 @@ const PageThumbnail: React.FC = ({ // Open file manager modal with custom handler for page insertion openFilesModal({ insertAfterPage: page.pageNumber, - customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => { + customHandler: ( + files: File[] | StirlingFileStub[], + insertAfterPage?: number, + isFromStorage?: boolean, + ) => { if (insertAfterPage !== undefined) { onInsertFiles(files, insertAfterPage, isFromStorage); } @@ -242,7 +295,15 @@ const PageThumbnail: React.FC = ({ setIsMouseDown(false); setMouseStartPos(null); }, - [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage], + [ + isMouseDown, + mouseStartPos, + isDragging, + page.id, + isBoxSelected, + clearBoxSelection, + onTogglePage, + ], ); const handleMouseLeave = useCallback(() => { @@ -251,7 +312,9 @@ const PageThumbnail: React.FC = ({ setIsHovered(false); }, []); - const fileColorBorder = page.isBlankPage ? "transparent" : getFileColorWithOpacity(fileColorIndex, 0.3); + const fileColorBorder = page.isBlankPage + ? "transparent" + : getFileColorWithOpacity(fileColorIndex, 0.3); // Spread dragHandleProps but use our merged ref const { ref: _, ...restDragProps } = dragHandleProps || {}; @@ -420,7 +483,9 @@ const PageThumbnail: React.FC = ({ height: "100%", backgroundColor: "var(--mantine-color-gray-1)", borderRadius: 6, - boxShadow: page.isBlankPage ? "none" : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`, + boxShadow: page.isBlankPage + ? "none" + : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`, padding: 4, display: "flex", alignItems: "center", diff --git a/frontend/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.tsx b/frontend/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.tsx index 708361330..cb1d14b36 100644 --- a/frontend/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.tsx +++ b/frontend/src/core/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel.tsx @@ -98,7 +98,10 @@ const AdvancedSelectionPanel = ({ {/* Operators row at bottom */} - +
)} diff --git a/frontend/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx b/frontend/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx index f95e70544..975c7306b 100644 --- a/frontend/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx +++ b/frontend/src/core/components/pageEditor/bulkSelectionPanel/OperatorsSection.tsx @@ -8,7 +8,10 @@ interface OperatorsSectionProps { onInsertOperator: (op: LogicalOperator) => void; } -const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => { +const OperatorsSection = ({ + csvInput, + onInsertOperator, +}: OperatorsSectionProps) => { const { t } = useTranslation(); return ( diff --git a/frontend/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx b/frontend/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx index c03fccaab..314ca282b 100644 --- a/frontend/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx +++ b/frontend/src/core/components/pageEditor/bulkSelectionPanel/SelectPages.tsx @@ -93,7 +93,12 @@ const SelectPages = ({ error={Boolean(error)} /> )} - diff --git a/frontend/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.tsx b/frontend/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.tsx index d058669d3..96c65f648 100644 --- a/frontend/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.tsx +++ b/frontend/src/core/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay.tsx @@ -7,7 +7,11 @@ interface SelectedPagesDisplayProps { syntaxError: string | null; } -const SelectedPagesDisplay = ({ selectedPageIds, displayDocument, syntaxError }: SelectedPagesDisplayProps) => { +const SelectedPagesDisplay = ({ + selectedPageIds, + displayDocument, + syntaxError, +}: SelectedPagesDisplayProps) => { if (selectedPageIds.length === 0 && !syntaxError) { return null; } diff --git a/frontend/src/core/components/pageEditor/commands/pageCommands.ts b/frontend/src/core/components/pageEditor/commands/pageCommands.ts index b6997e898..402977c46 100644 --- a/frontend/src/core/components/pageEditor/commands/pageCommands.ts +++ b/frontend/src/core/components/pageEditor/commands/pageCommands.ts @@ -35,11 +35,15 @@ export class RotatePageCommand extends DOMCommand { this.setDocument({ ...currentDoc, - pages: currentDoc.pages.map((p) => (p.id === this.pageId ? { ...p, rotation: newRotation } : p)), + pages: currentDoc.pages.map((p) => + p.id === this.pageId ? { ...p, rotation: newRotation } : p, + ), }); // Also update DOM immediately for CSS animation on currently-mounted pages - const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`); + const pageElement = document.querySelector( + `[data-page-id="${this.pageId}"]`, + ); if (pageElement) { const img = pageElement.querySelector("img"); if (img) img.style.transform = `rotate(${newRotation}deg)`; @@ -54,10 +58,14 @@ export class RotatePageCommand extends DOMCommand { this.setDocument({ ...currentDoc, - pages: currentDoc.pages.map((p) => (p.id === this.pageId ? { ...p, rotation: this.originalRotation! } : p)), + pages: currentDoc.pages.map((p) => + p.id === this.pageId ? { ...p, rotation: this.originalRotation! } : p, + ), }); - const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`); + const pageElement = document.querySelector( + `[data-page-id="${this.pageId}"]`, + ); if (pageElement) { const img = pageElement.querySelector("img"); if (img) img.style.transform = `rotate(${this.originalRotation}deg)`; @@ -118,12 +126,17 @@ export class DeletePagesCommand extends DOMCommand { const selectedPageNumbersBefore = this.getSelectedPages(); const selectedIdSet = new Set( selectedPageNumbersBefore - .map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum)?.id) + .map( + (pageNum) => + currentDoc.pages.find((p) => p.pageNumber === pageNum)?.id, + ) .filter((id): id is string => Boolean(id)), ); // Filter out deleted pages by ID (stable across undo/redo) - const remainingPages = currentDoc.pages.filter((page) => !this.pageIdsToDelete.includes(page.id)); + const remainingPages = currentDoc.pages.filter( + (page) => !this.pageIdsToDelete.includes(page.id), + ); if (remainingPages.length === 0) { // If all pages would be deleted, clear selection/splits and close PDF @@ -162,7 +175,9 @@ export class DeletePagesCommand extends DOMCommand { // Apply changes this.setDocument(updatedDocument); - const remainingSelectedPageIds = remainingPages.filter((page) => selectedIdSet.has(page.id)).map((page) => page.id); + const remainingSelectedPageIds = remainingPages + .filter((page) => selectedIdSet.has(page.id)) + .map((page) => page.id); this.setSelectedPageIds(remainingSelectedPageIds); this.setSplitPositions(newPositions); @@ -175,7 +190,12 @@ export class DeletePagesCommand extends DOMCommand { this.setDocument(this.originalDocument); this.setSplitPositions(this.originalSplitPositions); const restoredIds = this.originalSelectedPages - .map((pageNum) => this.originalDocument!.pages.find((page) => page.pageNumber === pageNum)?.id || "") + .map( + (pageNum) => + this.originalDocument!.pages.find( + (page) => page.pageNumber === pageNum, + )?.id || "", + ) .filter((id) => id !== ""); this.setSelectedPageIds(restoredIds); } @@ -207,18 +227,28 @@ export class ReorderPagesCommand extends DOMCommand { this.originalPages = currentDoc.pages.map((page) => ({ ...page })); // Perform the reorder - const sourceIndex = currentDoc.pages.findIndex((p) => p.pageNumber === this.sourcePageNumber); + const sourceIndex = currentDoc.pages.findIndex( + (p) => p.pageNumber === this.sourcePageNumber, + ); if (sourceIndex === -1) return; const newPages = [...currentDoc.pages]; - if (this.selectedPages && this.selectedPages.length > 1 && this.selectedPages.includes(this.sourcePageNumber)) { + if ( + this.selectedPages && + this.selectedPages.length > 1 && + this.selectedPages.includes(this.sourcePageNumber) + ) { // Multi-page reorder const selectedPageObjects = this.selectedPages - .map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum)) + .map((pageNum) => + currentDoc.pages.find((p) => p.pageNumber === pageNum), + ) .filter((page) => page !== undefined) as PDFPage[]; - const remainingPages = newPages.filter((page) => !this.selectedPages!.includes(page.pageNumber)); + const remainingPages = newPages.filter( + (page) => !this.selectedPages!.includes(page.pageNumber), + ); remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects); remainingPages.forEach((page, index) => { @@ -231,7 +261,10 @@ export class ReorderPagesCommand extends DOMCommand { const [movedPage] = newPages.splice(sourceIndex, 1); // Adjust target index if moving forward (after removal, indices shift) - const adjustedTargetIndex = sourceIndex < this.targetIndex ? this.targetIndex - 1 : this.targetIndex; + const adjustedTargetIndex = + sourceIndex < this.targetIndex + ? this.targetIndex - 1 + : this.targetIndex; newPages.splice(adjustedTargetIndex, 0, movedPage); @@ -346,7 +379,8 @@ export class BulkRotateCommand extends DOMCommand { const newRotationById = new Map(); const updatedPages = currentDoc.pages.map((page) => { if (pageIdSet.has(page.id)) { - const newRotation = (((page.rotation + this.degrees) % 360) + 360) % 360; + const newRotation = + (((page.rotation + this.degrees) % 360) + 360) % 360; newRotationById.set(page.id, newRotation); return { ...page, rotation: newRotation }; } @@ -360,7 +394,8 @@ export class BulkRotateCommand extends DOMCommand { const pageElement = document.querySelector(`[data-page-id="${pageId}"]`); if (pageElement) { const img = pageElement.querySelector("img"); - if (img) img.style.transform = `rotate(${newRotationById.get(pageId)}deg)`; + if (img) + img.style.transform = `rotate(${newRotationById.get(pageId)}deg)`; } } } @@ -371,7 +406,9 @@ export class BulkRotateCommand extends DOMCommand { // Restore original rotations in state const updatedPages = currentDoc.pages.map((page) => - this.originalRotations.has(page.id) ? { ...page, rotation: this.originalRotations.get(page.id)! } : page, + this.originalRotations.has(page.id) + ? { ...page, rotation: this.originalRotations.get(page.id)! } + : page, ); this.setDocument({ ...currentDoc, pages: updatedPages }); @@ -455,7 +492,9 @@ export class SplitAllCommand extends DOMCommand { // Check if all splits are already active const currentSplits = this.getSplitPositions(); - const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) => currentSplits.has(pos)); + const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) => + currentSplits.has(pos), + ); if (hasAllSplits) { // Remove all splits @@ -473,7 +512,9 @@ export class SplitAllCommand extends DOMCommand { get description(): string { const currentSplits = this.getSplitPositions(); - const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) => currentSplits.has(pos)); + const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) => + currentSplits.has(pos), + ); return hasAllSplits ? "Remove all splits" : "Split all pages"; } } @@ -622,7 +663,9 @@ export class BulkPageBreakCommand extends DOMCommand { // Find the original page by matching the page ID from the original document const originalPage = this.originalDocument?.pages[originalPageNum - 1]; if (originalPage) { - const foundPage = newPages.find((page) => page.id === originalPage.id && !page.isBlankPage); + const foundPage = newPages.find( + (page) => page.id === originalPage.id && !page.isBlankPage, + ); if (foundPage) { updatedSelection.push(foundPage.pageNumber); } @@ -655,7 +698,10 @@ export class InsertFilesCommand extends DOMCommand { private setDocument: (doc: PDFDocument) => void, private setSelectedPages: (pages: number[]) => void, private getSelectedPages: () => number[], - private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map) => void, + private updateFileContext?: ( + updatedDocument: PDFDocument, + insertedFiles?: Map, + ) => void, ) { super(); } @@ -677,7 +723,8 @@ export class InsertFilesCommand extends DOMCommand { // Process all files and wait for their completion const baseTimestamp = Date.now(); const extractionPromises = this.files.map(async (file, index) => { - const fileId = `inserted-${file.name}-${baseTimestamp + index}` as FileId; + const fileId = + `inserted-${file.name}-${baseTimestamp + index}` as FileId; // Store inserted file for export this.insertedFileMap.set(fileId, file); // Use base timestamp + index to ensure unique but predictable file IDs @@ -702,7 +749,10 @@ export class InsertFilesCommand extends DOMCommand { // Add pages before insertion point for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) { - const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ }; + const page = { + ...currentDoc.pages[i], + pageNumber: pageNumberCounter++, + }; newPages.push(page); } @@ -720,7 +770,10 @@ export class InsertFilesCommand extends DOMCommand { // Add remaining pages after insertion point for (let i = insertIndex; i < currentDoc.pages.length; i++) { - const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ }; + const page = { + ...currentDoc.pages[i], + pageNumber: pageNumberCounter++, + }; newPages.push(page); } @@ -765,9 +818,12 @@ export class InsertFilesCommand extends DOMCommand { } } - private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise { + private async generateThumbnailsForInsertedPages( + updatedDocument: PDFDocument, + ): Promise { try { - const { thumbnailGenerationService } = await import("@app/services/thumbnailGenerationService"); + const { thumbnailGenerationService } = + await import("@app/services/thumbnailGenerationService"); // Group pages by file ID to generate thumbnails efficiently const pagesByFileId = new Map(); @@ -789,7 +845,10 @@ export class InsertFilesCommand extends DOMCommand { console.log("Generating thumbnails for file:", fileId); console.log("Pages:", pages.length); - console.log("ArrayBuffer size:", arrayBuffer?.byteLength || "undefined"); + console.log( + "ArrayBuffer size:", + arrayBuffer?.byteLength || "undefined", + ); try { if (arrayBuffer && arrayBuffer.byteLength > 0) { @@ -802,12 +861,21 @@ export class InsertFilesCommand extends DOMCommand { console.log("Generating thumbnails for page numbers:", pageNumbers); // Generate thumbnails for all pages from this file at once - const results = await thumbnailGenerationService.generateThumbnails(fileId, arrayBuffer, pageNumbers, { - scale: 0.2, - quality: 0.8, - }); + const results = await thumbnailGenerationService.generateThumbnails( + fileId, + arrayBuffer, + pageNumbers, + { + scale: 0.2, + quality: 0.8, + }, + ); - console.log("Thumbnail generation results:", results.length, "thumbnails generated"); + console.log( + "Thumbnail generation results:", + results.length, + "thumbnails generated", + ); // Update pages with generated thumbnails for (let i = 0; i < results.length && i < pages.length; i++) { @@ -815,7 +883,9 @@ export class InsertFilesCommand extends DOMCommand { const page = pages[i]; if (result.success) { - const pageIndex = updatedDocument.pages.findIndex((p) => p.id === page.id); + const pageIndex = updatedDocument.pages.findIndex( + (p) => p.id === page.id, + ); if (pageIndex >= 0) { updatedDocument.pages[pageIndex].thumbnail = result.thumbnail; console.log("Updated thumbnail for page:", page.id); @@ -829,7 +899,11 @@ export class InsertFilesCommand extends DOMCommand { console.error("No valid ArrayBuffer found for file ID:", fileId); } } catch (error) { - console.error("Failed to generate thumbnails for file:", fileId, error); + console.error( + "Failed to generate thumbnails for file:", + fileId, + error, + ); } finally { this.fileDataMap.delete(fileId); } @@ -839,13 +913,19 @@ export class InsertFilesCommand extends DOMCommand { } } - private async extractPagesFromFile(file: File, baseTimestamp: number): Promise { + private async extractPagesFromFile( + file: File, + baseTimestamp: number, + ): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = async (event) => { try { const arrayBuffer = event.target?.result as ArrayBuffer; - console.log("File reader onload - arrayBuffer size:", arrayBuffer?.byteLength || "undefined"); + console.log( + "File reader onload - arrayBuffer size:", + arrayBuffer?.byteLength || "undefined", + ); if (!arrayBuffer) { reject(new Error("Failed to read file")); @@ -856,7 +936,8 @@ export class InsertFilesCommand extends DOMCommand { const clonedArrayBuffer = arrayBuffer.slice(0); // Use PDF.js via the worker manager to extract pages - const { pdfWorkerManager } = await import("@app/services/pdfWorkerManager"); + const { pdfWorkerManager } = + await import("@app/services/pdfWorkerManager"); const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer); const pageCount = pdf.numPages; @@ -864,13 +945,24 @@ export class InsertFilesCommand extends DOMCommand { const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId; console.log("Original ArrayBuffer size:", arrayBuffer.byteLength); - console.log("Storing ArrayBuffer for fileId:", fileId, "size:", arrayBuffer.byteLength); + console.log( + "Storing ArrayBuffer for fileId:", + fileId, + "size:", + arrayBuffer.byteLength, + ); // Store the original ArrayBuffer for thumbnail generation this.fileDataMap.set(fileId, arrayBuffer); - console.log("After storing - fileDataMap size:", this.fileDataMap.size); - console.log("Stored value size:", this.fileDataMap.get(fileId)?.byteLength || "undefined"); + console.log( + "After storing - fileDataMap size:", + this.fileDataMap.size, + ); + console.log( + "Stored value size:", + this.fileDataMap.get(fileId)?.byteLength || "undefined", + ); for (let i = 1; i <= pageCount; i++) { const pageId = `${fileId}-page-${i}`; diff --git a/frontend/src/core/components/pageEditor/fileColors.ts b/frontend/src/core/components/pageEditor/fileColors.ts index daab899f2..18eedff99 100644 --- a/frontend/src/core/components/pageEditor/fileColors.ts +++ b/frontend/src/core/components/pageEditor/fileColors.ts @@ -54,7 +54,10 @@ export function getFileColor(index: number): string { * @param opacity - Opacity value (0-1), defaults to 0.3 * @returns RGBA color string */ -export function getFileColorWithOpacity(index: number, opacity: number = 0.2): string { +export function getFileColorWithOpacity( + index: number, + opacity: number = 0.2, +): string { const rgb = getFileColor(index); // Convert rgb(r, g, b) to rgba(r, g, b, a) return rgb.replace("rgb(", "rgba(").replace(")", `, ${opacity})`); diff --git a/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts b/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts index 1e8199c33..86473719a 100644 --- a/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts +++ b/frontend/src/core/components/pageEditor/hooks/useEditedDocumentState.ts @@ -20,7 +20,9 @@ export const useEditedDocumentState = ({ fileOrder, updateCurrentPages, }: UseEditedDocumentStateParams) => { - const [editedDocument, setEditedDocument] = useState(null); + const [editedDocument, setEditedDocument] = useState( + null, + ); const editedDocumentRef = useRef(null); const pagePositionCacheRef = useRef>(new Map()); const pageNeighborCacheRef = useRef>(new Map()); @@ -89,7 +91,8 @@ export const useEditedDocumentState = ({ const currentEditedDocument = editedDocumentRef.current; if (!mergedPdfDocument || !currentEditedDocument) return; - const signatureChanged = mergedDocSignature !== lastSyncedSignatureRef.current; + const signatureChanged = + mergedDocSignature !== lastSyncedSignatureRef.current; const metadataChanged = currentEditedDocument.id !== mergedPdfDocument.id || currentEditedDocument.file !== mergedPdfDocument.file || @@ -117,7 +120,8 @@ export const useEditedDocumentState = ({ } const hasAdditions = newPages.length > 0; - const isEphemeralPage = (page: PDFPage) => Boolean(page.isBlankPage || page.isPlaceholder); + const isEphemeralPage = (page: PDFPage) => + Boolean(page.isBlankPage || page.isPlaceholder); let hasRemovals = false; for (const page of prev.pages) { @@ -142,12 +146,16 @@ export const useEditedDocumentState = ({ const nextInsertIndexByFile = new Map(placeholderPositions); if (hasRemovals) { - pages = pages.filter((page) => sourceIds.has(page.id) || isEphemeralPage(page)); + pages = pages.filter( + (page) => sourceIds.has(page.id) || isEphemeralPage(page), + ); } if (hasAdditions) { const mergedIndexMap = new Map(); - sourcePages.forEach((page, index) => mergedIndexMap.set(page.id, index)); + sourcePages.forEach((page, index) => + mergedIndexMap.set(page.id, index), + ); const additions = newPages .map((page) => ({ @@ -163,36 +171,45 @@ export const useEditedDocumentState = ({ return a.mergedIndex - b.mergedIndex; }); - additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => { - if (pages.some((existing) => existing.id === page.id)) { - return; - } + additions.forEach( + ({ page, neighborId, cachedIndex, mergedIndex }) => { + if (pages.some((existing) => existing.id === page.id)) { + return; + } - let insertIndex: number; - const originalFileId = page.originalFileId; - const placeholderIndex = originalFileId !== undefined ? nextInsertIndexByFile.get(originalFileId) : undefined; + let insertIndex: number; + const originalFileId = page.originalFileId; + const placeholderIndex = + originalFileId !== undefined + ? nextInsertIndexByFile.get(originalFileId) + : undefined; - if (originalFileId && placeholderIndex !== undefined) { - insertIndex = Math.min(placeholderIndex, pages.length); - nextInsertIndexByFile.set(originalFileId, insertIndex + 1); - } else if (neighborId === null) { - insertIndex = 0; - } else if (neighborId) { - const neighborIndex = pages.findIndex((p) => p.id === neighborId); - if (neighborIndex !== -1) { - insertIndex = neighborIndex + 1; + if (originalFileId && placeholderIndex !== undefined) { + insertIndex = Math.min(placeholderIndex, pages.length); + nextInsertIndexByFile.set(originalFileId, insertIndex + 1); + } else if (neighborId === null) { + insertIndex = 0; + } else if (neighborId) { + const neighborIndex = pages.findIndex( + (p) => p.id === neighborId, + ); + if (neighborIndex !== -1) { + insertIndex = neighborIndex + 1; + } else { + const fallbackIndex = + cachedIndex ?? mergedIndex ?? pages.length; + insertIndex = Math.min(fallbackIndex, pages.length); + } } else { - const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length; + const fallbackIndex = + cachedIndex ?? mergedIndex ?? pages.length; insertIndex = Math.min(fallbackIndex, pages.length); } - } else { - const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length; - insertIndex = Math.min(fallbackIndex, pages.length); - } - const clonedPage = { ...page }; - pages.splice(insertIndex, 0, clonedPage); - }); + const clonedPage = { ...page }; + pages.splice(insertIndex, 0, clonedPage); + }, + ); } } @@ -244,4 +261,6 @@ export const useEditedDocumentState = ({ }; }; -export type UseEditedDocumentStateReturn = ReturnType; +export type UseEditedDocumentStateReturn = ReturnType< + typeof useEditedDocumentState +>; diff --git a/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts b/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts index daf429d1a..a790e9408 100644 --- a/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts +++ b/frontend/src/core/components/pageEditor/hooks/useEditorCommands.ts @@ -63,7 +63,12 @@ export const usePageEditorCommands = ({ const handleRotatePages = useCallback( (pageIds: string[], rotation: number) => { - const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation, getEditedDocument, setEditedDocument); + const bulkRotateCommand = new BulkRotateCommand( + pageIds, + rotation, + getEditedDocument, + setEditedDocument, + ); executeCommandWithTracking(bulkRotateCommand); }, [executeCommandWithTracking, getEditedDocument, setEditedDocument], @@ -72,7 +77,12 @@ export const usePageEditorCommands = ({ const createRotateCommand = useCallback( (pageIds: string[], rotation: number) => ({ execute: () => { - const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation, getEditedDocument, setEditedDocument); + const bulkRotateCommand = new BulkRotateCommand( + pageIds, + rotation, + getEditedDocument, + setEditedDocument, + ); executeCommandWithTracking(bulkRotateCommand); }, }), @@ -123,7 +133,12 @@ export const usePageEditorCommands = ({ const createSplitCommand = useCallback( (pageId: string, pageNumber: number) => ({ execute: () => { - const splitCommand = new SplitCommand(pageId, pageNumber, () => splitPositionsRef.current, setSplitPositions); + const splitCommand = new SplitCommand( + pageId, + pageNumber, + () => splitPositionsRef.current, + setSplitPositions, + ); executeCommandWithTracking(splitCommand); }, }), @@ -208,12 +223,18 @@ export const usePageEditorCommands = ({ if (!displayDocument || selectedPageIds.length === 0) return; const selectedSplitPageIds = displayDocument.pages - .filter((page, index) => selectedPageIds.includes(page.id) && index < displayDocument.pages.length - 1) + .filter( + (page, index) => + selectedPageIds.includes(page.id) && + index < displayDocument.pages.length - 1, + ) .map((page) => page.id); if (selectedSplitPageIds.length === 0) return; - const existingSplitsCount = selectedSplitPageIds.filter((id) => splitPositions.has(id)).length; + const existingSplitsCount = selectedSplitPageIds.filter((id) => + splitPositions.has(id), + ).length; const noSplitsCount = selectedSplitPageIds.length - existingSplitsCount; const shouldRemoveSplits = existingSplitsCount > noSplitsCount; @@ -234,7 +255,14 @@ export const usePageEditorCommands = ({ }; executeCommandWithTracking(smartSplitCommand); - }, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]); + }, [ + selectedPageIds, + displayDocument, + splitPositions, + setSplitPositions, + getPageNumbersFromIds, + executeCommandWithTracking, + ]); const handleSplitAll = handleSplit; @@ -243,7 +271,11 @@ export const usePageEditorCommands = ({ const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); - const pageBreakCommand = new PageBreakCommand(selectedPageNumbers, getEditedDocument, setEditedDocument); + const pageBreakCommand = new PageBreakCommand( + selectedPageNumbers, + getEditedDocument, + setEditedDocument, + ); executeCommandWithTracking(pageBreakCommand); }, [ displayDocument, @@ -257,7 +289,11 @@ export const usePageEditorCommands = ({ const handlePageBreakAll = handlePageBreak; const handleInsertFiles = useCallback( - async (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => { + async ( + files: File[] | StirlingFileStub[], + insertAfterPage: number, + isFromStorage?: boolean, + ) => { console.log("[PageEditor] handleInsertFiles called:", { fileCount: files.length, insertAfterPage, @@ -274,7 +310,9 @@ export const usePageEditorCommands = ({ } try { - const targetPage = workingDocument.pages.find((p) => p.pageNumber === insertAfterPage); + const targetPage = workingDocument.pages.find( + (p) => p.pageNumber === insertAfterPage, + ); if (!targetPage) { console.log("[PageEditor] Target page not found:", insertAfterPage); return; @@ -317,7 +355,8 @@ export const usePageEditorCommands = ({ id: `${fileId}-${page.pageNumber ?? idx + 1}`, pageNumber: page.pageNumber ?? idx + 1, originalFileId: fileId, - originalPageNumber: page.originalPageNumber ?? page.pageNumber ?? idx + 1, + originalPageNumber: + page.originalPageNumber ?? page.pageNumber ?? idx + 1, rotation: page.rotation ?? 0, thumbnail: page.thumbnail ?? null, selected: false, @@ -328,7 +367,9 @@ export const usePageEditorCommands = ({ } if (newPages.length > 0) { - const targetIndex = workingDocument.pages.findIndex((p) => p.id === targetPage.id); + const targetIndex = workingDocument.pages.findIndex( + (p) => p.id === targetPage.id, + ); if (targetIndex >= 0) { const updatedPages = [...workingDocument.pages]; @@ -350,14 +391,26 @@ export const usePageEditorCommands = ({ console.error("Failed to insert files:", error); } }, - [getEditedDocument, actions, selectors, updateFileOrderFromPages, setEditedDocument], + [ + getEditedDocument, + actions, + selectors, + updateFileOrderFromPages, + setEditedDocument, + ], ); const handleReorderPages = useCallback( - (sourcePageNumber: number, targetIndex: number, draggedPageIds?: string[]) => { + ( + sourcePageNumber: number, + targetIndex: number, + draggedPageIds?: string[], + ) => { if (!displayDocument) return; - const selectedPages = draggedPageIds ? getPageNumbersFromIds(draggedPageIds) : undefined; + const selectedPages = draggedPageIds + ? getPageNumbersFromIds(draggedPageIds) + : undefined; const reorderCommand = new ReorderPagesCommand( sourcePageNumber, @@ -397,4 +450,6 @@ export const usePageEditorCommands = ({ }; }; -export type UsePageEditorCommandsReturn = ReturnType; +export type UsePageEditorCommandsReturn = ReturnType< + typeof usePageEditorCommands +>; diff --git a/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts b/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts index cd641a9c9..5fda7123e 100644 --- a/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts +++ b/frontend/src/core/components/pageEditor/hooks/useInitialPageDocument.ts @@ -8,7 +8,9 @@ import { PDFDocument } from "@app/types/pageEditor"; */ export function useInitialPageDocument(): PDFDocument | null { const { document: liveDocument } = usePageDocument(); - const [initialDocument, setInitialDocument] = useState(null); + const [initialDocument, setInitialDocument] = useState( + null, + ); const lastDocumentIdRef = useRef(null); const liveDocumentId = liveDocument?.id ?? null; @@ -30,7 +32,11 @@ export function useInitialPageDocument(): PDFDocument | null { return; } - console.log("📄 useInitialPageDocument: Captured initial document with", liveDocument.pages.length, "pages"); + console.log( + "📄 useInitialPageDocument: Captured initial document with", + liveDocument.pages.length, + "pages", + ); setInitialDocument(liveDocument); }, [liveDocument, initialDocument]); diff --git a/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts index 206b4b431..001ffb2ab 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageDocument.ts @@ -17,7 +17,12 @@ export interface PageDocumentHook { */ export function usePageDocument(): PageDocumentHook { const { state, selectors } = useFileState(); - const { fileOrder, currentPages, persistedDocument, persistedDocumentSignature } = usePageEditor(); + const { + fileOrder, + currentPages, + persistedDocument, + persistedDocumentSignature, + } = usePageEditor(); // Use PageEditorContext's fileOrder instead of FileContext's global order // This ensures the page editor respects its own workspace ordering @@ -55,9 +60,12 @@ export function usePageDocument(): PageDocumentHook { const globalProcessing = state.ui.isProcessing; // Get primary file record outside useMemo to track processedFile changes - const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null; + const primaryStirlingFileStub = primaryFileId + ? selectors.getStirlingFileStub(primaryFileId) + : null; const processedFilePages = primaryStirlingFileStub?.processedFile?.pages; - const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages; + const processedFileTotalPages = + primaryStirlingFileStub?.processedFile?.totalPages; const placeholderDocumentRef = useRef(null); const [placeholderVersion, setPlaceholderVersion] = useState(0); @@ -89,20 +97,24 @@ export function usePageDocument(): PageDocumentHook { const analysis = await FileAnalyzer.quickPDFAnalysis(file); if (canceled) return; const totalPages = Math.max(1, analysis.pageCount || 1); - const pages: PDFPage[] = Array.from({ length: totalPages }, (_, index) => ({ - id: `placeholder-${primaryFileId}-page-${index + 1}`, - pageNumber: index + 1, - thumbnail: null, - rotation: 0, - selected: false, - originalFileId: primaryFileId, - originalPageNumber: index + 1, - })); + const pages: PDFPage[] = Array.from( + { length: totalPages }, + (_, index) => ({ + id: `placeholder-${primaryFileId}-page-${index + 1}`, + pageNumber: index + 1, + thumbnail: null, + rotation: 0, + selected: false, + originalFileId: primaryFileId, + originalPageNumber: index + 1, + }), + ); if (!canceled) { placeholderDocumentRef.current = { id: `placeholder-${primaryFileId}`, - name: selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name, + name: + selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name, file, pages, totalPages, @@ -147,7 +159,13 @@ export function usePageDocument(): PageDocumentHook { // Check if persisted document is still valid // Must match signature AND have the same number of source files const persistedFileIds = persistedDocument - ? Array.from(new Set(persistedDocument.pages.map((p) => p.originalFileId).filter(Boolean))) + ? Array.from( + new Set( + persistedDocument.pages + .map((p) => p.originalFileId) + .filter(Boolean), + ), + ) : []; const persistedIsValid = persistedDocument && @@ -160,14 +178,20 @@ export function usePageDocument(): PageDocumentHook { console.log("[usePageDocument] Using persisted document"); return persistedDocument; } else if (persistedDocument) { - console.log("[usePageDocument] Persisted document invalid - rebuilding:", { - sigMatch: persistedDocumentSignature === currentPagesSignature, - persistedFiles: persistedFileIds.length, - activeFiles: activeFileIds.length, - }); + console.log( + "[usePageDocument] Persisted document invalid - rebuilding:", + { + sigMatch: persistedDocumentSignature === currentPagesSignature, + persistedFiles: persistedFileIds.length, + activeFiles: activeFileIds.length, + }, + ); } - if (!primaryStirlingFileStub?.processedFile && placeholderDocumentRef.current) { + if ( + !primaryStirlingFileStub?.processedFile && + placeholderDocumentRef.current + ) { return placeholderDocumentRef.current; } @@ -175,18 +199,29 @@ export function usePageDocument(): PageDocumentHook { // If we have file IDs but no file record, something is wrong - return null to show loading if (!primaryStirlingFileStub) { - console.log("🎬 PageEditor: No primary file record found, showing loading"); + console.log( + "🎬 PageEditor: No primary file record found, showing loading", + ); return null; } - const namingFileIds = selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds; + const namingFileIds = + selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds; const name = namingFileIds.length <= 1 ? namingFileIds[0] - ? (selectors.getStirlingFileStub(namingFileIds[0])?.name ?? "document.pdf") + ? (selectors.getStirlingFileStub(namingFileIds[0])?.name ?? + "document.pdf") : "document.pdf" - : namingFileIds.map((id) => (selectors.getStirlingFileStub(id)?.name ?? "file").replace(/\.pdf$/i, "")).join(" + "); + : namingFileIds + .map((id) => + (selectors.getStirlingFileStub(id)?.name ?? "file").replace( + /\.pdf$/i, + "", + ), + ) + .join(" + "); // Build page insertion map from files with insertion positions const insertionMap = new Map(); // insertAfterPageId -> fileIds @@ -218,7 +253,11 @@ export function usePageDocument(): PageDocumentHook { let pages: PDFPage[]; // Helper function to create pages from a file (or placeholder if deselected) - const createPagesFromFile = (fileId: FileId, startPageNumber: number, isSelected: boolean): PDFPage[] => { + const createPagesFromFile = ( + fileId: FileId, + startPageNumber: number, + isSelected: boolean, + ): PDFPage[] => { const stirlingFileStub = selectors.getStirlingFileStub(fileId); if (!stirlingFileStub) { return []; @@ -262,17 +301,20 @@ export function usePageDocument(): PageDocumentHook { if (processedFile?.totalPages) { // Fallback: create pages without thumbnails but with correct count - return Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({ - id: `${fileId}-${pageIndex + 1}`, - pageNumber: startPageNumber + pageIndex, - originalPageNumber: pageIndex + 1, - originalFileId: fileId, - rotation: 0, - thumbnail: null, - selected: false, - splitAfter: false, - isPlaceholder: false, - })); + return Array.from( + { length: processedFile.totalPages }, + (_, pageIndex) => ({ + id: `${fileId}-${pageIndex + 1}`, + pageNumber: startPageNumber + pageIndex, + originalPageNumber: pageIndex + 1, + originalFileId: fileId, + rotation: 0, + thumbnail: null, + selected: false, + splitAfter: false, + isPlaceholder: false, + }), + ); } // No processedFile yet - create a single loading placeholder @@ -320,7 +362,9 @@ export function usePageDocument(): PageDocumentHook { // Process each insertion by finding the page ID and inserting after it for (const [insertAfterPageId, fileIds] of insertionMap.entries()) { - const targetPageIndex = pages.findIndex((p) => p.id === insertAfterPageId); + const targetPageIndex = pages.findIndex( + (p) => p.id === insertAfterPageId, + ); if (targetPageIndex === -1) continue; @@ -352,14 +396,22 @@ export function usePageDocument(): PageDocumentHook { pageNumber: index + 1, })); - const currentPagesSet = currentPages ? new Set(currentPages.map((page) => page.id)) : null; - if (currentPagesSet && currentPages && currentPagesSet.size === pages.length) { + const currentPagesSet = currentPages + ? new Set(currentPages.map((page) => page.id)) + : null; + if ( + currentPagesSet && + currentPages && + currentPagesSet.size === pages.length + ) { const sameIds = pages.every((page) => currentPagesSet.has(page.id)); if (sameIds) { const mergedById = new Map(pages.map((page) => [page.id, page])); pages = currentPages.map((currentPage, index) => { const source = mergedById.get(currentPage.id); - const mergedPage = source ? { ...source, ...currentPage } : currentPage; + const mergedPage = source + ? { ...source, ...currentPage } + : currentPage; return { ...mergedPage, pageNumber: index + 1, diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorDropdownState.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorDropdownState.ts index d165acaec..ac26c142d 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageEditorDropdownState.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorDropdownState.ts @@ -20,7 +20,8 @@ export interface PageEditorDropdownState { fileColorMap: Map; } -const isPdf = (name?: string | null) => typeof name === "string" && name.toLowerCase().endsWith(".pdf"); +const isPdf = (name?: string | null) => + typeof name === "string" && name.toLowerCase().endsWith(".pdf"); export function usePageEditorDropdownState(): PageEditorDropdownState { const { state, selectors } = useFileState(); @@ -42,9 +43,14 @@ export function usePageEditorDropdownState(): PageEditorDropdownState { .filter((file): file is PageEditorDropdownFile => file !== null); }, [fileOrder, selectors, state.ui.selectedFileIds]); - const fileColorMap = useFileColorMap(pageEditorFiles.map((file) => file.fileId)); + const fileColorMap = useFileColorMap( + pageEditorFiles.map((file) => file.fileId), + ); - const selectedCount = useMemo(() => pageEditorFiles.filter((file) => file.isSelected).length, [pageEditorFiles]); + const selectedCount = useMemo( + () => pageEditorFiles.filter((file) => file.isSelected).length, + [pageEditorFiles], + ); return useMemo( () => ({ @@ -55,6 +61,12 @@ export function usePageEditorDropdownState(): PageEditorDropdownState { onReorder: reorderFiles, fileColorMap, }), - [pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap], + [ + pageEditorFiles, + selectedCount, + toggleFileSelection, + reorderFiles, + fileColorMap, + ], ); } diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts index 9c5d922c4..2aca4a1d0 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorExport.ts @@ -43,9 +43,13 @@ const removePlaceholderPages = (document: PDFDocument): PDFDocument => { }; }; -const normalizeProcessedDocuments = (processed: PDFDocument | PDFDocument[]): PDFDocument | PDFDocument[] => { +const normalizeProcessedDocuments = ( + processed: PDFDocument | PDFDocument[], +): PDFDocument | PDFDocument[] => { if (Array.isArray(processed)) { - const normalized = processed.map(removePlaceholderPages).filter((doc) => doc.pages.length > 0); + const normalized = processed + .map(removePlaceholderPages) + .filter((doc) => doc.pages.length > 0); return normalized; } return removePlaceholderPages(processed); @@ -104,17 +108,23 @@ export const usePageEditorExport = ({ setExportLoading(true); try { - const processedDocuments = documentManipulationService.applyDOMChangesToDocument( - displayDocument, - displayDocument, - splitPositions, - ); + const processedDocuments = + documentManipulationService.applyDOMChangesToDocument( + displayDocument, + displayDocument, + splitPositions, + ); - const normalizedDocuments = normalizeProcessedDocuments(processedDocuments); - const documentWithDOMState = Array.isArray(normalizedDocuments) ? normalizedDocuments[0] : normalizedDocuments; + const normalizedDocuments = + normalizeProcessedDocuments(processedDocuments); + const documentWithDOMState = Array.isArray(normalizedDocuments) + ? normalizedDocuments[0] + : normalizedDocuments; if (!documentWithDOMState || documentWithDOMState.pages.length === 0) { - console.warn("Export skipped: no concrete pages available after filtering placeholders."); + console.warn( + "Export skipped: no concrete pages available after filtering placeholders.", + ); setExportLoading(false); return; } @@ -126,14 +136,23 @@ export const usePageEditorExport = ({ const sourceFiles = getSourceFiles(); const exportFilename = getExportFilename(); const result = sourceFiles - ? await pdfExportService.exportPDFMultiFile(documentWithDOMState, sourceFiles, validSelectedPageIds, { - selectedOnly: true, - filename: exportFilename, - }) - : await pdfExportService.exportPDF(documentWithDOMState, validSelectedPageIds, { - selectedOnly: true, - filename: exportFilename, - }); + ? await pdfExportService.exportPDFMultiFile( + documentWithDOMState, + sourceFiles, + validSelectedPageIds, + { + selectedOnly: true, + filename: exportFilename, + }, + ) + : await pdfExportService.exportPDF( + documentWithDOMState, + validSelectedPageIds, + { + selectedOnly: true, + filename: exportFilename, + }, + ); pdfExportService.downloadFile(result.blob, result.filename); setHasUnsavedChanges(false); @@ -158,26 +177,36 @@ export const usePageEditorExport = ({ setExportLoading(true); try { - const processedDocuments = documentManipulationService.applyDOMChangesToDocument( - displayDocument, - displayDocument, - splitPositions, - ); + const processedDocuments = + documentManipulationService.applyDOMChangesToDocument( + displayDocument, + displayDocument, + splitPositions, + ); - const normalizedDocuments = normalizeProcessedDocuments(processedDocuments); + const normalizedDocuments = + normalizeProcessedDocuments(processedDocuments); if ( - (Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) || - (!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0) + (Array.isArray(normalizedDocuments) && + normalizedDocuments.length === 0) || + (!Array.isArray(normalizedDocuments) && + normalizedDocuments.pages.length === 0) ) { - console.warn("Export skipped: no concrete pages available after filtering placeholders."); + console.warn( + "Export skipped: no concrete pages available after filtering placeholders.", + ); setExportLoading(false); return; } const sourceFiles = getSourceFiles(); const exportFilename = getExportFilename(); - const files = await exportProcessedDocumentsToFiles(normalizedDocuments, sourceFiles, exportFilename); + const files = await exportProcessedDocumentsToFiles( + normalizedDocuments, + sourceFiles, + exportFilename, + ); if (files.length > 1) { const JSZip = await import("jszip"); @@ -203,33 +232,50 @@ export const usePageEditorExport = ({ console.error("Export failed:", error); setExportLoading(false); } - }, [displayDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges, setExportLoading]); + }, [ + displayDocument, + splitPositions, + getSourceFiles, + getExportFilename, + setHasUnsavedChanges, + setExportLoading, + ]); const applyChanges = useCallback(async () => { if (!displayDocument) return; setExportLoading(true); try { - const processedDocuments = documentManipulationService.applyDOMChangesToDocument( - displayDocument, - displayDocument, - splitPositions, - ); + const processedDocuments = + documentManipulationService.applyDOMChangesToDocument( + displayDocument, + displayDocument, + splitPositions, + ); - const normalizedDocuments = normalizeProcessedDocuments(processedDocuments); + const normalizedDocuments = + normalizeProcessedDocuments(processedDocuments); if ( - (Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) || - (!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0) + (Array.isArray(normalizedDocuments) && + normalizedDocuments.length === 0) || + (!Array.isArray(normalizedDocuments) && + normalizedDocuments.pages.length === 0) ) { - console.warn("Apply changes skipped: no concrete pages available after filtering placeholders."); + console.warn( + "Apply changes skipped: no concrete pages available after filtering placeholders.", + ); setExportLoading(false); return; } const sourceFiles = getSourceFiles(); const exportFilename = getExportFilename(); - const files = await exportProcessedDocumentsToFiles(normalizedDocuments, sourceFiles, exportFilename); + const files = await exportProcessedDocumentsToFiles( + normalizedDocuments, + sourceFiles, + exportFilename, + ); // Add "_multitool" suffix to filenames const renamedFiles = files.map((file) => { diff --git a/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts b/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts index 69b1edeb9..13a96ec3e 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageEditorState.ts @@ -52,7 +52,9 @@ export function usePageEditorState(): PageEditorState { // Helper functions const togglePage = useCallback((pageId: string) => { setSelectedPageIds((prev) => { - const newSelection = prev.includes(pageId) ? prev.filter((id) => id !== pageId) : [...prev, pageId]; + const newSelection = prev.includes(pageId) + ? prev.filter((id) => id !== pageId) + : [...prev, pageId]; return newSelection; }); }, []); // Empty deps - uses updater function so always has latest state @@ -60,7 +62,9 @@ export function usePageEditorState(): PageEditorState { const toggleSelectAll = useCallback((allPageIds: string[]) => { if (!allPageIds.length) return; - setSelectedPageIds((prev) => (prev.length === allPageIds.length ? [] : allPageIds)); + setSelectedPageIds((prev) => + prev.length === allPageIds.length ? [] : allPageIds, + ); }, []); const animateReorder = useCallback(() => { diff --git a/frontend/src/core/components/pageEditor/hooks/usePageSelectionManager.ts b/frontend/src/core/components/pageEditor/hooks/usePageSelectionManager.ts index 7e5ecab33..e19b64f0c 100644 --- a/frontend/src/core/components/pageEditor/hooks/usePageSelectionManager.ts +++ b/frontend/src/core/components/pageEditor/hooks/usePageSelectionManager.ts @@ -53,7 +53,11 @@ export const usePageSelectionManager = ({ ); useEffect(() => { - if (displayDocument && displayDocument.pages.length > 0 && !hasInitializedSelection.current) { + if ( + displayDocument && + displayDocument.pages.length > 0 && + !hasInitializedSelection.current + ) { const allPageIds = displayDocument.pages.map((page) => page.id); setSelectedPageIds(allPageIds); setSelectionMode(true); @@ -128,4 +132,6 @@ export const usePageSelectionManager = ({ }; }; -export type UsePageSelectionManagerReturn = ReturnType; +export type UsePageSelectionManagerReturn = ReturnType< + typeof usePageSelectionManager +>; diff --git a/frontend/src/core/components/pageEditor/hooks/useUndoManagerState.ts b/frontend/src/core/components/pageEditor/hooks/useUndoManagerState.ts index 77da05678..86fdf347d 100644 --- a/frontend/src/core/components/pageEditor/hooks/useUndoManagerState.ts +++ b/frontend/src/core/components/pageEditor/hooks/useUndoManagerState.ts @@ -6,7 +6,9 @@ interface UseUndoManagerStateParams { setHasUnsavedChanges: (dirty: boolean) => void; } -export const useUndoManagerState = ({ setHasUnsavedChanges }: UseUndoManagerStateParams) => { +export const useUndoManagerState = ({ + setHasUnsavedChanges, +}: UseUndoManagerStateParams) => { const undoManagerRef = useRef(new UndoManager()); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); diff --git a/frontend/src/core/components/pageEditor/pageEditorRightRailButtons.tsx b/frontend/src/core/components/pageEditor/pageEditorRightRailButtons.tsx index f3cd59a13..97a5ffff1 100644 --- a/frontend/src/core/components/pageEditor/pageEditorRightRailButtons.tsx +++ b/frontend/src/core/components/pageEditor/pageEditorRightRailButtons.tsx @@ -1,6 +1,9 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons"; +import { + useRightRailButtons, + RightRailButtonWithAction, +} from "@app/hooks/useRightRailButtons"; import LocalIcon from "@app/components/shared/LocalIcon"; import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton"; @@ -22,7 +25,9 @@ interface PageEditorRightRailButtonsParams { closePdf: () => void; } -export function usePageEditorRightRailButtons(params: PageEditorRightRailButtonsParams) { +export function usePageEditorRightRailButtons( + params: PageEditorRightRailButtonsParams, +) { const { totalPages, selectedPageCount, @@ -46,9 +51,18 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons // Lift i18n labels out of memo for clarity const selectAllLabel = t("rightRail.selectAll", "Select All"); const deselectAllLabel = t("rightRail.deselectAll", "Deselect All"); - const selectByNumberLabel = t("rightRail.selectByNumber", "Select by Page Numbers"); - const deleteSelectedLabel = t("rightRail.deleteSelected", "Delete Selected Pages"); - const exportSelectedLabel = t("rightRail.exportSelected", "Export Selected Pages"); + const selectByNumberLabel = t( + "rightRail.selectByNumber", + "Select by Page Numbers", + ); + const deleteSelectedLabel = t( + "rightRail.deleteSelected", + "Delete Selected Pages", + ); + const exportSelectedLabel = t( + "rightRail.exportSelected", + "Export Selected Pages", + ); const saveChangesLabel = t("rightRail.saveChanges", "Save Changes"); const closePdfLabel = t("rightRail.closePdf", "Close PDF"); @@ -67,7 +81,13 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons }, { id: "page-deselect-all", - icon: , + icon: ( + + ), tooltip: deselectAllLabel, ariaLabel: deselectAllLabel, section: "top" as const, @@ -99,7 +119,13 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons }, { id: "page-delete-selected", - icon: , + icon: ( + + ), tooltip: deleteSelectedLabel, ariaLabel: deleteSelectedLabel, section: "top" as const, diff --git a/frontend/src/core/components/pageEditor/utils/splitPositions.ts b/frontend/src/core/components/pageEditor/utils/splitPositions.ts index afb9d2847..ee8f03988 100644 --- a/frontend/src/core/components/pageEditor/utils/splitPositions.ts +++ b/frontend/src/core/components/pageEditor/utils/splitPositions.ts @@ -3,7 +3,9 @@ import { PDFDocument } from "@app/types/pageEditor"; /** * Build a map from page ID to its index in the provided document. */ -export function buildPageIdIndexMap(document: PDFDocument | null): Map { +export function buildPageIdIndexMap( + document: PDFDocument | null, +): Map { const map = new Map(); if (!document) return map; document.pages.forEach((page, index) => { @@ -16,7 +18,10 @@ export function buildPageIdIndexMap(document: PDFDocument | null): Map): Set { +export function convertSplitPageIdsToIndexes( + document: PDFDocument | null, + splitPageIds: Set, +): Set { const indexes = new Set(); if (!document || !splitPageIds || splitPageIds.size === 0) { return indexes; diff --git a/frontend/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx b/frontend/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx index 0c595d5e7..aa6c76afe 100644 --- a/frontend/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx +++ b/frontend/src/core/components/quickAccessBar/QuickAccessBarFooterExtensions.tsx @@ -7,6 +7,8 @@ interface QuickAccessBarFooterExtensionsProps { className?: string; } -export function QuickAccessBarFooterExtensions(_props: QuickAccessBarFooterExtensionsProps) { +export function QuickAccessBarFooterExtensions( + _props: QuickAccessBarFooterExtensionsProps, +) { return null; } diff --git a/frontend/src/core/components/rightRail/RightRailFooterExtensions.tsx b/frontend/src/core/components/rightRail/RightRailFooterExtensions.tsx index 5f0021f1d..1a90673d6 100644 --- a/frontend/src/core/components/rightRail/RightRailFooterExtensions.tsx +++ b/frontend/src/core/components/rightRail/RightRailFooterExtensions.tsx @@ -2,6 +2,8 @@ interface RightRailFooterExtensionsProps { className?: string; } -export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) { +export function RightRailFooterExtensions( + _props: RightRailFooterExtensionsProps, +) { return null; } diff --git a/frontend/src/core/components/shared/AllToolsNavButton.tsx b/frontend/src/core/components/shared/AllToolsNavButton.tsx index b6879f839..40e6f5641 100644 --- a/frontend/src/core/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/core/components/shared/AllToolsNavButton.tsx @@ -2,7 +2,10 @@ import React from "react"; import { useTranslation } from "react-i18next"; import AppsIcon from "@mui/icons-material/AppsRounded"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; -import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext"; +import { + useNavigationState, + useNavigationActions, +} from "@app/contexts/NavigationContext"; import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation"; import { handleUnlessSpecialClick } from "@app/utils/clickHandlers"; import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton"; @@ -12,9 +15,17 @@ interface AllToolsNavButtonProps { setActiveButton: (id: string) => void; } -const AllToolsNavButton: React.FC = ({ activeButton, setActiveButton }) => { +const AllToolsNavButton: React.FC = ({ + activeButton, + setActiveButton, +}) => { const { t } = useTranslation(); - const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); + const { + handleReaderToggle, + handleBackToTools, + selectedToolKey, + leftPanelView, + } = useToolWorkflow(); const { hasUnsavedChanges } = useNavigationState(); const { actions: navigationActions } = useNavigationActions(); const { getHomeNavigation } = useSidebarNavigation(); @@ -35,7 +46,10 @@ 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 isActive = + activeButton === "tools" && + !selectedToolKey && + leftPanelView === "toolPicker"; const navProps = getHomeNavigation(); diff --git a/frontend/src/core/components/shared/AppConfigModal.tsx b/frontend/src/core/components/shared/AppConfigModal.tsx index 20337ab70..765d09941 100644 --- a/frontend/src/core/components/shared/AppConfigModal.tsx +++ b/frontend/src/core/components/shared/AppConfigModal.tsx @@ -1,4 +1,10 @@ -import React, { useMemo, useState, useEffect, useCallback, useRef } from "react"; +import React, { + useMemo, + useState, + useEffect, + useCallback, + useRef, +} from "react"; import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core"; import { useNavigate, useLocation } from "react-router-dom"; import LocalIcon from "@app/components/shared/LocalIcon"; @@ -7,9 +13,15 @@ import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import "@app/components/shared/AppConfigModal.css"; import { useIsMobile } from "@app/hooks/useIsMobile"; -import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex"; +import { + Z_INDEX_CONFIG_MODAL, + Z_INDEX_OVER_CONFIG_MODAL, +} from "@app/styles/zIndex"; import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; -import { UnsavedChangesProvider, useUnsavedChanges } from "@app/contexts/UnsavedChangesContext"; +import { + UnsavedChangesProvider, + useUnsavedChanges, +} from "@app/contexts/UnsavedChangesContext"; import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar"; interface AppConfigModalProps { @@ -17,7 +29,10 @@ interface AppConfigModalProps { onClose: () => void; } -const AppConfigModalInner: React.FC = ({ opened, onClose }) => { +const AppConfigModalInner: React.FC = ({ + opened, + onClose, +}) => { const [active, setActive] = useState("general"); const isMobile = useIsMobile(); const navigate = useNavigate(); @@ -42,7 +57,11 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) const section = getSectionFromPath(location.pathname); if (opened && section) { setActive(section); - } else if (opened && location.pathname.startsWith("/settings") && !section) { + } else if ( + opened && + location.pathname.startsWith("/settings") && + !section + ) { // If at /settings without a section, redirect to general navigate("/settings/general", { replace: true }); } @@ -64,7 +83,11 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) } }; window.addEventListener("appConfig:navigate", handler as EventListener); - return () => window.removeEventListener("appConfig:navigate", handler as EventListener); + return () => + window.removeEventListener( + "appConfig:navigate", + handler as EventListener, + ); }, [navigate]); const colors = useMemo( @@ -86,7 +109,11 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) const loginEnabled = config?.enableLogin ?? false; // Left navigation structure and icons - const configNavSections = useConfigNavSections(isAdmin, runningEE, loginEnabled); + const configNavSections = useConfigNavSections( + isAdmin, + runningEE, + loginEnabled, + ); const activeLabel = useMemo(() => { for (const section of configNavSections) { @@ -151,7 +178,12 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) {configNavSections.map((section) => (
{!isMobile && ( - + {section.title} )} @@ -159,10 +191,14 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) {section.items.map((item) => { const isActive = active === item.key; const isDisabled = item.disabled ?? false; - const color = isActive ? colors.navItemActive : colors.navItem; + const color = isActive + ? colors.navItemActive + : colors.navItem; const iconSize = isMobile ? 28 : 18; const showPlanWarning = - item.key === "adminPlan" && licenseAlert.active && licenseAlert.audience === "admin"; + item.key === "adminPlan" && + licenseAlert.active && + licenseAlert.audience === "admin"; const navItemContent = (
= ({ opened, onClose }) }} className={`modal-nav-item ${isMobile ? "mobile" : ""}`} style={{ - background: isActive ? colors.navItemActiveBg : "transparent", + background: isActive + ? colors.navItemActiveBg + : "transparent", opacity: isDisabled ? 0.6 : 1, cursor: isDisabled ? "not-allowed" : "pointer", }} data-tour={`admin-${item.key}-nav`} > - + {!isMobile && ( {item.label} {item.badge && ( - + {item.badge} )} @@ -196,7 +244,9 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) icon="warning-rounded" width={14} height={14} - style={{ color: "var(--mantine-color-orange-7)" }} + style={{ + color: "var(--mantine-color-orange-7)", + }} /> )} @@ -215,7 +265,9 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) {navItemContent} ) : ( - {navItemContent} + + {navItemContent} + ); })}
@@ -239,8 +291,18 @@ const AppConfigModalInner: React.FC = ({ opened, onClose }) {activeLabel}
- - + + diff --git a/frontend/src/core/components/shared/BulkShareModal.tsx b/frontend/src/core/components/shared/BulkShareModal.tsx index 346dddb8d..72fc9a007 100644 --- a/frontend/src/core/components/shared/BulkShareModal.tsx +++ b/frontend/src/core/components/shared/BulkShareModal.tsx @@ -1,5 +1,15 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from "@mantine/core"; +import { + Modal, + Stack, + Text, + Button, + Group, + Alert, + TextInput, + Paper, + Select, +} from "@mantine/core"; import LinkIcon from "@mui/icons-material/Link"; import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded"; import { useTranslation } from "react-i18next"; @@ -22,7 +32,12 @@ interface BulkShareModalProps { onShared?: () => Promise | void; } -const BulkShareModal: React.FC = ({ opened, onClose, files, onShared }) => { +const BulkShareModal: React.FC = ({ + opened, + onClose, + files, + onShared, +}) => { const { t } = useTranslation(); const { config } = useAppConfig(); const { actions } = useFileActions(); @@ -30,7 +45,9 @@ const BulkShareModal: React.FC = ({ opened, onClose, files, const [isWorking, setIsWorking] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [shareToken, setShareToken] = useState(null); - const [shareRole, setShareRole] = useState<"editor" | "commenter" | "viewer">("editor"); + const [shareRole, setShareRole] = useState<"editor" | "commenter" | "viewer">( + "editor", + ); useEffect(() => { if (!opened) { @@ -50,7 +67,9 @@ const BulkShareModal: React.FC = ({ opened, onClose, files, if (!shareToken) return ""; const frontendUrl = (config?.frontendUrl || "").trim(); if (frontendUrl) { - const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl; + const normalized = frontendUrl.endsWith("/") + ? frontendUrl.slice(0, -1) + : frontendUrl; return `${normalized}/share/${shareToken}`; } return absoluteWithBasePath(`/share/${shareToken}`); @@ -58,9 +77,12 @@ const BulkShareModal: React.FC = ({ opened, onClose, files, const createShareLink = useCallback( async (storedFileId: number) => { - const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, { - accessRole: shareRole, - }); + const response = await apiClient.post( + `/api/v1/storage/files/${storedFileId}/shares/links`, + { + accessRole: shareRole, + }, + ); return response.data as { token?: string }; }, [shareRole], @@ -81,11 +103,26 @@ const BulkShareModal: React.FC = ({ opened, onClose, files, setShareToken(null); try { - const rootIds = Array.from(new Set(files.map((file) => (file.originalFileId || file.id) as FileId))); - const remoteIds = Array.from(new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[])); - const existingRemoteId = rootIds.length === 1 && remoteIds.length === 1 ? remoteIds[0] : undefined; + const rootIds = Array.from( + new Set( + files.map((file) => (file.originalFileId || file.id) as FileId), + ), + ); + const remoteIds = Array.from( + new Set( + files.map((file) => file.remoteStorageId).filter(Boolean) as number[], + ), + ); + const existingRemoteId = + rootIds.length === 1 && remoteIds.length === 1 + ? remoteIds[0] + : undefined; - const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChains(rootIds, existingRemoteId); + const { + remoteId: storedId, + updatedAt, + chain, + } = await uploadHistoryChains(rootIds, existingRemoteId); const shareResponse = await createShareLink(storedId); setShareToken(shareResponse.token ?? null); @@ -118,7 +155,12 @@ const BulkShareModal: React.FC = ({ opened, onClose, files, } } catch (error: any) { console.error("Failed to generate share link:", error); - setErrorMessage(t("storageShare.failure", "Unable to generate a share link. Please try again.")); + setErrorMessage( + t( + "storageShare.failure", + "Unable to generate a share link. Please try again.", + ), + ); } finally { setIsWorking(false); } @@ -159,7 +201,10 @@ const BulkShareModal: React.FC = ({ opened, onClose, files, - {t("storageShare.bulkDescription", "Create one link to share all selected files with signed-in users.")} + {t( + "storageShare.bulkDescription", + "Create one link to share all selected files with signed-in users.", + )} {t("storageShare.fileCount", "{{count}} files selected", { @@ -170,13 +215,22 @@ const BulkShareModal: React.FC = ({ opened, onClose, files, {errorMessage && ( - + {errorMessage} )} {!shareLinksEnabled && ( - - {t("storageShare.linksDisabledBody", "Share links are disabled by your server settings.")} + + {t( + "storageShare.linksDisabledBody", + "Share links are disabled by your server settings.", + )} )} @@ -191,7 +245,9 @@ const BulkShareModal: React.FC = ({ opened, onClose, files, - )} diff --git a/frontend/src/core/components/shared/HoverActionMenu.tsx b/frontend/src/core/components/shared/HoverActionMenu.tsx index 3fc257716..c43225854 100644 --- a/frontend/src/core/components/shared/HoverActionMenu.tsx +++ b/frontend/src/core/components/shared/HoverActionMenu.tsx @@ -20,7 +20,12 @@ interface HoverActionMenuProps { className?: string; } -const HoverActionMenu: React.FC = ({ show, actions, position = "inside", className = "" }) => { +const HoverActionMenu: React.FC = ({ + show, + actions, + position = "inside", + className = "", +}) => { const visibleActions = actions.filter((action) => !action.hidden); if (visibleActions.length === 0) { diff --git a/frontend/src/core/components/shared/InfoBanner.tsx b/frontend/src/core/components/shared/InfoBanner.tsx index 983a2a3d0..a9fde9ae4 100644 --- a/frontend/src/core/components/shared/InfoBanner.tsx +++ b/frontend/src/core/components/shared/InfoBanner.tsx @@ -97,8 +97,19 @@ export const InfoBanner: React.FC = ({ alignItems: "center", }} > - - + + = ({ /> {title && ( - + {title} )} - + {message} @@ -124,7 +144,9 @@ export const InfoBanner: React.FC = ({ size="xs" onClick={onButtonClick} loading={loading} - leftSection={} + leftSection={ + + } > {buttonText} diff --git a/frontend/src/core/components/shared/LandingActions.tsx b/frontend/src/core/components/shared/LandingActions.tsx index b64df83a7..23faf275c 100644 --- a/frontend/src/core/components/shared/LandingActions.tsx +++ b/frontend/src/core/components/shared/LandingActions.tsx @@ -14,7 +14,12 @@ type LandingActionsProps = { onFileSelect: (event: React.ChangeEvent) => void; }; -export function LandingActions({ fileInputRef, onUploadClick, onMobileUploadClick, onFileSelect }: LandingActionsProps) { +export function LandingActions({ + fileInputRef, + onUploadClick, + onMobileUploadClick, + onFileSelect, +}: LandingActionsProps) { const terminology = useFileActionTerminology(); const { openFilesModal } = useFilesModalContext(); const icons = useFileActionIcons(); @@ -26,7 +31,14 @@ export function LandingActions({ fileInputRef, onUploadClick, onMobileUploadClic )} {onOpenInPageEditor && ( - )} diff --git a/frontend/src/core/components/shared/ObscuredOverlay.tsx b/frontend/src/core/components/shared/ObscuredOverlay.tsx index 592a79bef..ba8140d30 100644 --- a/frontend/src/core/components/shared/ObscuredOverlay.tsx +++ b/frontend/src/core/components/shared/ObscuredOverlay.tsx @@ -30,9 +30,15 @@ export default function ObscuredOverlay({ }} >
- {overlayMessage &&
{overlayMessage}
} + {overlayMessage && ( +
{overlayMessage}
+ )} {buttonText && onButtonClick && ( - )} diff --git a/frontend/src/core/components/shared/PageEditorFileDropdown.tsx b/frontend/src/core/components/shared/PageEditorFileDropdown.tsx index 2d850d717..4cbd84b15 100644 --- a/frontend/src/core/components/shared/PageEditorFileDropdown.tsx +++ b/frontend/src/core/components/shared/PageEditorFileDropdown.tsx @@ -28,13 +28,27 @@ interface FileMenuItemProps { onReorder: (fromIndex: number, toIndex: number) => void; } -const FileMenuItem: React.FC = ({ file, index, colorIndex, onToggleSelection, onReorder }) => { - const { itemRef, isDragging, isDragOver, dropPosition, movedRef, onPointerDown, onPointerMove, onPointerUp } = - useFileItemDragDrop({ - fileId: file.fileId, - index, - onReorder, - }); +const FileMenuItem: React.FC = ({ + file, + index, + colorIndex, + onToggleSelection, + onReorder, +}) => { + const { + itemRef, + isDragging, + isDragOver, + dropPosition, + movedRef, + onPointerDown, + onPointerMove, + onPointerUp, + } = useFileItemDragDrop({ + fileId: file.fileId, + index, + onReorder, + }); const itemName = file?.name || "Untitled"; const fileColorBorder = getFileColorWithOpacity(colorIndex, 1); @@ -52,7 +66,9 @@ const FileMenuItem: React.FC = ({ file, index, colorIndex, on
= ({ file, index, colorIndex, on style={{ padding: "0.75rem 0.75rem", cursor: isDragging ? "grabbing" : "grab", - backgroundColor: file.isSelected ? "rgba(0, 0, 0, 0.05)" : "transparent", + backgroundColor: file.isSelected + ? "rgba(0, 0, 0, 0.05)" + : "transparent", borderLeft: `6px solid ${fileColorBorder}`, opacity: isDragging ? 0.5 : 1, transition: "opacity 0.2s ease-in-out, background-color 0.15s ease", @@ -83,16 +101,18 @@ const FileMenuItem: React.FC = ({ file, index, colorIndex, on }} onMouseEnter={(e) => { if (!isDragging) { - (e.currentTarget as HTMLDivElement).style.backgroundColor = "rgba(0, 0, 0, 0.05)"; - (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover; + (e.currentTarget as HTMLDivElement).style.backgroundColor = + "rgba(0, 0, 0, 0.05)"; + (e.currentTarget as HTMLDivElement).style.borderLeftColor = + fileColorBorderHover; } }} onMouseLeave={(e) => { if (!isDragging) { - (e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected - ? "rgba(0, 0, 0, 0.05)" - : "transparent"; - (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder; + (e.currentTarget as HTMLDivElement).style.backgroundColor = + file.isSelected ? "rgba(0, 0, 0, 0.05)" : "transparent"; + (e.currentTarget as HTMLDivElement).style.borderLeftColor = + fileColorBorder; } }} > @@ -155,11 +175,18 @@ export const PageEditorFileDropdown: React.FC = ({ return ( -
+
{switchingTo === "pageEditor" ? ( ) : ( - + )} {selectedCount}/{totalCount} files selected @@ -208,15 +235,25 @@ export const PageEditorFileDropdown: React.FC = ({ transition: "background-color 0.15s ease", }} onMouseEnter={(e) => { - (e.currentTarget as HTMLDivElement).style.backgroundColor = "rgba(59, 130, 246, 0.25)"; + (e.currentTarget as HTMLDivElement).style.backgroundColor = + "rgba(59, 130, 246, 0.25)"; }} onMouseLeave={(e) => { - (e.currentTarget as HTMLDivElement).style.backgroundColor = "transparent"; + (e.currentTarget as HTMLDivElement).style.backgroundColor = + "transparent"; }} > - - + + Add File diff --git a/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx b/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx index c44025bf3..47cda328d 100644 --- a/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx +++ b/frontend/src/core/components/shared/PageSelectionSyntaxHint.tsx @@ -14,7 +14,11 @@ interface PageSelectionSyntaxHintProps { const FALLBACK_MAX_PAGES = 100000; // large upper bound for syntax validation without a document -const PageSelectionSyntaxHint = ({ input, maxPages, variant = "panel" }: PageSelectionSyntaxHintProps) => { +const PageSelectionSyntaxHint = ({ + input, + maxPages, + variant = "panel", +}: PageSelectionSyntaxHintProps) => { const [syntaxError, setSyntaxError] = useState(null); const { t } = useTranslation(); @@ -26,20 +30,42 @@ const PageSelectionSyntaxHint = ({ input, maxPages, variant = "panel" }: PageSel } try { - const { warning } = parseSelectionWithDiagnostics(text, maxPages && maxPages > 0 ? maxPages : FALLBACK_MAX_PAGES); + const { warning } = parseSelectionWithDiagnostics( + text, + maxPages && maxPages > 0 ? maxPages : FALLBACK_MAX_PAGES, + ); setSyntaxError( - warning ? t("bulkSelection.syntaxError", "There is a syntax issue. See Page Selection tips for help.") : null, + warning + ? t( + "bulkSelection.syntaxError", + "There is a syntax issue. See Page Selection tips for help.", + ) + : null, ); } catch { - setSyntaxError(t("bulkSelection.syntaxError", "There is a syntax issue. See Page Selection tips for help.")); + setSyntaxError( + t( + "bulkSelection.syntaxError", + "There is a syntax issue. See Page Selection tips for help.", + ), + ); } }, [input, maxPages]); if (!syntaxError) return null; return ( -
- +
+ {syntaxError}
diff --git a/frontend/src/core/components/shared/PrivateContent.tsx b/frontend/src/core/components/shared/PrivateContent.tsx index a2b048e95..270fe544a 100644 --- a/frontend/src/core/components/shared/PrivateContent.tsx +++ b/frontend/src/core/components/shared/PrivateContent.tsx @@ -23,7 +23,12 @@ interface PrivateContentProps extends React.HTMLAttributes { * preview * */ -export const PrivateContent: React.FC = ({ children, className = "", style, ...props }) => { +export const PrivateContent: React.FC = ({ + children, + className = "", + style, + ...props +}) => { const combinedClassName = `ph-no-capture${className ? ` ${className}` : ""}`; const combinedStyle = { display: "contents" as const, ...style }; diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index db57bed92..16d0ff096 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -1,4 +1,11 @@ -import React, { useState, useRef, forwardRef, useEffect, useMemo, useCallback } from "react"; +import React, { + useState, + useRef, + forwardRef, + useEffect, + useMemo, + useCallback, +} from "react"; import { createPortal } from "react-dom"; import { Stack, Divider, Menu, Indicator } from "@mantine/core"; import { useTranslation } from "react-i18next"; @@ -12,7 +19,10 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi import { useFilesModalContext } from "@app/contexts/FilesModalContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useFileSelection, useFileState } from "@app/contexts/file/fileHooks"; -import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext"; +import { + useNavigationState, + useNavigationActions, +} from "@app/contexts/NavigationContext"; import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation"; import { handleUnlessSpecialClick } from "@app/utils/clickHandlers"; import { ButtonConfig } from "@app/types/sidebar"; @@ -67,7 +77,8 @@ const QuickAccessBar = forwardRef((_, ref) => { const { selectedFiles, selectedFileIds } = useFileSelection(); const { state, selectors } = useFileState(); const { actions } = useFileActions(); - const { hasUnsavedChanges, workbench: currentWorkbench } = useNavigationState(); + const { hasUnsavedChanges, workbench: currentWorkbench } = + useNavigationState(); const { actions: navigationActions } = useNavigationActions(); const { getToolNavigation } = useSidebarNavigation(); const { config } = useAppConfig(); @@ -76,18 +87,29 @@ const QuickAccessBar = forwardRef((_, ref) => { const [activeButton, setActiveButton] = useState("tools"); const [accessMenuOpen, setAccessMenuOpen] = useState(false); const [accessInviteOpen, setAccessInviteOpen] = useState(false); - const [selectedAccessFileId, setSelectedAccessFileId] = useState(null); + const [selectedAccessFileId, setSelectedAccessFileId] = useState< + string | null + >(null); const [shareManageOpen, setShareManageOpen] = useState(false); const scrollableRef = useRef(null); const accessButtonRef = useRef(null); const accessPopoverRef = useRef(null); - const [accessPopoverPosition, setAccessPopoverPosition] = useState({ top: 160, left: 84 }); + const [accessPopoverPosition, setAccessPopoverPosition] = useState({ + top: 160, + left: 84, + }); const { sharingEnabled, shareLinksEnabled } = useSharingEnabled(); const groupSigningEnabled = useGroupSigningEnabled(); const isSignWorkbenchActive = - currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE || currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE; + currentWorkbench === SIGN_REQUEST_WORKBENCH_TYPE || + currentWorkbench === SESSION_DETAIL_WORKBENCH_TYPE; const [inviteRows, setInviteRows] = useState< - Array<{ id: number; email: string; role: "editor" | "commenter" | "viewer"; error?: string }> + Array<{ + id: number; + email: string; + role: "editor" | "commenter" | "viewer"; + error?: string; + }> >([{ id: Date.now(), email: "", role: "editor" }]); const [isInviting, setIsInviting] = useState(false); @@ -101,8 +123,12 @@ const QuickAccessBar = forwardRef((_, ref) => { if (!groupSigningEnabled) return; const fetchCount = async () => { try { - const response = await apiClient.get("/api/v1/security/cert-sign/sign-requests"); - const pending = response.data.filter((r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED").length; + const response = await apiClient.get( + "/api/v1/security/cert-sign/sign-requests", + ); + const pending = response.data.filter( + (r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED", + ).length; setPendingSignCount(pending); } catch { /* silent — avoid noisy background error toasts */ @@ -118,8 +144,12 @@ const QuickAccessBar = forwardRef((_, ref) => { if (!signMenuOpen && groupSigningEnabled) { const timeout = setTimeout(async () => { try { - const response = await apiClient.get("/api/v1/security/cert-sign/sign-requests"); - const pending = response.data.filter((r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED").length; + const response = await apiClient.get( + "/api/v1/security/cert-sign/sign-requests", + ); + const pending = response.data.filter( + (r) => r.myStatus !== "SIGNED" && r.myStatus !== "DECLINED", + ).length; setPendingSignCount(pending); } catch { /* silent */ @@ -131,16 +161,28 @@ const QuickAccessBar = forwardRef((_, ref) => { const configButtonIcon = useConfigButtonIcon(); - const { tooltipOpen, manualCloseOnly, showCloseButton, toursMenuOpen, setToursMenuOpen, handleTooltipOpenChange } = - useToursTooltip(); + const { + tooltipOpen, + manualCloseOnly, + showCloseButton, + toursMenuOpen, + setToursMenuOpen, + handleTooltipOpenChange, + } = useToursTooltip(); - const isRTL = typeof document !== "undefined" && document.documentElement.dir === "rtl"; + const isRTL = + typeof document !== "undefined" && document.documentElement.dir === "rtl"; const hasSelectedFiles = selectedFiles.length > 0; const selectedFileStubs = useMemo( - () => selectedFileIds.map((id) => selectors.getStirlingFileStub(id)).filter((x): x is StirlingFileStub => Boolean(x)), + () => + selectedFileIds + .map((id) => selectors.getStirlingFileStub(id)) + .filter((x): x is StirlingFileStub => Boolean(x)), [selectedFileIds, selectors, state.files.byId], ); - const selectedAccessFileStub = selectedFileStubs.find((file) => file.id === selectedAccessFileId) || selectedFileStubs[0]; + const selectedAccessFileStub = + selectedFileStubs.find((file) => file.id === selectedAccessFileId) || + selectedFileStubs[0]; useEffect(() => { if (!hasSelectedFiles) { setAccessMenuOpen(false); @@ -148,7 +190,10 @@ const QuickAccessBar = forwardRef((_, ref) => { setAccessInviteOpen(false); return; } - if (!selectedAccessFileId || !selectedFiles.some((file) => file.fileId === selectedAccessFileId)) { + if ( + !selectedAccessFileId || + !selectedFiles.some((file) => file.fileId === selectedAccessFileId) + ) { setSelectedAccessFileId(selectedFiles[0]?.fileId ?? null); } }, [hasSelectedFiles, selectedAccessFileId, selectedFiles]); @@ -187,7 +232,9 @@ const QuickAccessBar = forwardRef((_, ref) => { if (accessButtonRef.current?.contains(target)) return; // Check if click is inside a Mantine dropdown - const mantineDropdown = (target as Element).closest?.(".mantine-Combobox-dropdown, .mantine-Popover-dropdown"); + const mantineDropdown = (target as Element).closest?.( + ".mantine-Combobox-dropdown, .mantine-Popover-dropdown", + ); if (mantineDropdown) return; setAccessMenuOpen(false); @@ -211,7 +258,9 @@ const QuickAccessBar = forwardRef((_, ref) => { try { const parsed = new URL(frontendUrl); if (parsed.protocol === "http:" || parsed.protocol === "https:") { - const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl; + const normalized = frontendUrl.endsWith("/") + ? frontendUrl.slice(0, -1) + : frontendUrl; return `${normalized}/share/`; } } catch { @@ -233,7 +282,11 @@ const QuickAccessBar = forwardRef((_, ref) => { } const originalFileId = (fileStub.originalFileId || fileStub.id) as FileId; const remoteId = fileStub.remoteStorageId as number | undefined; - const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChain(originalFileId, remoteId); + const { + remoteId: storedId, + updatedAt, + chain, + } = await uploadHistoryChain(originalFileId, remoteId); for (const stub of chain) { actions.updateStirlingFileStub(stub.id, { remoteStorageId: storedId, @@ -266,7 +319,10 @@ const QuickAccessBar = forwardRef((_, ref) => { if (selectedFileStubs.length > 1) { alert({ alertType: "warning", - title: t("storageShare.selectSingleFile", "Select a single file to manage sharing."), + title: t( + "storageShare.selectSingleFile", + "Select a single file to manage sharing.", + ), expandable: false, durationMs: 2500, }); @@ -275,7 +331,10 @@ const QuickAccessBar = forwardRef((_, ref) => { if (selectedAccessFileStub?.remoteOwnedByCurrentUser === false) { alert({ alertType: "warning", - title: t("storageShare.ownerOnly", "Only the owner can manage sharing."), + title: t( + "storageShare.ownerOnly", + "Only the owner can manage sharing.", + ), expandable: false, durationMs: 2500, }); @@ -291,19 +350,40 @@ const QuickAccessBar = forwardRef((_, ref) => { console.error("Failed to upload file for sharing:", error); alert({ alertType: "warning", - title: t("storageUpload.failure", "Upload failed. Please check your login and storage settings."), + title: t( + "storageUpload.failure", + "Upload failed. Please check your login and storage settings.", + ), expandable: false, durationMs: 3000, }); } - }, [ensureStoredFile, selectedAccessFileStub, selectedFileStubs.length, sharingEnabled, t]); + }, [ + ensureStoredFile, + selectedAccessFileStub, + selectedFileStubs.length, + sharingEnabled, + t, + ]); const handleInviteRowChange = useCallback( - (id: number, updates: Partial<{ email: string; role: "editor" | "commenter" | "viewer"; error?: string }>) => { + ( + id: number, + updates: Partial<{ + email: string; + role: "editor" | "commenter" | "viewer"; + error?: string; + }>, + ) => { setInviteRows((prev) => prev.map((row) => { if (row.id !== id) return row; - const nextError = Object.prototype.hasOwnProperty.call(updates, "error") ? updates.error : row.error; + const nextError = Object.prototype.hasOwnProperty.call( + updates, + "error", + ) + ? updates.error + : row.error; return { ...row, ...updates, error: nextError }; }), ); @@ -312,11 +392,16 @@ const QuickAccessBar = forwardRef((_, ref) => { ); const handleAddInviteRow = useCallback(() => { - setInviteRows((prev) => [...prev, { id: Date.now(), email: "", role: "editor" }]); + setInviteRows((prev) => [ + ...prev, + { id: Date.now(), email: "", role: "editor" }, + ]); }, []); const handleRemoveInviteRow = useCallback((id: number) => { - setInviteRows((prev) => (prev.length > 1 ? prev.filter((row) => row.id !== id) : prev)); + setInviteRows((prev) => + prev.length > 1 ? prev.filter((row) => row.id !== id) : prev, + ); }, []); const handleSendInvites = useCallback(async () => { @@ -324,7 +409,10 @@ const QuickAccessBar = forwardRef((_, ref) => { if (selectedAccessFileStub.remoteOwnedByCurrentUser === false) { alert({ alertType: "warning", - title: t("storageShare.ownerOnly", "Only the owner can manage sharing."), + title: t( + "storageShare.ownerOnly", + "Only the owner can manage sharing.", + ), expandable: false, durationMs: 2500, }); @@ -334,7 +422,10 @@ const QuickAccessBar = forwardRef((_, ref) => { const trimmed = row.email.trim(); let error: string | undefined; if (!trimmed) { - error = t("storageShare.invalidUsername", "Enter a valid username or email address."); + error = t( + "storageShare.invalidUsername", + "Enter a valid username or email address.", + ); } return { ...row, email: trimmed, error }; }); @@ -363,14 +454,23 @@ const QuickAccessBar = forwardRef((_, ref) => { console.error("Failed to send invite:", error); alert({ alertType: "warning", - title: t("storageShare.userAddFailed", "Unable to share with that user."), + title: t( + "storageShare.userAddFailed", + "Unable to share with that user.", + ), expandable: false, durationMs: 3000, }); } finally { setIsInviting(false); } - }, [ensureStoredFile, inviteRows, resetInviteRows, selectedAccessFileStub, t]); + }, [ + ensureStoredFile, + inviteRows, + resetInviteRows, + selectedAccessFileStub, + t, + ]); const handleCopyShareLink = async () => { if (!selectedAccessFileStub) return; @@ -386,7 +486,10 @@ const QuickAccessBar = forwardRef((_, ref) => { if (selectedFileStubs.length > 1) { alert({ alertType: "warning", - title: t("storageShare.selectSingleFile", "Select a single file to copy a link."), + title: t( + "storageShare.selectSingleFile", + "Select a single file to copy a link.", + ), expandable: false, durationMs: 2500, }); @@ -395,7 +498,10 @@ const QuickAccessBar = forwardRef((_, ref) => { if (selectedAccessFileStub?.remoteOwnedByCurrentUser === false) { alert({ alertType: "warning", - title: t("storageShare.ownerOnly", "Only the owner can manage sharing."), + title: t( + "storageShare.ownerOnly", + "Only the owner can manage sharing.", + ), expandable: false, durationMs: 2500, }); @@ -408,7 +514,10 @@ const QuickAccessBar = forwardRef((_, ref) => { console.error("Failed to upload file for sharing:", error); alert({ alertType: "warning", - title: t("storageUpload.failure", "Upload failed. Please check your login and storage settings."), + title: t( + "storageUpload.failure", + "Upload failed. Please check your login and storage settings.", + ), expandable: false, durationMs: 3000, }); @@ -417,25 +526,37 @@ const QuickAccessBar = forwardRef((_, ref) => { } try { const storedId = await ensureStoredFile(selectedAccessFileStub); - const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(`/api/v1/storage/files/${storedId}`, { + const response = await apiClient.get<{ + shareLinks?: Array<{ token?: string }>; + }>(`/api/v1/storage/files/${storedId}`, { suppressErrorToast: true, }); const links = response.data?.shareLinks ?? []; let token = links[links.length - 1]?.token; if (!token) { - const shareResponse = await apiClient.post(`/api/v1/storage/files/${storedId}/shares/links`, { - accessRole: "editor", - }); + const shareResponse = await apiClient.post( + `/api/v1/storage/files/${storedId}/shares/links`, + { + accessRole: "editor", + }, + ); token = shareResponse.data?.token; if (token) { - actions.updateStirlingFileStub(selectedAccessFileStub.id, { remoteHasShareLinks: true }); - await fileStorage.updateFileMetadata(selectedAccessFileStub.id, { remoteHasShareLinks: true }); + actions.updateStirlingFileStub(selectedAccessFileStub.id, { + remoteHasShareLinks: true, + }); + await fileStorage.updateFileMetadata(selectedAccessFileStub.id, { + remoteHasShareLinks: true, + }); } } if (!token) { alert({ alertType: "warning", - title: t("storageShare.failure", "Unable to generate a share link. Please try again."), + title: t( + "storageShare.failure", + "Unable to generate a share link. Please try again.", + ), expandable: false, durationMs: 2500, }); @@ -475,14 +596,28 @@ const QuickAccessBar = forwardRef((_, ref) => { }; // Helper function to render navigation buttons with URL support - const renderNavButton = (config: ButtonConfig, index: number, shouldGuardNavigation = false) => { + const renderNavButton = ( + config: ButtonConfig, + index: number, + shouldGuardNavigation = false, + ) => { const isActive = !isSignWorkbenchActive && - isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); + 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; + config.type === "navigation" && + (config.id === "read" || config.id === "automate") + ? getToolNavigation(config.id) + : null; const handleClick = (e?: React.MouseEvent) => { // If there are unsaved changes and this button should guard navigation, show warning modal @@ -507,11 +642,21 @@ const QuickAccessBar = forwardRef((_, ref) => { border: "none", borderRadius: "0.5rem", } - : getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); + : getNavButtonStyle( + config, + activeButton, + isFilesModalOpen, + configModalOpen, + selectedToolKey, + leftPanelView, + ); // Render navigation button with conditional URL support return ( -
+
((_, ref) => { { id: "read", name: t("quickAccess.reader", "Reader"), - icon: , + icon: ( + + ), size: "md" as const, isRound: false, type: "navigation" as const, @@ -547,7 +698,13 @@ const QuickAccessBar = forwardRef((_, ref) => { { id: "automate", name: t("quickAccess.automate", "Automate"), - icon: , + icon: ( + + ), size: "md" as const, isRound: false, type: "navigation" as const, @@ -566,17 +723,28 @@ const QuickAccessBar = forwardRef((_, ref) => { // 'read' is always available (viewer mode) if (button.id === "read") return true; // Check if tool is actually available (not just present in registry) - const availability = toolAvailability[button.id as keyof typeof toolAvailability]; + const availability = + toolAvailability[button.id as keyof typeof toolAvailability]; return availability?.available !== false; }), - [t, setActiveButton, handleReaderToggle, selectedToolKey, resetTool, handleToolSelect, toolAvailability], + [ + t, + setActiveButton, + handleReaderToggle, + selectedToolKey, + resetTool, + handleToolSelect, + toolAvailability, + ], ); const middleButtons: ButtonConfig[] = [ { id: "files", name: t("quickAccess.files", "Files"), - icon: , + icon: ( + + ), isRound: true, size: "md", type: "modal", @@ -596,13 +764,16 @@ const QuickAccessBar = forwardRef((_, ref) => { // Determine if settings button should be hidden // Hide when login is disabled AND showSettingsWhenNoLogin is false - const shouldHideSettingsButton = config?.enableLogin === false && config?.showSettingsWhenNoLogin === false; + const shouldHideSettingsButton = + config?.enableLogin === false && config?.showSettingsWhenNoLogin === false; const bottomButtons: ButtonConfig[] = [ { id: "help", name: t("quickAccess.tours", "Tours"), - icon: , + icon: ( + + ), isRound: true, size: "md", type: "action", @@ -616,7 +787,13 @@ const QuickAccessBar = forwardRef((_, ref) => { { id: "config", name: t("quickAccess.settings", "Settings"), - icon: configButtonIcon ?? , + icon: configButtonIcon ?? ( + + ), size: "md" as const, type: "modal" as const, onClick: () => { @@ -636,8 +813,14 @@ const QuickAccessBar = forwardRef((_, ref) => { > {/* Fixed header outside scrollable area */}
- - + +
{/* Scrollable content area */} @@ -654,7 +837,11 @@ const QuickAccessBar = forwardRef((_, ref) => { {mainButtons.map((config, index) => ( - {renderNavButton(config, index, config.id === "read" || config.id === "automate")} + {renderNavButton( + config, + index, + config.id === "read" || config.id === "automate", + )} ))} @@ -665,12 +852,20 @@ const QuickAccessBar = forwardRef((_, ref) => { {middleButtons.map((config, index) => ( - {renderNavButton(config, index)} + + {renderNavButton(config, index)} + ))} {hasSelectedFiles && sharingEnabled && (
} + icon={ + + } label={t("quickAccess.access", "Access")} isActive={!isSignWorkbenchActive && accessMenuOpen} onClick={() => { @@ -682,11 +877,27 @@ const QuickAccessBar = forwardRef((_, ref) => {
)} {groupSigningEnabled && ( -
+
{pendingSignCount > 0 ? ( - + } + icon={ + + } label={t("quickAccess.sign", "Sign")} isActive={signMenuOpen || isSignWorkbenchActive} onClick={() => setSignMenuOpen((prev) => !prev)} @@ -696,7 +907,13 @@ const QuickAccessBar = forwardRef((_, ref) => { ) : ( } + icon={ + + } label={t("quickAccess.sign", "Sign")} isActive={signMenuOpen || isSignWorkbenchActive} onClick={() => setSignMenuOpen((prev) => !prev)} @@ -726,29 +943,65 @@ const QuickAccessBar = forwardRef((_, ref) => { "quickAccess.toursTooltip.admin", "Watch walkthroughs here: Tools tour, New V2 layout tour, and the Admin tour.", ) - : t("quickAccess.toursTooltip.user", "Watch walkthroughs here: Tools tour and the New V2 layout tour."); + : t( + "quickAccess.toursTooltip.user", + "Watch walkthroughs here: Tools tour and the New V2 layout tour.", + ); const tourItems = [ { key: "whatsnew", - icon: , - title: t("quickAccess.helpMenu.whatsNewTour", "See what's new in V2"), - description: t("quickAccess.helpMenu.whatsNewTourDesc", "Tour the updated layout"), + icon: ( + + ), + title: t( + "quickAccess.helpMenu.whatsNewTour", + "See what's new in V2", + ), + description: t( + "quickAccess.helpMenu.whatsNewTourDesc", + "Tour the updated layout", + ), onClick: () => requestStartTour("whatsnew"), }, { key: "tools", - icon: , + icon: ( + + ), title: t("quickAccess.helpMenu.toolsTour", "Tools Tour"), - description: t("quickAccess.helpMenu.toolsTourDesc", "Learn what the tools can do"), + description: t( + "quickAccess.helpMenu.toolsTourDesc", + "Learn what the tools can do", + ), onClick: () => requestStartTour("tools"), }, ...(isAdmin ? [ { key: "admin", - icon: , - title: t("quickAccess.helpMenu.adminTour", "Admin Tour"), - description: t("quickAccess.helpMenu.adminTourDesc", "Explore admin settings & features"), + icon: ( + + ), + title: t( + "quickAccess.helpMenu.adminTour", + "Admin Tour", + ), + description: t( + "quickAccess.helpMenu.adminTourDesc", + "Explore admin settings & features", + ), onClick: () => requestStartTour("admin"), }, ] @@ -769,10 +1022,20 @@ const QuickAccessBar = forwardRef((_, ref) => { {tourItems.map((item) => ( - +
-
{item.title}
-
{item.description}
+
+ {item.title} +
+
+ {item.description} +
))} @@ -803,12 +1066,20 @@ const QuickAccessBar = forwardRef((_, ref) => { const buttonNode = renderNavButton(buttonConfig, index); const shouldShowSettingsBadge = - buttonConfig.id === "config" && licenseAlert.active && licenseAlert.audience === "admin"; + buttonConfig.id === "config" && + licenseAlert.active && + licenseAlert.audience === "admin"; return ( {shouldShowSettingsBadge ? ( - + {buttonNode} ) : ( @@ -821,7 +1092,10 @@ const QuickAccessBar = forwardRef((_, ref) => {
- setConfigModalOpen(false)} /> + setConfigModalOpen(false)} + /> {selectedAccessFileStub && ( ((_, ref) => { onClick={() => setAccessInviteOpen(false)} aria-label={t("quickAccess.accessBack", "Back")} > - +
{accessInviteOpen @@ -869,7 +1147,11 @@ const QuickAccessBar = forwardRef((_, ref) => { }} aria-label={t("storageShare.manage", "Manage sharing")} > - + )}
-
+
-
{t("quickAccess.accessFileLabel", "File")}
+
+ {t("quickAccess.accessFileLabel", "File")} +
handleInviteRowChange(row.id, { email: event.target.value, error: undefined })} + onChange={(event) => + handleInviteRowChange(row.id, { + email: event.target.value, + error: undefined, + }) + } /> - {row.error &&
{row.error}
} + {row.error && ( +
+ {row.error} +
+ )}
- +
))} - @@ -997,12 +1349,24 @@ const QuickAccessBar = forwardRef((_, ref) => { onClick={() => void handleSendInvites()} disabled={isInviting} > - + {t("quickAccess.accessSendInvite", "Send Invite")} {shareLinksEnabled && ( - )} @@ -1010,14 +1374,30 @@ const QuickAccessBar = forwardRef((_, ref) => { ) : ( <> {sharingEnabled && ( - )} {shareLinksEnabled && ( - )} diff --git a/frontend/src/core/components/shared/RainbowThemeProvider.tsx b/frontend/src/core/components/shared/RainbowThemeProvider.tsx index a3fa5e04a..25ddffaa7 100644 --- a/frontend/src/core/components/shared/RainbowThemeProvider.tsx +++ b/frontend/src/core/components/shared/RainbowThemeProvider.tsx @@ -22,7 +22,9 @@ const RainbowThemeContext = createContext(null); export function useRainbowThemeContext() { const context = useContext(RainbowThemeContext); if (!context) { - throw new Error("useRainbowThemeContext must be used within RainbowThemeProvider"); + throw new Error( + "useRainbowThemeContext must be used within RainbowThemeProvider", + ); } return context; } @@ -35,12 +37,22 @@ export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) { const rainbowTheme = useRainbowTheme(); // Determine the Mantine color scheme - const mantineColorScheme = rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode; + const mantineColorScheme = + rainbowTheme.themeMode === "rainbow" ? "dark" : rainbowTheme.themeMode; return ( - -
+ +
{children} diff --git a/frontend/src/core/components/shared/RightRail.tsx b/frontend/src/core/components/shared/RightRail.tsx index 639a354e7..95d4694a7 100644 --- a/frontend/src/core/components/shared/RightRail.tsx +++ b/frontend/src/core/components/shared/RightRail.tsx @@ -3,7 +3,11 @@ import { ActionIcon, Divider } from "@mantine/core"; import "@app/components/shared/rightRail/RightRail.css"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useRightRail } from "@app/contexts/RightRailContext"; -import { useFileState, useFileSelection, useFileActions } from "@app/contexts/FileContext"; +import { + useFileState, + useFileSelection, + useFileActions, +} from "@app/contexts/FileContext"; import { isStirlingFile } from "@app/types/fileContext"; import { useNavigationState } from "@app/contexts/NavigationContext"; import { useTranslation } from "react-i18next"; @@ -20,7 +24,11 @@ import DarkModeIcon from "@mui/icons-material/DarkMode"; import LightModeIcon from "@mui/icons-material/LightMode"; import { useSidebarContext } from "@app/contexts/SidebarContext"; -import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from "@app/types/rightRail"; +import { + RightRailButtonConfig, + RightRailRenderContext, + RightRailSection, +} from "@app/types/rightRail"; import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide"; import { downloadFile } from "@app/services/downloadService"; @@ -34,10 +42,17 @@ function renderWithTooltip( ) { if (!tooltip) return node; - const portalTarget = typeof document !== "undefined" ? document.body : undefined; + const portalTarget = + typeof document !== "undefined" ? document.body : undefined; return ( - +
{node}
); @@ -45,7 +60,8 @@ function renderWithTooltip( export default function RightRail() { const { sidebarRefs } = useSidebarContext(); - const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs); + const { position: tooltipPosition, offset: tooltipOffset } = + useRightRailTooltipSide(sidebarRefs); const { t } = useTranslation(); const terminology = useFileActionTerminology(); const icons = useFileActionIcons(); @@ -53,8 +69,10 @@ export default function RightRail() { const { toggleTheme, themeMode } = useRainbowThemeContext(); const { buttons, actions, allButtonsDisabled } = useRightRail(); - const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow(); - const disableForFullscreen = toolPanelMode === "fullscreen" && leftPanelView === "toolPicker"; + const { pageEditorFunctions, toolPanelMode, leftPanelView } = + useToolWorkflow(); + const disableForFullscreen = + toolPanelMode === "fullscreen" && leftPanelView === "toolPicker"; const { workbench: currentView } = useNavigationState(); @@ -63,7 +81,8 @@ export default function RightRail() { const { actions: fileActions } = useFileActions(); const activeFiles = selectors.getFiles(); const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0; - const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0; + const pageEditorSelectedCount = + pageEditorFunctions?.selectedPageIds?.length ?? 0; const totalItems = useMemo(() => { if (currentView === "pageEditor") return pageEditorTotalPages; @@ -79,7 +98,9 @@ export default function RightRail() { const sectionsWithButtons = useMemo(() => { return SECTION_ORDER.map((section) => { - const sectionButtons = buttons.filter((btn) => (btn.section ?? "top") === section && (btn.visible ?? true)); + const sectionButtons = buttons.filter( + (btn) => (btn.section ?? "top") === section && (btn.visible ?? true), + ); return { section, buttons: sectionButtons }; }).filter((entry) => entry.buttons.length > 0); }, [buttons]); @@ -87,7 +108,9 @@ export default function RightRail() { const renderButton = useCallback( (btn: RightRailButtonConfig) => { const action = actions[btn.id]; - const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen); + const disabled = Boolean( + btn.disabled || allButtonsDisabled || disableForFullscreen, + ); const isActive = Boolean(btn.active); const triggerAction = () => { @@ -108,8 +131,12 @@ export default function RightRail() { if (!btn.icon) return null; - const ariaLabel = btn.ariaLabel || (typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined); - const className = ["right-rail-icon", btn.className].filter(Boolean).join(" "); + const ariaLabel = + btn.ariaLabel || + (typeof btn.tooltip === "string" ? (btn.tooltip as string) : undefined); + const className = ["right-rail-icon", btn.className] + .filter(Boolean) + .join(" "); const buttonNode = ( ); - return renderWithTooltip(buttonNode, btn.tooltip, tooltipPosition, tooltipOffset); + return renderWithTooltip( + buttonNode, + btn.tooltip, + tooltipPosition, + tooltipOffset, + ); }, - [actions, allButtonsDisabled, disableForFullscreen, tooltipPosition, tooltipOffset], + [ + actions, + allButtonsDisabled, + disableForFullscreen, + tooltipPosition, + tooltipOffset, + ], ); const handleExportAll = useCallback( @@ -136,9 +174,12 @@ export default function RightRail() { if (currentView === "viewer") { const buffer = await viewerContext?.exportActions?.saveAsCopy?.(); if (!buffer) return; - const fileToExport = selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0]; + const fileToExport = + selectedFiles.length > 0 ? selectedFiles[0] : activeFiles[0]; if (!fileToExport) return; - const stub = isStirlingFile(fileToExport) ? selectors.getStirlingFileStub(fileToExport.fileId) : undefined; + const stub = isStirlingFile(fileToExport) + ? selectors.getStirlingFileStub(fileToExport.fileId) + : undefined; try { const result = await downloadFile({ data: new Blob([buffer], { type: "application/pdf" }), @@ -162,11 +203,14 @@ export default function RightRail() { return; } - const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles; + const filesToExport = + selectedFiles.length > 0 ? selectedFiles : activeFiles; if (filesToExport.length > 0) { for (const file of filesToExport) { - const stub = isStirlingFile(file) ? selectors.getStirlingFileStub(file.fileId) : undefined; + const stub = isStirlingFile(file) + ? selectors.getStirlingFileStub(file.fileId) + : undefined; try { const result = await downloadFile({ data: file, @@ -181,12 +225,24 @@ export default function RightRail() { }); } } catch (error) { - console.error("[RightRail] Failed to export file:", file.name, error); + console.error( + "[RightRail] Failed to export file:", + file.name, + error, + ); } } } }, - [currentView, selectedFiles, activeFiles, pageEditorFunctions, viewerContext, selectors, fileActions], + [ + currentView, + selectedFiles, + activeFiles, + pageEditorFunctions, + viewerContext, + selectors, + fileActions, + ], ); const downloadTooltip = useMemo(() => { @@ -203,7 +259,11 @@ export default function RightRail() { }, [currentView, selectedCount, t, terminology]); return ( -
+
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => ( @@ -212,7 +272,11 @@ export default function RightRail() { const content = renderButton(btn); if (!content) return null; return ( -
+
{content}
); @@ -222,11 +286,21 @@ export default function RightRail() { ))}
{renderWithTooltip( - + {themeMode === "dark" ? ( ) : ( @@ -238,7 +312,12 @@ export default function RightRail() { tooltipOffset, )} - + {renderWithTooltip( handleExportAll()} - disabled={disableForFullscreen || (currentView !== "viewer" && (totalItems === 0 || allButtonsDisabled))} + disabled={ + disableForFullscreen || + (currentView !== "viewer" && + (totalItems === 0 || allButtonsDisabled)) + } > - + , downloadTooltip, tooltipPosition, @@ -261,9 +348,17 @@ export default function RightRail() { radius="md" className="right-rail-icon" onClick={() => handleExportAll(true)} - disabled={disableForFullscreen || (currentView !== "viewer" && (totalItems === 0 || allButtonsDisabled))} + disabled={ + disableForFullscreen || + (currentView !== "viewer" && + (totalItems === 0 || allButtonsDisabled)) + } > - + , t("rightRail.saveAs", "Save As"), tooltipPosition, diff --git a/frontend/src/core/components/shared/ShareFileModal.tsx b/frontend/src/core/components/shared/ShareFileModal.tsx index 757aa86bc..2f205326e 100644 --- a/frontend/src/core/components/shared/ShareFileModal.tsx +++ b/frontend/src/core/components/shared/ShareFileModal.tsx @@ -1,5 +1,15 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; -import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from "@mantine/core"; +import { + Modal, + Stack, + Text, + Button, + Group, + Alert, + TextInput, + Paper, + Select, +} from "@mantine/core"; import LinkIcon from "@mui/icons-material/Link"; import ContentCopyRoundedIcon from "@mui/icons-material/ContentCopyRounded"; import { useTranslation } from "react-i18next"; @@ -22,7 +32,12 @@ interface ShareFileModalProps { onUploaded?: () => Promise | void; } -const ShareFileModal: React.FC = ({ opened, onClose, file, onUploaded }) => { +const ShareFileModal: React.FC = ({ + opened, + onClose, + file, + onUploaded, +}) => { const { t } = useTranslation(); const { config } = useAppConfig(); const { actions } = useFileActions(); @@ -30,7 +45,9 @@ const ShareFileModal: React.FC = ({ opened, onClose, file, const [isWorking, setIsWorking] = useState(false); const [errorMessage, setErrorMessage] = useState(null); const [shareToken, setShareToken] = useState(null); - const [shareRole, setShareRole] = useState<"editor" | "commenter" | "viewer">("editor"); + const [shareRole, setShareRole] = useState<"editor" | "commenter" | "viewer">( + "editor", + ); useEffect(() => { if (!opened) { @@ -53,7 +70,9 @@ const ShareFileModal: React.FC = ({ opened, onClose, file, try { const parsed = new URL(frontendUrl); if (parsed.protocol === "http:" || parsed.protocol === "https:") { - const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl; + const normalized = frontendUrl.endsWith("/") + ? frontendUrl.slice(0, -1) + : frontendUrl; return `${normalized}/share/${shareToken}`; } } catch { @@ -65,9 +84,12 @@ const ShareFileModal: React.FC = ({ opened, onClose, file, const createShareLink = useCallback( async (storedFileId: number) => { - const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, { - accessRole: shareRole, - }); + const response = await apiClient.post( + `/api/v1/storage/files/${storedFileId}/shares/links`, + { + accessRole: shareRole, + }, + ); return response.data as { token?: string }; }, [shareRole], @@ -99,7 +121,11 @@ const ShareFileModal: React.FC = ({ opened, onClose, file, if (!isUpToDate) { const originalFileId = (file.originalFileId || file.id) as FileId; const remoteId = file.remoteStorageId; - const { remoteId: newStoredId, updatedAt, chain } = await uploadHistoryChain(originalFileId, remoteId); + const { + remoteId: newStoredId, + updatedAt, + chain, + } = await uploadHistoryChain(originalFileId, remoteId); storedId = newStoredId; for (const stub of chain) { @@ -132,14 +158,21 @@ const ShareFileModal: React.FC = ({ opened, onClose, file, }); if (storedId) { actions.updateStirlingFileStub(file.id, { remoteHasShareLinks: true }); - await fileStorage.updateFileMetadata(file.id, { remoteHasShareLinks: true }); + await fileStorage.updateFileMetadata(file.id, { + remoteHasShareLinks: true, + }); } if (onUploaded) { await onUploaded(); } } catch (error: any) { console.error("Failed to generate share link:", error); - setErrorMessage(t("storageShare.failure", "Unable to generate a share link. Please try again.")); + setErrorMessage( + t( + "storageShare.failure", + "Unable to generate a share link. Please try again.", + ), + ); } finally { setIsWorking(false); } @@ -192,13 +225,22 @@ const ShareFileModal: React.FC = ({ opened, onClose, file, {errorMessage && ( - + {errorMessage} )} {!shareLinksEnabled && ( - - {t("storageShare.linksDisabledBody", "Share links are disabled by your server settings.")} + + {t( + "storageShare.linksDisabledBody", + "Share links are disabled by your server settings.", + )} )} @@ -213,7 +255,9 @@ const ShareFileModal: React.FC = ({ opened, onClose, file, {showEmailWarning && ( - + {t( @@ -479,11 +621,21 @@ const ShareManagementModal: React.FC = ({ opened, onC )} - - @@ -491,17 +643,27 @@ const ShareManagementModal: React.FC = ({ opened, onC )} {sharedUsers.length === 0 ? ( - {t("storageShare.noSharedUsers", "No users have access yet.")} + {t( + "storageShare.noSharedUsers", + "No users have access yet.", + )} ) : ( {sharedUsers.map((user) => ( - + {user.username} {user.accessRole === "commenter" && ( - {t("storageShare.commenterHint", "Commenting is coming soon.")} + {t( + "storageShare.commenterHint", + "Commenting is coming soon.", + )} )} @@ -509,14 +671,34 @@ const ShareManagementModal: React.FC = ({ opened, onC { - if (val) updatePreference("defaultViewerZoom", val as ViewerZoomSetting); + if (val) + updatePreference( + "defaultViewerZoom", + val as ViewerZoomSetting, + ); }} data={[ - { label: t("settings.general.zoomLevel.auto", "Auto"), value: "auto" }, - { label: t("settings.general.zoomLevel.fitWidth", "Fit width"), value: "fitWidth" }, - { label: t("settings.general.zoomLevel.fitPage", "Fit page"), value: "fitPage" }, + { + label: t("settings.general.zoomLevel.auto", "Auto"), + value: "auto", + }, + { + label: t("settings.general.zoomLevel.fitWidth", "Fit width"), + value: "fitWidth", + }, + { + label: t("settings.general.zoomLevel.fitPage", "Fit page"), + value: "fitPage", + }, { label: "50%", value: "50" }, { label: "75%", value: "75" }, { label: "100%", value: "100" }, @@ -352,13 +486,25 @@ const GeneralSection: React.FC = ({ ]} style={{ width: 140 }} allowDeselect={false} - comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + comboboxProps={{ + withinPortal: true, + zIndex: Z_INDEX_OVER_CONFIG_MODAL, + }} />
-
+
- {t("settings.general.hideUnavailableTools", "Hide unavailable tools")} + {t( + "settings.general.hideUnavailableTools", + "Hide unavailable tools", + )} {t( @@ -369,13 +515,27 @@ const GeneralSection: React.FC = ({
updatePreference("hideUnavailableTools", event.currentTarget.checked)} + onChange={(event) => + updatePreference( + "hideUnavailableTools", + event.currentTarget.checked, + ) + } />
-
+
- {t("settings.general.hideUnavailableConversions", "Hide unavailable conversions")} + {t( + "settings.general.hideUnavailableConversions", + "Hide unavailable conversions", + )} {t( @@ -386,7 +546,12 @@ const GeneralSection: React.FC = ({
updatePreference("hideUnavailableConversions", event.currentTarget.checked)} + onChange={(event) => + updatePreference( + "hideUnavailableConversions", + event.currentTarget.checked, + ) + } />
= ({ w={300} withArrow > -
+
{t("settings.general.autoUnzip", "Auto-unzip API responses")} - {t("settings.general.autoUnzipDescription", "Automatically extract files from ZIP responses")} + {t( + "settings.general.autoUnzipDescription", + "Automatically extract files from ZIP responses", + )}
updatePreference("autoUnzip", event.currentTarget.checked)} + onChange={(event) => + updatePreference("autoUnzip", event.currentTarget.checked) + } />
@@ -423,13 +600,26 @@ const GeneralSection: React.FC = ({ w={300} withArrow > -
+
- {t("settings.general.autoUnzipFileLimit", "Auto-unzip file limit")} + {t( + "settings.general.autoUnzipFileLimit", + "Auto-unzip file limit", + )} - {t("settings.general.autoUnzipFileLimitDescription", "Maximum number of files to extract from ZIP")} + {t( + "settings.general.autoUnzipFileLimitDescription", + "Maximum number of files to extract from ZIP", + )}
= ({ onBlur={() => { const numValue = Number(fileLimitInput); const finalValue = - !fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100 + !fileLimitInput || + isNaN(numValue) || + numValue < 1 || + numValue > 100 ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue; setFileLimitInput(finalValue); diff --git a/frontend/src/core/components/shared/config/configSections/HotkeysSection.tsx b/frontend/src/core/components/shared/config/configSections/HotkeysSection.tsx index 044601093..e7e15c9e8 100644 --- a/frontend/src/core/components/shared/config/configSections/HotkeysSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/HotkeysSection.tsx @@ -1,11 +1,26 @@ import React, { useEffect, useMemo, useState } from "react"; -import { Alert, Badge, Box, Button, Divider, Group, Paper, Stack, Text, TextInput } from "@mantine/core"; +import { + Alert, + Badge, + Box, + Button, + Divider, + Group, + Paper, + Stack, + Text, + TextInput, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useHotkeys } from "@app/contexts/HotkeyContext"; import { ToolId } from "@app/types/toolId"; import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay"; -import { bindingEquals, eventToBinding, HotkeyBinding } from "@app/utils/hotkeys"; +import { + bindingEquals, + eventToBinding, + HotkeyBinding, +} from "@app/utils/hotkeys"; import { ToolRegistryEntry } from "@app/data/toolsTaxonomy"; const rowStyle: React.CSSProperties = { @@ -25,12 +40,24 @@ const rowHeaderStyle: React.CSSProperties = { const HotkeysSection: React.FC = () => { const { t } = useTranslation(); const { toolRegistry } = useToolWorkflow(); - const { hotkeys, defaults, updateHotkey, resetHotkey, pauseHotkeys, resumeHotkeys, getDisplayParts, isMac } = useHotkeys(); + const { + hotkeys, + defaults, + updateHotkey, + resetHotkey, + pauseHotkeys, + resumeHotkeys, + getDisplayParts, + isMac, + } = useHotkeys(); const [editingTool, setEditingTool] = useState(null); const [error, setError] = useState(null); const [searchQuery, setSearchQuery] = useState(""); - const tools = useMemo(() => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], [toolRegistry]); + const tools = useMemo( + () => Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][], + [toolRegistry], + ); const filteredTools = useMemo(() => { if (!searchQuery.trim()) return tools; @@ -78,14 +105,26 @@ const HotkeysSection: React.FC = () => { return; } - const conflictEntry = (Object.entries(hotkeys) as [ToolId, HotkeyBinding][]).find( - ([toolId, existing]) => toolId !== editingTool && bindingEquals(existing, binding), + const conflictEntry = ( + Object.entries(hotkeys) as [ToolId, HotkeyBinding][] + ).find( + ([toolId, existing]) => + toolId !== editingTool && bindingEquals(existing, binding), ); if (conflictEntry) { const conflictKey = conflictEntry[0]; - const conflictTool = conflictKey in toolRegistry ? toolRegistry[conflictKey as ToolId]?.name : conflictKey; - setError(t("settings.hotkeys.errorConflict", "Shortcut already used by {{tool}}.", { tool: conflictTool })); + const conflictTool = + conflictKey in toolRegistry + ? toolRegistry[conflictKey as ToolId]?.name + : conflictKey; + setError( + t( + "settings.hotkeys.errorConflict", + "Shortcut already used by {{tool}}.", + { tool: conflictTool }, + ), + ); return; } @@ -140,13 +179,22 @@ const HotkeysSection: React.FC = () => { const isEditing = editingTool === toolId; const defaultParts = getDisplayParts(defaultBinding); const defaultLabel = - defaultParts.length > 0 ? defaultParts.join(" + ") : t("settings.hotkeys.none", "Not assigned"); + defaultParts.length > 0 + ? defaultParts.join(" + ") + : t("settings.hotkeys.none", "Not assigned"); return (
-
+
{tool.name} @@ -156,7 +204,11 @@ const HotkeysSection: React.FC = () => { )} - {t("settings.hotkeys.defaultLabel", "Default: {{shortcut}}", { shortcut: defaultLabel })} + {t( + "settings.hotkeys.defaultLabel", + "Default: {{shortcut}}", + { shortcut: defaultLabel }, + )}
@@ -169,13 +221,19 @@ const HotkeysSection: React.FC = () => { onClick={() => handleStartCapture(toolId)} > {isEditing - ? t("settings.hotkeys.capturing", "Press keys… (Esc to cancel)") + ? t( + "settings.hotkeys.capturing", + "Press keys… (Esc to cancel)", + ) : t("settings.hotkeys.change", "Change shortcut")} )} diff --git a/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts b/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts index 5b0c335d7..51fd6873e 100644 --- a/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts +++ b/frontend/src/core/components/shared/config/configSections/providerDefinitions.ts @@ -31,13 +31,17 @@ const useGoogleProvider = (): Provider => { icon: "/Login/google.svg", type: "oauth2", scope: t("provider.oauth2.google.scope", "Sign-in authentication"), - documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration", + documentationUrl: + "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration", fields: [ { key: "clientId", type: "text", label: t("provider.oauth2.google.clientId.label", "Client ID"), - description: t("provider.oauth2.google.clientId.description", "The OAuth2 client ID from Google Cloud Console"), + description: t( + "provider.oauth2.google.clientId.description", + "The OAuth2 client ID from Google Cloud Console", + ), placeholder: "your-client-id.apps.googleusercontent.com", }, { @@ -53,13 +57,19 @@ const useGoogleProvider = (): Provider => { key: "scopes", type: "text", label: t("provider.oauth2.google.scopes.label", "Scopes"), - description: t("provider.oauth2.google.scopes.description", "Comma-separated OAuth2 scopes"), + description: t( + "provider.oauth2.google.scopes.description", + "Comma-separated OAuth2 scopes", + ), defaultValue: "email, profile", }, { key: "useAsUsername", type: "text", - label: t("provider.oauth2.google.useAsUsername.label", "Use as Username"), + label: t( + "provider.oauth2.google.useAsUsername.label", + "Use as Username", + ), description: t( "provider.oauth2.google.useAsUsername.description", "Field to use as username (email, name, given_name, family_name)", @@ -79,13 +89,17 @@ const useGitHubProvider = (): Provider => { icon: "/Login/github.svg", type: "oauth2", scope: t("provider.oauth2.github.scope", "Sign-in authentication"), - documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration", + documentationUrl: + "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration", fields: [ { key: "clientId", type: "text", label: t("provider.oauth2.github.clientId.label", "Client ID"), - description: t("provider.oauth2.github.clientId.description", "The OAuth2 client ID from GitHub Developer Settings"), + description: t( + "provider.oauth2.github.clientId.description", + "The OAuth2 client ID from GitHub Developer Settings", + ), }, { key: "clientSecret", @@ -100,14 +114,23 @@ const useGitHubProvider = (): Provider => { key: "scopes", type: "text", label: t("provider.oauth2.github.scopes.label", "Scopes"), - description: t("provider.oauth2.github.scopes.description", "Comma-separated OAuth2 scopes"), + description: t( + "provider.oauth2.github.scopes.description", + "Comma-separated OAuth2 scopes", + ), defaultValue: "read:user", }, { key: "useAsUsername", type: "text", - label: t("provider.oauth2.github.useAsUsername.label", "Use as Username"), - description: t("provider.oauth2.github.useAsUsername.description", "Field to use as username (email, login, name)"), + label: t( + "provider.oauth2.github.useAsUsername.label", + "Use as Username", + ), + description: t( + "provider.oauth2.github.useAsUsername.description", + "Field to use as username (email, login, name)", + ), defaultValue: "login", }, ], @@ -124,7 +147,8 @@ const useKeycloakProvider = (): Provider => { type: "oauth2", scope: t("provider.oauth2.keycloak.scope", "SSO"), businessTier: false, - documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration", + documentationUrl: + "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration", fields: [ { key: "issuer", @@ -140,25 +164,40 @@ const useKeycloakProvider = (): Provider => { key: "clientId", type: "text", label: t("provider.oauth2.keycloak.clientId.label", "Client ID"), - description: t("provider.oauth2.keycloak.clientId.description", "The OAuth2 client ID from Keycloak"), + description: t( + "provider.oauth2.keycloak.clientId.description", + "The OAuth2 client ID from Keycloak", + ), }, { key: "clientSecret", type: "password", - label: t("provider.oauth2.keycloak.clientSecret.label", "Client Secret"), - description: t("provider.oauth2.keycloak.clientSecret.description", "The OAuth2 client secret from Keycloak"), + label: t( + "provider.oauth2.keycloak.clientSecret.label", + "Client Secret", + ), + description: t( + "provider.oauth2.keycloak.clientSecret.description", + "The OAuth2 client secret from Keycloak", + ), }, { key: "scopes", type: "text", label: t("provider.oauth2.keycloak.scopes.label", "Scopes"), - description: t("provider.oauth2.keycloak.scopes.description", "Comma-separated OAuth2 scopes"), + description: t( + "provider.oauth2.keycloak.scopes.description", + "Comma-separated OAuth2 scopes", + ), defaultValue: "openid, profile, email", }, { key: "useAsUsername", type: "text", - label: t("provider.oauth2.keycloak.useAsUsername.label", "Use as Username"), + label: t( + "provider.oauth2.keycloak.useAsUsername.label", + "Use as Username", + ), description: t( "provider.oauth2.keycloak.useAsUsername.description", "Field to use as username (email, name, given_name, family_name, preferred_username)", @@ -179,13 +218,20 @@ const useGenericOAuth2Provider = (): Provider => { type: "oauth2", scope: t("provider.oauth2.generic.scope", "SSO"), businessTier: false, - documentationUrl: "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration", + documentationUrl: + "https://docs.stirlingpdf.com/Configuration/OAuth%20SSO%20Configuration", fields: [ { key: "enabled", type: "switch", - label: t("provider.oauth2.generic.enabled.label", "Enable Generic OAuth2"), - description: t("provider.oauth2.generic.enabled.description", "Enable authentication using a custom OAuth2 provider"), + label: t( + "provider.oauth2.generic.enabled.label", + "Enable Generic OAuth2", + ), + description: t( + "provider.oauth2.generic.enabled.description", + "Enable authentication using a custom OAuth2 provider", + ), defaultValue: false, }, { @@ -212,32 +258,50 @@ const useGenericOAuth2Provider = (): Provider => { key: "clientId", type: "text", label: t("provider.oauth2.generic.clientId.label", "Client ID"), - description: t("provider.oauth2.generic.clientId.description", "The OAuth2 client ID from your provider"), + description: t( + "provider.oauth2.generic.clientId.description", + "The OAuth2 client ID from your provider", + ), }, { key: "clientSecret", type: "password", label: t("provider.oauth2.generic.clientSecret.label", "Client Secret"), - description: t("provider.oauth2.generic.clientSecret.description", "The OAuth2 client secret from your provider"), + description: t( + "provider.oauth2.generic.clientSecret.description", + "The OAuth2 client secret from your provider", + ), }, { key: "scopes", type: "text", label: t("provider.oauth2.generic.scopes.label", "Scopes"), - description: t("provider.oauth2.generic.scopes.description", "Comma-separated OAuth2 scopes"), + description: t( + "provider.oauth2.generic.scopes.description", + "Comma-separated OAuth2 scopes", + ), defaultValue: "openid, profile, email", }, { key: "useAsUsername", type: "text", - label: t("provider.oauth2.generic.useAsUsername.label", "Use as Username"), - description: t("provider.oauth2.generic.useAsUsername.description", "Field to use as username"), + label: t( + "provider.oauth2.generic.useAsUsername.label", + "Use as Username", + ), + description: t( + "provider.oauth2.generic.useAsUsername.description", + "Field to use as username", + ), defaultValue: "email", }, { key: "autoCreateUser", type: "switch", - label: t("provider.oauth2.generic.autoCreateUser.label", "Auto Create Users"), + label: t( + "provider.oauth2.generic.autoCreateUser.label", + "Auto Create Users", + ), description: t( "provider.oauth2.generic.autoCreateUser.description", "Automatically create user accounts on first OAuth2 login", @@ -247,8 +311,14 @@ const useGenericOAuth2Provider = (): Provider => { { key: "blockRegistration", type: "switch", - label: t("provider.oauth2.generic.blockRegistration.label", "Block Registration"), - description: t("provider.oauth2.generic.blockRegistration.description", "Prevent new user registration via OAuth2"), + label: t( + "provider.oauth2.generic.blockRegistration.label", + "Block Registration", + ), + description: t( + "provider.oauth2.generic.blockRegistration.description", + "Prevent new user registration via OAuth2", + ), defaultValue: false, }, ], @@ -264,27 +334,37 @@ const useSMTPProvider = (): Provider => { icon: "mail-rounded", type: "oauth2", scope: t("provider.smtp.scope", "Email Notifications"), - documentationUrl: "https://docs.stirlingpdf.com/Configuration/System%20and%20Security/#email-configuration", + documentationUrl: + "https://docs.stirlingpdf.com/Configuration/System%20and%20Security/#email-configuration", fields: [ { key: "enabled", type: "switch", label: t("provider.smtp.enabled.label", "Enable Mail"), - description: t("provider.smtp.enabled.description", "Enable email notifications and SMTP functionality"), + description: t( + "provider.smtp.enabled.description", + "Enable email notifications and SMTP functionality", + ), defaultValue: false, }, { key: "host", type: "text", label: t("provider.smtp.host.label", "SMTP Host"), - description: t("provider.smtp.host.description", "The hostname or IP address of your SMTP server"), + description: t( + "provider.smtp.host.description", + "The hostname or IP address of your SMTP server", + ), placeholder: "smtp.example.com", }, { key: "port", type: "number", label: t("provider.smtp.port.label", "SMTP Port"), - description: t("provider.smtp.port.description", "The port number for SMTP connection (typically 25, 465, or 587)"), + description: t( + "provider.smtp.port.description", + "The port number for SMTP connection (typically 25, 465, or 587)", + ), placeholder: "587", defaultValue: "587", }, @@ -292,19 +372,28 @@ const useSMTPProvider = (): Provider => { key: "username", type: "text", label: t("provider.smtp.username.label", "SMTP Username"), - description: t("provider.smtp.username.description", "Username for SMTP authentication"), + description: t( + "provider.smtp.username.description", + "Username for SMTP authentication", + ), }, { key: "password", type: "password", label: t("provider.smtp.password.label", "SMTP Password"), - description: t("provider.smtp.password.description", "Password for SMTP authentication"), + description: t( + "provider.smtp.password.description", + "Password for SMTP authentication", + ), }, { key: "from", type: "text", label: t("provider.smtp.from.label", "From Address"), - description: t("provider.smtp.from.description", "The email address to use as the sender"), + description: t( + "provider.smtp.from.description", + "The email address to use as the sender", + ), placeholder: "noreply@example.com", }, ], @@ -326,7 +415,10 @@ const useTelegramProvider = (): Provider => { { key: "enabled", type: "switch", - label: t("admin.settings.telegram.enabled.label", "Enable Telegram Bot"), + label: t( + "admin.settings.telegram.enabled.label", + "Enable Telegram Bot", + ), description: t( "admin.settings.telegram.enabled.description", "Allow users to interact with Stirling PDF through your configured Telegram bot.", @@ -337,7 +429,10 @@ const useTelegramProvider = (): Provider => { key: "botUsername", type: "text", label: t("admin.settings.telegram.botUsername.label", "Bot Username"), - description: t("admin.settings.telegram.botUsername.description", "The public username of your Telegram bot."), + description: t( + "admin.settings.telegram.botUsername.description", + "The public username of your Telegram bot.", + ), placeholder: "my_pdf_bot", }, { @@ -353,7 +448,10 @@ const useTelegramProvider = (): Provider => { { key: "pipelineInboxFolder", type: "text", - label: t("admin.settings.telegram.pipelineInboxFolder.label", "Inbox Folder"), + label: t( + "admin.settings.telegram.pipelineInboxFolder.label", + "Inbox Folder", + ), description: t( "admin.settings.telegram.pipelineInboxFolder.description", "Folder under the pipeline directory where incoming Telegram files are stored.", @@ -363,7 +461,10 @@ const useTelegramProvider = (): Provider => { { key: "customFolderSuffix", type: "switch", - label: t("admin.settings.telegram.customFolderSuffix.label", "Use Custom Folder Suffix"), + label: t( + "admin.settings.telegram.customFolderSuffix.label", + "Use Custom Folder Suffix", + ), description: t( "admin.settings.telegram.customFolderSuffix.description", "Append the chat ID to incoming file folders to isolate uploads per chat.", @@ -373,7 +474,10 @@ const useTelegramProvider = (): Provider => { { key: "enableAllowUserIDs", type: "switch", - label: t("admin.settings.telegram.enableAllowUserIDs.label", "Allow Specific User IDs"), + label: t( + "admin.settings.telegram.enableAllowUserIDs.label", + "Allow Specific User IDs", + ), description: t( "admin.settings.telegram.enableAllowUserIDs.description", "When enabled, only listed user IDs can use the bot.", @@ -383,18 +487,27 @@ const useTelegramProvider = (): Provider => { { key: "allowUserIDs", type: "tags", - label: t("admin.settings.telegram.allowUserIDs.label", "Allowed User IDs"), + label: t( + "admin.settings.telegram.allowUserIDs.label", + "Allowed User IDs", + ), description: t( "admin.settings.telegram.allowUserIDs.description", "Enter Telegram user IDs allowed to interact with the bot.", ), - placeholder: t("admin.settings.telegram.allowUserIDs.placeholder", "Add user ID and press enter"), + placeholder: t( + "admin.settings.telegram.allowUserIDs.placeholder", + "Add user ID and press enter", + ), defaultValue: [], }, { key: "enableAllowChannelIDs", type: "switch", - label: t("admin.settings.telegram.enableAllowChannelIDs.label", "Allow Specific Channel IDs"), + label: t( + "admin.settings.telegram.enableAllowChannelIDs.label", + "Allow Specific Channel IDs", + ), description: t( "admin.settings.telegram.enableAllowChannelIDs.description", "When enabled, only listed channel IDs can use the bot.", @@ -404,18 +517,27 @@ const useTelegramProvider = (): Provider => { { key: "allowChannelIDs", type: "tags", - label: t("admin.settings.telegram.allowChannelIDs.label", "Allowed Channel IDs"), + label: t( + "admin.settings.telegram.allowChannelIDs.label", + "Allowed Channel IDs", + ), description: t( "admin.settings.telegram.allowChannelIDs.description", "Enter Telegram channel IDs allowed to interact with the bot.", ), - placeholder: t("admin.settings.telegram.allowChannelIDs.placeholder", "Add channel ID and press enter"), + placeholder: t( + "admin.settings.telegram.allowChannelIDs.placeholder", + "Add channel ID and press enter", + ), defaultValue: [], }, { key: "processingTimeoutSeconds", type: "number", - label: t("admin.settings.telegram.processingTimeoutSeconds.label", "Processing Timeout (seconds)"), + label: t( + "admin.settings.telegram.processingTimeoutSeconds.label", + "Processing Timeout (seconds)", + ), description: t( "admin.settings.telegram.processingTimeoutSeconds.description", "Maximum time to wait for a processing job before reporting an error.", @@ -425,7 +547,10 @@ const useTelegramProvider = (): Provider => { { key: "pollingIntervalMillis", type: "number", - label: t("admin.settings.telegram.pollingIntervalMillis.label", "Polling Interval (ms)"), + label: t( + "admin.settings.telegram.pollingIntervalMillis.label", + "Polling Interval (ms)", + ), description: t( "admin.settings.telegram.pollingIntervalMillis.description", "Interval between checks for new Telegram updates.", @@ -435,7 +560,10 @@ const useTelegramProvider = (): Provider => { { key: "feedback.general.enabled", type: "switch", - label: t("admin.settings.telegram.feedback.general.enabled.label", "Enable Feedback"), + label: t( + "admin.settings.telegram.feedback.general.enabled.label", + "Enable Feedback", + ), description: t( "admin.settings.telegram.feedback.general.enabled.description", "Control whether the bot sends feedback messages at all.", @@ -445,7 +573,10 @@ const useTelegramProvider = (): Provider => { { key: "feedback.channel.noValidDocument", type: "switch", - label: t("admin.settings.telegram.feedback.channel.noValidDocument.label", 'Show "No valid document" (Channel)'), + label: t( + "admin.settings.telegram.feedback.channel.noValidDocument.label", + 'Show "No valid document" (Channel)', + ), description: t( "admin.settings.telegram.feedback.channel.noValidDocument.description", "Suppress the no valid document response for channel uploads.", @@ -455,7 +586,10 @@ const useTelegramProvider = (): Provider => { { key: "feedback.channel.errorProcessing", type: "switch", - label: t("admin.settings.telegram.feedback.channel.errorProcessing.label", "Show processing errors (Channel)"), + label: t( + "admin.settings.telegram.feedback.channel.errorProcessing.label", + "Show processing errors (Channel)", + ), description: t( "admin.settings.telegram.feedback.channel.errorProcessing.description", "Send processing error messages to channels.", @@ -465,7 +599,10 @@ const useTelegramProvider = (): Provider => { { key: "feedback.channel.errorMessage", type: "switch", - label: t("admin.settings.telegram.feedback.channel.errorMessage.label", "Show error messages (Channel)"), + label: t( + "admin.settings.telegram.feedback.channel.errorMessage.label", + "Show error messages (Channel)", + ), description: t( "admin.settings.telegram.feedback.channel.errorMessage.description", "Show detailed error messages for channels.", @@ -475,7 +612,10 @@ const useTelegramProvider = (): Provider => { { key: "feedback.user.noValidDocument", type: "switch", - label: t("admin.settings.telegram.feedback.user.noValidDocument.label", 'Show "No valid document" (User)'), + label: t( + "admin.settings.telegram.feedback.user.noValidDocument.label", + 'Show "No valid document" (User)', + ), description: t( "admin.settings.telegram.feedback.user.noValidDocument.description", "Suppress the no valid document response for user uploads.", @@ -485,7 +625,10 @@ const useTelegramProvider = (): Provider => { { key: "feedback.user.errorProcessing", type: "switch", - label: t("admin.settings.telegram.feedback.user.errorProcessing.label", "Show processing errors (User)"), + label: t( + "admin.settings.telegram.feedback.user.errorProcessing.label", + "Show processing errors (User)", + ), description: t( "admin.settings.telegram.feedback.user.errorProcessing.description", "Send processing error messages to users.", @@ -495,7 +638,10 @@ const useTelegramProvider = (): Provider => { { key: "feedback.user.errorMessage", type: "switch", - label: t("admin.settings.telegram.feedback.user.errorMessage.label", "Show error messages (User)"), + label: t( + "admin.settings.telegram.feedback.user.errorMessage.label", + "Show error messages (User)", + ), description: t( "admin.settings.telegram.feedback.user.errorMessage.description", "Show detailed error messages for users.", @@ -516,88 +662,137 @@ const useSAML2Provider = (): Provider => { type: "saml2", scope: t("provider.saml2.scope", "SSO (SAML)"), businessTier: true, - documentationUrl: "https://docs.stirlingpdf.com/Configuration/SAML%20SSO%20Configuration/", + documentationUrl: + "https://docs.stirlingpdf.com/Configuration/SAML%20SSO%20Configuration/", fields: [ { key: "enabled", type: "switch", label: t("provider.saml2.enabled.label", "Enable SAML2"), - description: t("provider.saml2.enabled.description", "Enable SAML2 authentication (Enterprise only)"), + description: t( + "provider.saml2.enabled.description", + "Enable SAML2 authentication (Enterprise only)", + ), defaultValue: false, }, { key: "provider", type: "text", label: t("provider.saml2.provider.label", "Provider Name"), - description: t("provider.saml2.provider.description", "The name of your SAML2 provider"), + description: t( + "provider.saml2.provider.description", + "The name of your SAML2 provider", + ), }, { key: "registrationId", type: "text", label: t("provider.saml2.registrationId.label", "Registration ID"), - description: t("provider.saml2.registrationId.description", "The name of your Service Provider (SP) app name"), + description: t( + "provider.saml2.registrationId.description", + "The name of your Service Provider (SP) app name", + ), defaultValue: "stirling", }, { key: "idpMetadataUri", type: "text", label: t("provider.saml2.idpMetadataUri.label", "IDP Metadata URI"), - description: t("provider.saml2.idpMetadataUri.description", "The URI for your provider's metadata"), - placeholder: "https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata", + description: t( + "provider.saml2.idpMetadataUri.description", + "The URI for your provider's metadata", + ), + placeholder: + "https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata", }, { key: "idpSingleLoginUrl", type: "text", - label: t("provider.saml2.idpSingleLoginUrl.label", "IDP Single Login URL"), - description: t("provider.saml2.idpSingleLoginUrl.description", "The URL for initiating SSO"), - placeholder: "https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml", + label: t( + "provider.saml2.idpSingleLoginUrl.label", + "IDP Single Login URL", + ), + description: t( + "provider.saml2.idpSingleLoginUrl.description", + "The URL for initiating SSO", + ), + placeholder: + "https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml", }, { key: "idpSingleLogoutUrl", type: "text", - label: t("provider.saml2.idpSingleLogoutUrl.label", "IDP Single Logout URL"), - description: t("provider.saml2.idpSingleLogoutUrl.description", "The URL for initiating SLO"), - placeholder: "https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml", + label: t( + "provider.saml2.idpSingleLogoutUrl.label", + "IDP Single Logout URL", + ), + description: t( + "provider.saml2.idpSingleLogoutUrl.description", + "The URL for initiating SLO", + ), + placeholder: + "https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml", }, { key: "idpIssuer", type: "text", label: t("provider.saml2.idpIssuer.label", "IDP Issuer"), - description: t("provider.saml2.idpIssuer.description", "The ID of your provider"), + description: t( + "provider.saml2.idpIssuer.description", + "The ID of your provider", + ), }, { key: "idpCert", type: "text", label: t("provider.saml2.idpCert.label", "IDP Certificate"), - description: t("provider.saml2.idpCert.description", "The certificate path (e.g., classpath:okta.cert)"), + description: t( + "provider.saml2.idpCert.description", + "The certificate path (e.g., classpath:okta.cert)", + ), placeholder: "classpath:okta.cert", }, { key: "privateKey", type: "text", label: t("provider.saml2.privateKey.label", "Private Key"), - description: t("provider.saml2.privateKey.description", "Your private key path"), + description: t( + "provider.saml2.privateKey.description", + "Your private key path", + ), placeholder: "classpath:saml-private-key.key", }, { key: "spCert", type: "text", label: t("provider.saml2.spCert.label", "SP Certificate"), - description: t("provider.saml2.spCert.description", "Your signing certificate path"), + description: t( + "provider.saml2.spCert.description", + "Your signing certificate path", + ), placeholder: "classpath:saml-public-cert.crt", }, { key: "autoCreateUser", type: "switch", label: t("provider.saml2.autoCreateUser.label", "Auto Create Users"), - description: t("provider.saml2.autoCreateUser.description", "Automatically create user accounts on first SAML2 login"), + description: t( + "provider.saml2.autoCreateUser.description", + "Automatically create user accounts on first SAML2 login", + ), defaultValue: true, }, { key: "blockRegistration", type: "switch", - label: t("provider.saml2.blockRegistration.label", "Block Registration"), - description: t("provider.saml2.blockRegistration.description", "Prevent new user registration via SAML2"), + label: t( + "provider.saml2.blockRegistration.label", + "Block Registration", + ), + description: t( + "provider.saml2.blockRegistration.description", + "Prevent new user registration via SAML2", + ), defaultValue: false, }, ], @@ -613,20 +808,30 @@ const useGoogleDriveProvider = (): Provider => { icon: "/images/google-drive.svg", type: "googledrive", scope: t("provider.googledrive.scope", "File Import"), - documentationUrl: "https://docs.stirlingpdf.com/Configuration/Google%20Drive%20File%20Picker/", + documentationUrl: + "https://docs.stirlingpdf.com/Configuration/Google%20Drive%20File%20Picker/", fields: [ { key: "enabled", type: "switch", - label: t("provider.googledrive.enabled.label", "Enable Google Drive File Picker"), - description: t("provider.googledrive.enabled.description", "Allow users to import files directly from Google Drive"), + label: t( + "provider.googledrive.enabled.label", + "Enable Google Drive File Picker", + ), + description: t( + "provider.googledrive.enabled.description", + "Allow users to import files directly from Google Drive", + ), defaultValue: false, }, { key: "clientId", type: "text", label: t("provider.googledrive.clientId.label", "Client ID"), - description: t("provider.googledrive.clientId.description", "Google OAuth 2.0 Client ID from Google Cloud Console"), + description: t( + "provider.googledrive.clientId.description", + "Google OAuth 2.0 Client ID from Google Cloud Console", + ), placeholder: "xxx.apps.googleusercontent.com", }, { @@ -643,7 +848,10 @@ const useGoogleDriveProvider = (): Provider => { key: "appId", type: "text", label: t("provider.googledrive.appId.label", "App ID"), - description: t("provider.googledrive.appId.description", "Google Drive App ID from Google Cloud Console"), + description: t( + "provider.googledrive.appId.description", + "Google Drive App ID from Google Cloud Console", + ), placeholder: "xxxxxxxxxxxxx", }, ], diff --git a/frontend/src/core/components/shared/config/useRestartServer.ts b/frontend/src/core/components/shared/config/useRestartServer.ts index e6190e2b9..fd5993170 100644 --- a/frontend/src/core/components/shared/config/useRestartServer.ts +++ b/frontend/src/core/components/shared/config/useRestartServer.ts @@ -26,7 +26,10 @@ export function useRestartServer() { alert({ alertType: "neutral", title: t("admin.settings.restarting", "Restarting Server"), - body: t("admin.settings.restartingMessage", "The server is restarting. Please wait a moment..."), + body: t( + "admin.settings.restartingMessage", + "The server is restarting. Please wait a moment...", + ), }); // Wait a moment then reload the page setTimeout(() => { @@ -37,7 +40,10 @@ export function useRestartServer() { alert({ alertType: "error", title: t("admin.error", "Error"), - body: t("admin.settings.restartError", "Failed to restart server. Please restart manually."), + body: t( + "admin.settings.restartError", + "Failed to restart server. Please restart manually.", + ), }); }); }; diff --git a/frontend/src/core/components/shared/filePreview/DocumentStack.tsx b/frontend/src/core/components/shared/filePreview/DocumentStack.tsx index 9995d2225..bba1eb79a 100644 --- a/frontend/src/core/components/shared/filePreview/DocumentStack.tsx +++ b/frontend/src/core/components/shared/filePreview/DocumentStack.tsx @@ -6,7 +6,10 @@ export interface DocumentStackProps { children: React.ReactNode; } -const DocumentStack: React.FC = ({ totalFiles, children }) => { +const DocumentStack: React.FC = ({ + totalFiles, + children, +}) => { const stackDocumentBaseStyle = { position: "absolute" as const, width: "100%", diff --git a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx index 913e5967f..e0b61d6a4 100644 --- a/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx +++ b/frontend/src/core/components/shared/filePreview/DocumentThumbnail.tsx @@ -12,7 +12,13 @@ export interface DocumentThumbnailProps { children?: React.ReactNode; } -const DocumentThumbnail: React.FC = ({ file, thumbnail, style = {}, onClick, children }) => { +const DocumentThumbnail: React.FC = ({ + file, + thumbnail, + style = {}, + onClick, + children, +}) => { if (!file) return null; const containerStyle = { @@ -51,7 +57,12 @@ const DocumentThumbnail: React.FC = ({ file, thumbnail, return (
{getFileTypeIcon(file)}
diff --git a/frontend/src/core/components/shared/filePreview/HoverOverlay.tsx b/frontend/src/core/components/shared/filePreview/HoverOverlay.tsx index 267aec295..cc4ae15d2 100644 --- a/frontend/src/core/components/shared/filePreview/HoverOverlay.tsx +++ b/frontend/src/core/components/shared/filePreview/HoverOverlay.tsx @@ -8,14 +8,22 @@ export interface HoverOverlayProps { children: React.ReactNode; } -const HoverOverlay: React.FC = ({ onMouseEnter, onMouseLeave, children }) => { +const HoverOverlay: React.FC = ({ + onMouseEnter, + onMouseLeave, + children, +}) => { const defaultMouseEnter = (e: React.MouseEvent) => { - const overlay = e.currentTarget.querySelector(".hover-overlay") as HTMLElement; + const overlay = e.currentTarget.querySelector( + ".hover-overlay", + ) as HTMLElement; if (overlay) overlay.style.opacity = "1"; }; const defaultMouseLeave = (e: React.MouseEvent) => { - const overlay = e.currentTarget.querySelector(".hover-overlay") as HTMLElement; + const overlay = e.currentTarget.querySelector( + ".hover-overlay", + ) as HTMLElement; if (overlay) overlay.style.opacity = "0"; }; diff --git a/frontend/src/core/components/shared/filePreview/NavigationArrows.tsx b/frontend/src/core/components/shared/filePreview/NavigationArrows.tsx index 9e782d82f..f8b416f27 100644 --- a/frontend/src/core/components/shared/filePreview/NavigationArrows.tsx +++ b/frontend/src/core/components/shared/filePreview/NavigationArrows.tsx @@ -10,7 +10,12 @@ export interface NavigationArrowsProps { children: React.ReactNode; } -const NavigationArrows: React.FC = ({ onPrevious, onNext, disabled = false, children }) => { +const NavigationArrows: React.FC = ({ + onPrevious, + onNext, + disabled = false, + children, +}) => { const navigationArrowStyle = { position: "absolute" as const, top: "50%", @@ -36,7 +41,15 @@ const NavigationArrows: React.FC = ({ onPrevious, onNext, {/* Content */} - + {children} diff --git a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx index afcacad07..2666f0660 100644 --- a/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx +++ b/frontend/src/core/components/shared/filePreview/getFileTypeIcon.tsx @@ -12,21 +12,36 @@ type FileLike = File | StirlingFileStub; * - Uses the real file type and extension to decide the icon. * - No any-casts; accepts File or StirlingFileStub. */ -export function getFileTypeIcon(file: FileLike, size: number | string = "2rem"): React.ReactElement { +export function getFileTypeIcon( + file: FileLike, + size: number | string = "2rem", +): React.ReactElement { const name = (file?.name ?? "").toLowerCase(); const mime = (file?.type ?? "").toLowerCase(); const ext = detectFileExtension(name); // JavaScript if (ext === "js" || mime.includes("javascript")) { - return ; + return ( + + ); } // PDF if (ext === "pdf" || mime === "application/pdf") { - return ; + return ( + + ); } // Fallback generic - return ; + return ( + + ); } diff --git a/frontend/src/core/components/shared/fitText/textFit.ts b/frontend/src/core/components/shared/fitText/textFit.ts index afcaf57b7..4ad2ad9bc 100644 --- a/frontend/src/core/components/shared/fitText/textFit.ts +++ b/frontend/src/core/components/shared/fitText/textFit.ts @@ -17,11 +17,15 @@ export type AdjustFontSizeOptions = { * Imperative util: progressively reduces font-size until content fits within the element * (width and optional line count). Returns a cleanup that disconnects observers. */ -export function adjustFontSizeToFit(element: HTMLElement, options: AdjustFontSizeOptions = {}): () => void { +export function adjustFontSizeToFit( + element: HTMLElement, + options: AdjustFontSizeOptions = {}, +): () => void { if (!element) return () => {}; const computed = window.getComputedStyle(element); - const baseFontPx = options.maxFontSizePx ?? parseFloat(computed.fontSize || "16"); + const baseFontPx = + options.maxFontSizePx ?? parseFloat(computed.fontSize || "16"); const minScale = Math.max(0.1, options.minFontScale ?? 0.7); const stepScale = Math.max(0.005, options.stepScale ?? 0.05); const singleLine = options.singleLine ?? false; @@ -93,10 +97,20 @@ export function adjustFontSizeToFit(element: HTMLElement, options: AdjustFontSiz } /** React hook wrapper for convenience */ -export function useAdjustFontSizeToFit(ref: RefObject, options: AdjustFontSizeOptions = {}) { +export function useAdjustFontSizeToFit( + ref: RefObject, + options: AdjustFontSizeOptions = {}, +) { useEffect(() => { if (!ref.current) return; const cleanup = adjustFontSizeToFit(ref.current, options); return cleanup; - }, [ref, options.maxFontSizePx, options.minFontScale, options.stepScale, options.maxLines, options.singleLine]); + }, [ + ref, + options.maxFontSizePx, + options.minFontScale, + options.stepScale, + options.maxLines, + options.singleLine, + ]); } diff --git a/frontend/src/core/components/shared/modals/InsufficientCreditsModal.tsx b/frontend/src/core/components/shared/modals/InsufficientCreditsModal.tsx index 9610f655c..5666001a4 100644 --- a/frontend/src/core/components/shared/modals/InsufficientCreditsModal.tsx +++ b/frontend/src/core/components/shared/modals/InsufficientCreditsModal.tsx @@ -10,6 +10,8 @@ interface InsufficientCreditsModalProps { requiredCredits?: number; } -export function InsufficientCreditsModal(_props: InsufficientCreditsModalProps) { +export function InsufficientCreditsModal( + _props: InsufficientCreditsModalProps, +) { return null; } diff --git a/frontend/src/core/components/shared/pageEditor/useFileItemDragDrop.ts b/frontend/src/core/components/shared/pageEditor/useFileItemDragDrop.ts index a63246edf..b51b90737 100644 --- a/frontend/src/core/components/shared/pageEditor/useFileItemDragDrop.ts +++ b/frontend/src/core/components/shared/pageEditor/useFileItemDragDrop.ts @@ -1,5 +1,8 @@ import { useRef, useEffect, useState } from "react"; -import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { + draggable, + dropTargetForElements, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { FileId } from "@app/types/file"; @@ -25,7 +28,11 @@ interface UseFileItemDragDropReturn { * Hook to handle drag and drop functionality for file items in a list. * Manages drag state, drop zones, and reordering logic using Pragmatic Drag and Drop. */ -export const useFileItemDragDrop = ({ fileId, index, onReorder }: UseFileItemDragDropParams): UseFileItemDragDropReturn => { +export const useFileItemDragDrop = ({ + fileId, + index, + onReorder, +}: UseFileItemDragDropParams): UseFileItemDragDropReturn => { const [isDragging, setIsDragging] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const [dropPosition, setDropPosition] = useState<"above" | "below">("below"); @@ -104,7 +111,8 @@ export const useFileItemDragDrop = ({ fileId, index, onReorder }: UseFileItemDra if (!element) return; const rect = element.getBoundingClientRect(); - const clientY = (source as any).element?.getBoundingClientRect().top || 0; + const clientY = + (source as any).element?.getBoundingClientRect().top || 0; const midpoint = rect.top + rect.height / 2; setDropPosition(clientY < midpoint ? "below" : "above"); diff --git a/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx b/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx index fc97c9143..641ec5f84 100644 --- a/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx +++ b/frontend/src/core/components/shared/quickAccessBar/ActiveToolButton.tsx @@ -16,7 +16,10 @@ import React, { useEffect, useRef, useState } from "react"; import { ActionIcon, Divider } from "@mantine/core"; import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; -import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext"; +import { + useNavigationState, + useNavigationActions, +} from "@app/contexts/NavigationContext"; import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation"; import { handleUnlessSpecialClick } from "@app/utils/clickHandlers"; import FitText from "@app/components/shared/FitText"; @@ -30,8 +33,12 @@ interface ActiveToolButtonProps { const NAV_IDS = ["read", "sign", "automate"]; -const ActiveToolButton: React.FC = ({ setActiveButton, tooltipPosition = "right" }) => { - const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = useToolWorkflow(); +const ActiveToolButton: React.FC = ({ + setActiveButton, + tooltipPosition = "right", +}) => { + const { selectedTool, selectedToolKey, leftPanelView, handleBackToTools } = + useToolWorkflow(); const { hasUnsavedChanges } = useNavigationState(); const { actions: navigationActions } = useNavigationActions(); const { getHomeNavigation } = useSidebarNavigation(); @@ -40,11 +47,14 @@ const ActiveToolButton: React.FC = ({ setActiveButton, to // Special case: multiTool should always show even when sidebars are hidden const indicatorShouldShow = Boolean( selectedToolKey && - ((leftPanelView === "toolContent" && !NAV_IDS.includes(selectedToolKey)) || selectedToolKey === "multiTool"), + ((leftPanelView === "toolContent" && !NAV_IDS.includes(selectedToolKey)) || + selectedToolKey === "multiTool"), ); // Local animation and hover state - const [indicatorTool, setIndicatorTool] = useState(null); + const [indicatorTool, setIndicatorTool] = useState< + typeof selectedTool | null + >(null); const [indicatorVisible, setIndicatorVisible] = useState(false); const [replayAnim, setReplayAnim] = useState(false); const [isBackHover, setIsBackHover] = useState(false); @@ -172,9 +182,13 @@ const ActiveToolButton: React.FC = ({ setActiveButton, to variant="subtle" onMouseEnter={() => setIsBackHover(true)} onMouseLeave={() => setIsBackHover(false)} - aria-label={isBackHover ? "Back to all tools" : indicatorTool.name} + aria-label={ + isBackHover ? "Back to all tools" : indicatorTool.name + } style={{ - backgroundColor: isBackHover ? "var(--color-gray-300)" : "var(--icon-tools-bg)", + backgroundColor: isBackHover + ? "var(--color-gray-300)" + : "var(--icon-tools-bg)", color: isBackHover ? "#fff" : "var(--icon-tools-color)", border: "none", borderRadius: "8px", @@ -183,7 +197,11 @@ const ActiveToolButton: React.FC = ({ setActiveButton, to }} > - {isBackHover ? : indicatorTool.icon} + {isBackHover ? ( + + ) : ( + indicatorTool.icon + )} diff --git a/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.css b/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.css index a9bf01e9a..dde53c3c4 100644 --- a/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.css +++ b/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.css @@ -293,7 +293,8 @@ background: var(--bg-raised); border-radius: 16px; border: 1px solid var(--border-default); - box-shadow: 0 18px 36px color-mix(in srgb, var(--text-primary) 12%, transparent); + box-shadow: 0 18px 36px + color-mix(in srgb, var(--text-primary) 12%, transparent); overflow: hidden; display: flex; flex-direction: column; @@ -444,7 +445,11 @@ .quick-access-popout__input.has-error { border-color: var(--color-yellow-400); - background: color-mix(in srgb, var(--color-yellow-200) 18%, var(--bg-surface)); + background: color-mix( + in srgb, + var(--color-yellow-200) 18%, + var(--bg-surface) + ); } .quick-access-popout__input::placeholder { @@ -591,7 +596,11 @@ .quick-access-popout__warning { margin-top: 0.75rem; - background: color-mix(in srgb, var(--color-yellow-100) 35%, var(--bg-surface)); + background: color-mix( + in srgb, + var(--color-yellow-100) 35%, + var(--bg-surface) + ); border: 1px solid var(--color-yellow-300); border-radius: 12px; padding: 0.75rem; @@ -723,7 +732,11 @@ .quick-access-popout__quick-sign-btn:hover { background: color-mix(in srgb, var(--btn-open-file) 12%, var(--bg-muted)); - border-color: color-mix(in srgb, var(--btn-open-file) 40%, var(--border-default)); + border-color: color-mix( + in srgb, + var(--btn-open-file) 40%, + var(--border-default) + ); } /* Tab navigation */ @@ -814,8 +827,17 @@ width: 1.5rem; height: 1.5rem; padding: 0; - background: color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%, transparent); - border: 1px solid color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 40%, transparent); + background: color-mix( + in srgb, + var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%, + transparent + ); + border: 1px solid + color-mix( + in srgb, + var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 40%, + transparent + ); border-radius: 5px; color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6)); cursor: pointer; @@ -827,7 +849,11 @@ } .quick-access-popout__section-action:hover { - background: color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 28%, transparent); + background: color-mix( + in srgb, + var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 28%, + transparent + ); border-color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6)); } @@ -909,7 +935,11 @@ } .quick-access-popout__filter-chip.is-active { - background: color-mix(in srgb, var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%, transparent); + background: color-mix( + in srgb, + var(--accent-interactive, var(--mantine-color-blue-6, #228be6)) 15%, + transparent + ); border-color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6)); color: var(--accent-interactive, var(--mantine-color-blue-6, #228be6)); font-weight: 500; @@ -926,7 +956,8 @@ min-height: 0; } -.quick-access-sign-popout .quick-access-popout__body.sign-tab-requestSignatures { +.quick-access-sign-popout + .quick-access-popout__body.sign-tab-requestSignatures { transform: translateX(0%); } diff --git a/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.ts b/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.ts index 437efbc86..1d38c26eb 100644 --- a/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.ts +++ b/frontend/src/core/components/shared/quickAccessBar/QuickAccessBar.ts @@ -14,8 +14,12 @@ export const isNavButtonActive = ( selectedToolKey?: string | null, leftPanelView?: "toolPicker" | "toolContent" | "hidden", ): boolean => { - const isActiveByLocalState = config.type === "navigation" && activeButton === config.id; - const isActiveByContext = config.type === "navigation" && leftPanelView === "toolContent" && selectedToolKey === config.id; + const isActiveByLocalState = + config.type === "navigation" && activeButton === config.id; + const isActiveByContext = + config.type === "navigation" && + leftPanelView === "toolContent" && + selectedToolKey === config.id; const isActiveByModal = (config.type === "modal" && config.id === "files" && isFilesModalOpen) || (config.type === "modal" && config.id === "config" && configModalOpen); @@ -34,7 +38,14 @@ export const getNavButtonStyle = ( selectedToolKey?: string | null, leftPanelView?: "toolPicker" | "toolContent" | "hidden", ) => { - const isActive = isNavButtonActive(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView); + const isActive = isNavButtonActive( + config, + activeButton, + isFilesModalOpen, + configModalOpen, + selectedToolKey, + leftPanelView, + ); if (isActive) { return { @@ -57,7 +68,10 @@ export const getNavButtonStyle = ( /** * Determine the active nav button based on current tool state and registry */ -export const getActiveNavButton = (selectedToolKey: string | null, readerMode: boolean): string => { +export const getActiveNavButton = ( + selectedToolKey: string | null, + readerMode: boolean, +): string => { // Reader mode takes precedence and should highlight the Read nav item if (readerMode) { return "read"; diff --git a/frontend/src/core/components/shared/quickAccessBar/QuickAccessButton.tsx b/frontend/src/core/components/shared/quickAccessBar/QuickAccessButton.tsx index d79816739..a04719078 100644 --- a/frontend/src/core/components/shared/quickAccessBar/QuickAccessButton.tsx +++ b/frontend/src/core/components/shared/quickAccessBar/QuickAccessButton.tsx @@ -38,8 +38,12 @@ const QuickAccessButton: React.FC = ({ disabled = false, }) => { const buttonSize = size || (isActive ? "lg" : "md"); - const bgColor = backgroundColor || (isActive ? "var(--icon-tools-bg)" : "var(--icon-inactive-bg)"); - const textColor = color || (isActive ? "var(--icon-tools-color)" : "var(--icon-inactive-color)"); + const bgColor = + backgroundColor || + (isActive ? "var(--icon-tools-bg)" : "var(--icon-inactive-bg)"); + const textColor = + color || + (isActive ? "var(--icon-tools-color)" : "var(--icon-inactive-color)"); const actionIconProps = component === "a" && href diff --git a/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts b/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts index d01cbc818..8d4d7ee0d 100644 --- a/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts +++ b/frontend/src/core/components/shared/quickAccessBar/useToursTooltip.ts @@ -41,7 +41,8 @@ export function useToursTooltip(): ToursTooltipState { }; window.addEventListener(TOUR_STATE_EVENT, handleTourStateChange); - return () => window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange); + return () => + window.removeEventListener(TOUR_STATE_EVENT, handleTourStateChange); }, []); // Show once after onboarding is complete @@ -71,7 +72,11 @@ export function useToursTooltip(): ToursTooltipState { [hasBeenDismissed, toursMenuOpen, handleDismissToursTooltip], ); - const tooltipOpen = toursMenuOpen ? false : hasBeenDismissed ? undefined : showToursTooltip; + const tooltipOpen = toursMenuOpen + ? false + : hasBeenDismissed + ? undefined + : showToursTooltip; return { tooltipOpen, diff --git a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx index d7db93fe1..c0383a58c 100644 --- a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -7,12 +7,19 @@ import { ViewerContext } from "@app/contexts/ViewerContext"; import { useSignature } from "@app/contexts/SignatureContext"; import { useFileState, useFileContext } from "@app/contexts/FileContext"; import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers"; -import { useNavigationState, useNavigationGuard, useNavigationActions } from "@app/contexts/NavigationContext"; +import { + useNavigationState, + useNavigationGuard, + useNavigationActions, +} from "@app/contexts/NavigationContext"; import { useSidebarContext } from "@app/contexts/SidebarContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useRightRailTooltipSide } from "@app/hooks/useRightRailTooltipSide"; import { useRedactionMode, useRedaction } from "@app/contexts/RedactionContext"; -import { defaultParameters, RedactParameters } from "@app/hooks/tools/redact/useRedactParameters"; +import { + defaultParameters, + RedactParameters, +} from "@app/hooks/tools/redact/useRedactParameters"; import { RedactionMode } from "@embedpdf/plugin-redaction"; interface ViewerAnnotationControlsProps { @@ -20,11 +27,15 @@ interface ViewerAnnotationControlsProps { disabled?: boolean; } -export default function ViewerAnnotationControls({ currentView, disabled = false }: ViewerAnnotationControlsProps) { +export default function ViewerAnnotationControls({ + currentView, + disabled = false, +}: ViewerAnnotationControlsProps) { const { t } = useTranslation(); const { sidebarRefs } = useSidebarContext(); const { setLeftPanelView, setSidebarsVisible } = useToolWorkflow(); - const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs); + const { position: tooltipPosition, offset: tooltipOffset } = + useRightRailTooltipSide(sidebarRefs); // Viewer context for PDF controls - safely handle when not available const viewerContext = React.useContext(ViewerContext); @@ -45,33 +56,60 @@ export default function ViewerAnnotationControls({ currentView, disabled = false // Get redaction pending state and navigation guard const { isRedacting: _isRedacting } = useRedactionMode(); - const { requestNavigation, setHasUnsavedChanges, hasUnsavedChanges } = useNavigationGuard(); - const { setRedactionMode, activateRedact, setRedactionConfig, setRedactionsApplied, redactionApiRef, setActiveType } = - useRedaction(); + const { requestNavigation, setHasUnsavedChanges, hasUnsavedChanges } = + useNavigationGuard(); + const { + setRedactionMode, + activateRedact, + setRedactionConfig, + setRedactionsApplied, + redactionApiRef, + setActiveType, + } = useRedaction(); // Check if we're in any annotation tool that should disable the toggle const isInAnnotationTool = - selectedTool === "annotate" || selectedTool === "sign" || selectedTool === "addImage" || selectedTool === "addText"; + selectedTool === "annotate" || + selectedTool === "sign" || + selectedTool === "addImage" || + selectedTool === "addText"; // Check if we're on annotate tool to highlight the button const isAnnotateActive = selectedTool === "annotate"; - const annotationsHidden = viewerContext ? !viewerContext.isAnnotationsVisible : false; + const annotationsHidden = viewerContext + ? !viewerContext.isAnnotationsVisible + : false; // Persist annotations to file if there are unsaved changes const saveAnnotationsIfNeeded = async () => { - if (!viewerContext?.exportActions?.saveAsCopy || currentView !== "viewer" || !historyApiRef?.current?.canUndo()) return; + if ( + !viewerContext?.exportActions?.saveAsCopy || + currentView !== "viewer" || + !historyApiRef?.current?.canUndo() + ) + return; if (activeFiles.length === 0 || state.files.ids.length === 0) return; try { const arrayBuffer = await viewerContext.exportActions.saveAsCopy(); if (!arrayBuffer) return; - const file = new File([new Blob([arrayBuffer])], activeFiles[0].name, { type: "application/pdf" }); + const file = new File([new Blob([arrayBuffer])], activeFiles[0].name, { + type: "application/pdf", + }); const parentStub = selectors.getStirlingFileStub(state.files.ids[0]); if (!parentStub) return; - const { stirlingFiles, stubs } = await createStirlingFilesAndStubs([file], parentStub, "redact"); - await fileActions.consumeFiles([state.files.ids[0]], stirlingFiles, stubs); + const { stirlingFiles, stubs } = await createStirlingFilesAndStubs( + [file], + parentStub, + "redact", + ); + await fileActions.consumeFiles( + [state.files.ids[0]], + stirlingFiles, + stubs, + ); // Clear unsaved changes flags after successful save setHasUnsavedChanges(false); @@ -151,7 +189,11 @@ export default function ViewerAnnotationControls({ currentView, disabled = false <> {/* Redaction Mode Toggle */} - + {/* Annotation Visibility Toggle */} diff --git a/frontend/src/core/components/shared/signing/ActiveSessionsPanel.tsx b/frontend/src/core/components/shared/signing/ActiveSessionsPanel.tsx index a0c042eaf..d0d2ed75a 100644 --- a/frontend/src/core/components/shared/signing/ActiveSessionsPanel.tsx +++ b/frontend/src/core/components/shared/signing/ActiveSessionsPanel.tsx @@ -21,10 +21,18 @@ interface ActiveSessionsPanelProps { onSessionClick: (session: SessionItem) => void; } -const ActiveSessionsPanel = ({ sessions, loading, onSessionClick }: ActiveSessionsPanelProps) => { +const ActiveSessionsPanel = ({ + sessions, + loading, + onSessionClick, +}: ActiveSessionsPanelProps) => { const { t } = useTranslation(); - const getStatusColor = (status?: string, itemType?: string, item?: SessionItem): string => { + const getStatusColor = ( + status?: string, + itemType?: string, + item?: SessionItem, + ): string => { if (itemType === "mySession" && item) { const signedCount = item.signedCount ?? 0; const totalCount = item.participantCount ?? 0; @@ -57,7 +65,11 @@ const ActiveSessionsPanel = ({ sessions, loading, onSessionClick }: ActiveSessio } // Show progress for all cases (including 0/X) if (totalCount > 0) { - return t("certSign.signatureProgress", "{{signedCount}}/{{totalCount}} signatures", { signedCount, totalCount }); + return t( + "certSign.signatureProgress", + "{{signedCount}}/{{totalCount}} signatures", + { signedCount, totalCount }, + ); } return t("certSign.awaitingSignatures", "Awaiting signatures"); } @@ -86,7 +98,10 @@ const ActiveSessionsPanel = ({ sessions, loading, onSessionClick }: ActiveSessio {sessions.length === 0 ? (
- {t("quickAccess.noActiveSessions", "No pending sign requests or active sessions")} + {t( + "quickAccess.noActiveSessions", + "No pending sign requests or active sessions", + )}
) : ( @@ -98,16 +113,20 @@ const ActiveSessionsPanel = ({ sessions, loading, onSessionClick }: ActiveSessio onClick={() => onSessionClick(session)} >
-
{session.documentName}
+
+ {session.documentName} +
{session.itemType === "signRequest" ? ( <> From: {session.ownerUsername} - {session.dueDate && ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`} + {session.dueDate && + ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`} ) : ( <> - Created: {new Date(session.createdAt).toLocaleDateString()} + Created:{" "} + {new Date(session.createdAt).toLocaleDateString()} {session.signedCount !== undefined && session.participantCount !== undefined && ` • ${session.signedCount}/${session.participantCount} signed`} @@ -116,7 +135,14 @@ const ActiveSessionsPanel = ({ sessions, loading, onSessionClick }: ActiveSessio
- + {getStatusLabel(session)}
diff --git a/frontend/src/core/components/shared/signing/CompletedSessionsPanel.tsx b/frontend/src/core/components/shared/signing/CompletedSessionsPanel.tsx index d75c3a350..02036277a 100644 --- a/frontend/src/core/components/shared/signing/CompletedSessionsPanel.tsx +++ b/frontend/src/core/components/shared/signing/CompletedSessionsPanel.tsx @@ -21,7 +21,11 @@ interface CompletedSessionsPanelProps { onSessionClick: (session: SessionItem) => void; } -const CompletedSessionsPanel = ({ sessions, loading, onSessionClick }: CompletedSessionsPanelProps) => { +const CompletedSessionsPanel = ({ + sessions, + loading, + onSessionClick, +}: CompletedSessionsPanelProps) => { const { t } = useTranslation(); const getStatusColor = (status?: string, itemType?: string): string => { @@ -73,16 +77,20 @@ const CompletedSessionsPanel = ({ sessions, loading, onSessionClick }: Completed onClick={() => onSessionClick(session)} >
-
{session.documentName}
+
+ {session.documentName} +
{session.itemType === "signRequest" ? ( <> From: {session.ownerUsername} - {session.dueDate && ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`} + {session.dueDate && + ` • Due: ${new Date(session.dueDate).toLocaleDateString()}`} ) : ( <> - Created: {new Date(session.createdAt).toLocaleDateString()} + Created:{" "} + {new Date(session.createdAt).toLocaleDateString()} {session.signedCount !== undefined && session.participantCount !== undefined && ` • ${session.signedCount}/${session.participantCount} signed`} @@ -91,7 +99,10 @@ const CompletedSessionsPanel = ({ sessions, loading, onSessionClick }: Completed
- + {getStatusLabel(session)}
diff --git a/frontend/src/core/components/shared/signing/CreateSessionFlow.tsx b/frontend/src/core/components/shared/signing/CreateSessionFlow.tsx index cd93e82d3..a2042ea01 100644 --- a/frontend/src/core/components/shared/signing/CreateSessionFlow.tsx +++ b/frontend/src/core/components/shared/signing/CreateSessionFlow.tsx @@ -29,14 +29,22 @@ interface StepWrapperProps { children: React.ReactNode; } -const StepWrapper: React.FC = ({ number, title, isActive, isCompleted, children }) => { +const StepWrapper: React.FC = ({ + number, + title, + isActive, + isCompleted, + children, +}) => { const { t } = useTranslation(); return (
= ({ const [currentStep, setCurrentStep] = useState(1); // Signature settings state - const [signatureSettings, setSignatureSettings] = useState({ - showSignature: false, - pageNumber: 1, - reason: "", - location: "", - showLogo: false, - includeSummaryPage: false, - }); + const [signatureSettings, setSignatureSettings] = useState( + { + showSignature: false, + pageNumber: 1, + reason: "", + location: "", + showLogo: false, + includeSummaryPage: false, + }, + ); const groupSigningTips = useGroupSigningTips(); const signatureSettingsTips = useSignatureSettingsTips(); @@ -128,12 +138,18 @@ export const CreateSessionFlow: React.FC = ({ }, { number: 2, - title: t("groupSigning.steps.selectParticipants.title", "Choose Participants"), + title: t( + "groupSigning.steps.selectParticipants.title", + "Choose Participants", + ), tooltip: null, }, { number: 3, - title: t("groupSigning.steps.configureDefaults.title", "Configure Signature Settings"), + title: t( + "groupSigning.steps.configureDefaults.title", + "Configure Signature Settings", + ), tooltip: signatureSettingsTips, }, { @@ -147,9 +163,17 @@ export const CreateSessionFlow: React.FC = ({
{/* Step 1: Select Document */} - 1}> + 1} + > {hasValidFile ? ( - setCurrentStep(2)} /> + setCurrentStep(2)} + /> ) : (
@@ -163,7 +187,12 @@ export const CreateSessionFlow: React.FC = ({ {/* Step 2: Select Participants */} - 2}> + 2} + > = ({ {/* Step 3: Configure Signature Defaults */} - 3}> + 3} + > = ({ {/* Step 4: Review & Send */} - + {selectedFile && ( - {t("quickAccess.selectSingleFileToRequest", "Select a single PDF file to request signatures")} + {t( + "quickAccess.selectSingleFileToRequest", + "Select a single PDF file to request signatures", + )}
) : ( <>
-
{t("quickAccess.selectedFile", "Selected file")}
+
+ {t("quickAccess.selectedFile", "Selected file")} +
- {selectedFiles[0]?.name || t("quickAccess.noFile", "No file selected")} + {selectedFiles[0]?.name || + t("quickAccess.noFile", "No file selected")}
-
{t("quickAccess.selectUsers", "Select users to sign")}
+
+ {t("quickAccess.selectUsers", "Select users to sign")} +
-
{t("quickAccess.dueDate", "Due date (optional)")}
+
+ {t("quickAccess.dueDate", "Due date (optional)")} +
onIncludeSummaryPageChange(e.currentTarget.checked)} + onChange={(e) => + onIncludeSummaryPageChange(e.currentTarget.checked) + } disabled={creating} size="sm" /> diff --git a/frontend/src/core/components/shared/signing/SignPopout.tsx b/frontend/src/core/components/shared/signing/SignPopout.tsx index ed4136f08..51de3b4f0 100644 --- a/frontend/src/core/components/shared/signing/SignPopout.tsx +++ b/frontend/src/core/components/shared/signing/SignPopout.tsx @@ -9,9 +9,17 @@ import CompletedSessionsPanel from "@app/components/shared/signing/CompletedSess import CreateSessionPanel from "@app/components/shared/signing/CreateSessionPanel"; import apiClient from "@app/services/apiClient"; import { alert } from "@app/components/toast"; -import { SignRequestSummary, SignRequestDetail, SessionSummary, SessionDetail } from "@app/types/signingSession"; +import { + SignRequestSummary, + SignRequestDetail, + SessionSummary, + SessionDetail, +} from "@app/types/signingSession"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; -import { useNavigationActions, useNavigationState } from "@app/contexts/NavigationContext"; +import { + useNavigationActions, + useNavigationState, +} from "@app/contexts/NavigationContext"; import { useFileSelection } from "@app/contexts/file/fileHooks"; import { fileStorage } from "@app/services/fileStorage"; import { useFileActions } from "@app/contexts/FileContext"; @@ -19,19 +27,25 @@ import SignRequestWorkbenchView from "@app/components/tools/certSign/SignRequest import SessionDetailWorkbenchView from "@app/components/tools/certSign/SessionDetailWorkbenchView"; import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; -export const SIGN_REQUEST_WORKBENCH_TYPE = "custom:signRequestWorkbench" as const; -export const SESSION_DETAIL_WORKBENCH_TYPE = "custom:sessionDetailWorkbench" as const; +export const SIGN_REQUEST_WORKBENCH_TYPE = + "custom:signRequestWorkbench" as const; +export const SESSION_DETAIL_WORKBENCH_TYPE = + "custom:sessionDetailWorkbench" as const; type SessionItem = (SignRequestSummary | SessionSummary) & { itemType: "signRequest" | "mySession"; }; -function sortSessions(sessions: SessionItem[], tab: "active" | "completed"): SessionItem[] { +function sortSessions( + sessions: SessionItem[], + tab: "active" | "completed", +): SessionItem[] { return [...sessions].sort((a, b) => { if (tab === "active") { const aDue = (a as SignRequestSummary).dueDate; const bDue = (b as SignRequestSummary).dueDate; - if (aDue && bDue) return new Date(aDue).getTime() - new Date(bDue).getTime(); + if (aDue && bDue) + return new Date(aDue).getTime() - new Date(bDue).getTime(); if (aDue) return -1; if (bDue) return 1; } @@ -47,11 +61,20 @@ interface SignPopoutProps { groupSigningEnabled: boolean; } -const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: SignPopoutProps) => { +const SignPopout = ({ + isOpen, + onClose, + buttonRef, + isRTL, + groupSigningEnabled, +}: SignPopoutProps) => { const { t } = useTranslation(); const isPhone = useIsPhone(); const popoverRef = useRef(null); - const [popoverPosition, setPopoverPosition] = useState({ top: 160, left: 84 }); + const [popoverPosition, setPopoverPosition] = useState({ + top: 160, + left: 84, + }); const [maxHeight, setMaxHeight] = useState(undefined); // Tab state @@ -126,7 +149,10 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: registerCustomWorkbenchView({ id: SESSION_DETAIL_WORKBENCH_ID, workbenchId: SESSION_DETAIL_WORKBENCH_TYPE, - label: t("certSign.collab.sessionDetail.workbenchTitle", "Session Management"), + label: t( + "certSign.collab.sessionDetail.workbenchTitle", + "Session Management", + ), component: SessionDetailWorkbenchView, hideTopControls: true, hideToolPanel: true, @@ -197,7 +223,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: if (popoverRef.current?.contains(target)) return; if (buttonRef.current?.contains(target)) return; - const mantineDropdown = (target as Element).closest?.(".mantine-Combobox-dropdown, .mantine-Popover-dropdown"); + const mantineDropdown = (target as Element).closest?.( + ".mantine-Combobox-dropdown, .mantine-Popover-dropdown", + ); if (mantineDropdown) return; onClose(); @@ -220,13 +248,18 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: setLoading(true); try { const [requestsResponse, sessionsResponse] = await Promise.all([ - apiClient.get("/api/v1/security/cert-sign/sign-requests"), + apiClient.get( + "/api/v1/security/cert-sign/sign-requests", + ), apiClient.get("/api/v1/security/cert-sign/sessions"), ]); setSignRequests(requestsResponse.data); setMySessions(sessionsResponse.data); } catch (error) { - console.error("Failed to fetch signing data:", error instanceof Error ? error.message : error); + console.error( + "Failed to fetch signing data:", + error instanceof Error ? error.message : error, + ); alert({ alertType: "warning", title: t("common.error"), @@ -248,7 +281,12 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: // Auto-refresh Active tab every 15 seconds to show updated signature status useEffect(() => { - if (isOpen && groupSigningEnabled && activeTab === "active" && !showCreatePanel) { + if ( + isOpen && + groupSigningEnabled && + activeTab === "active" && + !showCreatePanel + ) { const interval = setInterval(() => { fetchData(); }, 15000); // Refresh every 15 seconds @@ -264,7 +302,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: .filter((req) => req.myStatus !== "SIGNED" && req.myStatus !== "DECLINED") .map((req) => ({ ...req, itemType: "signRequest" as const })), // Sessions user created that aren't finalized yet - ...mySessions.filter((s) => !s.finalized).map((s) => ({ ...s, itemType: "mySession" as const })), + ...mySessions + .filter((s) => !s.finalized) + .map((s) => ({ ...s, itemType: "mySession" as const })), ]; const completedSessions: SessionItem[] = [ @@ -273,7 +313,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: .filter((req) => req.myStatus === "SIGNED" || req.myStatus === "DECLINED") .map((req) => ({ ...req, itemType: "signRequest" as const })), // Sessions user created that have been finalized - ...mySessions.filter((s) => s.finalized).map((s) => ({ ...s, itemType: "mySession" as const })), + ...mySessions + .filter((s) => s.finalized) + .map((s) => ({ ...s, itemType: "mySession" as const })), ]; // Filter options vary by tab @@ -286,7 +328,10 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: : [ { key: "mine", label: t("quickAccess.filterMine", "Mine") }, { key: "signed", label: t("quickAccess.filterSigned", "Signed") }, - { key: "declined", label: t("quickAccess.filterDeclined", "Declined") }, + { + key: "declined", + label: t("quickAccess.filterDeclined", "Declined"), + }, ]; const applyFiltersAndSearch = (sessions: SessionItem[]): SessionItem[] => { @@ -296,16 +341,31 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: result = result.filter((s) => s.documentName.toLowerCase().includes(q)); } const now = new Date(); - if (activeFilters.has("mine")) result = result.filter((s) => s.itemType === "mySession"); + if (activeFilters.has("mine")) + result = result.filter((s) => s.itemType === "mySession"); if (activeFilters.has("overdue")) - result = result.filter((s) => (s as SignRequestSummary).dueDate && new Date((s as SignRequestSummary).dueDate) < now); - if (activeFilters.has("signed")) result = result.filter((s) => (s as SignRequestSummary).myStatus === "SIGNED"); - if (activeFilters.has("declined")) result = result.filter((s) => (s as SignRequestSummary).myStatus === "DECLINED"); + result = result.filter( + (s) => + (s as SignRequestSummary).dueDate && + new Date((s as SignRequestSummary).dueDate) < now, + ); + if (activeFilters.has("signed")) + result = result.filter( + (s) => (s as SignRequestSummary).myStatus === "SIGNED", + ); + if (activeFilters.has("declined")) + result = result.filter( + (s) => (s as SignRequestSummary).myStatus === "DECLINED", + ); return result; }; - const displayedActiveSessions = applyFiltersAndSearch(sortSessions(activeSessions, "active")); - const displayedCompletedSessions = applyFiltersAndSearch(sortSessions(completedSessions, "completed")); + const displayedActiveSessions = applyFiltersAndSearch( + sortSessions(activeSessions, "active"), + ); + const displayedCompletedSessions = applyFiltersAndSearch( + sortSessions(completedSessions, "completed"), + ); // Create session handler const handleCreateSession = useCallback(async () => { @@ -314,7 +374,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: setCreating(true); try { const selectedFile = selectedFiles[0]; - const stirlingFile = await fileStorage.getStirlingFile(selectedFile.fileId); + const stirlingFile = await fileStorage.getStirlingFile( + selectedFile.fileId, + ); if (!stirlingFile) throw new Error("File not found"); const formData = new FormData(); @@ -351,7 +413,10 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: setShowCreatePanel(false); await fetchData(); } catch (error) { - console.error("Failed to create session:", error instanceof Error ? error.message : error); + console.error( + "Failed to create session:", + error instanceof Error ? error.message : error, + ); alert({ alertType: "error", title: t("common.error"), @@ -362,7 +427,14 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: } finally { setCreating(false); } - }, [selectedUserIds, dueDate, selectedFiles, fetchData, t, includeSummaryPage]); + }, [ + selectedUserIds, + dueDate, + selectedFiles, + fetchData, + t, + includeSummaryPage, + ]); // Handle clicking a sign request const handleSignRequestClick = useCallback( @@ -370,15 +442,24 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: onClose(); try { const [detailResponse, pdfResponse] = await Promise.all([ - apiClient.get(`/api/v1/security/cert-sign/sign-requests/${request.sessionId}`), - apiClient.get(`/api/v1/security/cert-sign/sign-requests/${request.sessionId}/document`, { - responseType: "blob", - }), + apiClient.get( + `/api/v1/security/cert-sign/sign-requests/${request.sessionId}`, + ), + apiClient.get( + `/api/v1/security/cert-sign/sign-requests/${request.sessionId}/document`, + { + responseType: "blob", + }, + ), ]); - const pdfFile = new File([pdfResponse.data], detailResponse.data.documentName, { - type: "application/pdf", - }); + const pdfFile = new File( + [pdfResponse.data], + detailResponse.data.documentName, + { + type: "application/pdf", + }, + ); const canSign = detailResponse.data.myStatus === "PENDING" || detailResponse.data.myStatus === "NOTIFIED" || @@ -387,7 +468,8 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: setCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID, { signRequest: detailResponse.data, pdfFile, - onSign: (certData: FormData) => handleSign(request.sessionId, certData), + onSign: (certData: FormData) => + handleSign(request.sessionId, certData), onDecline: () => handleDecline(request.sessionId), onBack: () => { clearCustomWorkbenchViewData(SIGN_REQUEST_WORKBENCH_ID); @@ -400,7 +482,10 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: navigationActions.setWorkbench(SIGN_REQUEST_WORKBENCH_TYPE); }); } catch (error) { - console.error("Failed to load sign request:", error instanceof Error ? error.message : error); + console.error( + "Failed to load sign request:", + error instanceof Error ? error.message : error, + ); alert({ alertType: "error", title: t("common.error"), @@ -410,7 +495,13 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: }); } }, - [onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t], + [ + onClose, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + navigationActions, + t, + ], ); // Handle clicking a session @@ -419,7 +510,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: onClose(); try { // First fetch session detail - const detailResponse = await apiClient.get(`/api/v1/security/cert-sign/sessions/${session.sessionId}`); + const detailResponse = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${session.sessionId}`, + ); // Determine which endpoint to use based on session state let pdfFile: File | null = null; @@ -427,10 +520,15 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: if (detailResponse.data.finalized) { // Finalized sessions have signed PDF available try { - const pdfResponse = await apiClient.get(`/api/v1/security/cert-sign/sessions/${session.sessionId}/signed-pdf`, { - responseType: "blob", + const pdfResponse = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${session.sessionId}/signed-pdf`, + { + responseType: "blob", + }, + ); + pdfFile = new File([pdfResponse.data], session.documentName, { + type: "application/pdf", }); - pdfFile = new File([pdfResponse.data], session.documentName, { type: "application/pdf" }); } catch (pdfError: any) { if (pdfError?.response?.status === 404) { // Finalized but signed PDF not available - backend issue @@ -451,10 +549,15 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: } else { // For non-finalized sessions, get original PDF (always available) try { - const pdfResponse = await apiClient.get(`/api/v1/security/cert-sign/sessions/${session.sessionId}/pdf`, { - responseType: "blob", + const pdfResponse = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${session.sessionId}/pdf`, + { + responseType: "blob", + }, + ); + pdfFile = new File([pdfResponse.data], session.documentName, { + type: "application/pdf", }); - pdfFile = new File([pdfResponse.data], session.documentName, { type: "application/pdf" }); } catch (_error) { // Fallback if PDF not available pdfFile = null; @@ -464,11 +567,14 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, { session: detailResponse.data, pdfFile, - onFinalize: () => handleFinalize(session.sessionId, session.documentName), - onLoadSignedPdf: () => handleLoadSignedPdf(session.sessionId, session.documentName), + onFinalize: () => + handleFinalize(session.sessionId, session.documentName), + onLoadSignedPdf: () => + handleLoadSignedPdf(session.sessionId, session.documentName), onAddParticipants: (userIds: number[], defaultReason?: string) => handleAddParticipants(session.sessionId, userIds, defaultReason), - onRemoveParticipant: (participantId: number) => handleRemoveParticipant(session.sessionId, participantId), + onRemoveParticipant: (participantId: number) => + handleRemoveParticipant(session.sessionId, participantId), onDelete: () => handleDeleteSession(session.sessionId), onBack: () => { clearCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID); @@ -481,22 +587,37 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: navigationActions.setWorkbench(SESSION_DETAIL_WORKBENCH_TYPE); }); } catch (error) { - console.error("Failed to load session:", error instanceof Error ? error.message : error); + console.error( + "Failed to load session:", + error instanceof Error ? error.message : error, + ); alert({ alertType: "error", title: t("common.error"), - body: t("certSign.sessions.fetchFailed", "Failed to load session details"), + body: t( + "certSign.sessions.fetchFailed", + "Failed to load session details", + ), expandable: false, durationMs: 3000, }); } }, - [onClose, setCustomWorkbenchViewData, clearCustomWorkbenchViewData, navigationActions, t], + [ + onClose, + setCustomWorkbenchViewData, + clearCustomWorkbenchViewData, + navigationActions, + t, + ], ); // Action handlers const handleSign = async (sessionId: string, certificateData: FormData) => { - await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, certificateData); + await apiClient.post( + `/api/v1/security/cert-sign/sign-requests/${sessionId}/sign`, + certificateData, + ); alert({ alertType: "success", title: t("success"), @@ -510,7 +631,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: }; const handleDecline = async (sessionId: string) => { - await apiClient.post(`/api/v1/security/cert-sign/sign-requests/${sessionId}/decline`); + await apiClient.post( + `/api/v1/security/cert-sign/sign-requests/${sessionId}/decline`, + ); alert({ alertType: "success", title: t("success"), @@ -524,13 +647,21 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: }; const handleFinalize = async (sessionId: string, documentName: string) => { - const response = await apiClient.post(`/api/v1/security/cert-sign/sessions/${sessionId}/finalize`, null, { - responseType: "blob", - }); + const response = await apiClient.post( + `/api/v1/security/cert-sign/sessions/${sessionId}/finalize`, + null, + { + responseType: "blob", + }, + ); const contentDisposition = response.headers["content-disposition"]; const filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/); - const filename = filenameMatch ? filenameMatch[1] : `${documentName}_signed.pdf`; - const signedFile = new File([response.data], filename, { type: "application/pdf" }); + const filename = filenameMatch + ? filenameMatch[1] + : `${documentName}_signed.pdf`; + const signedFile = new File([response.data], filename, { + type: "application/pdf", + }); await fileActions.addFiles([signedFile]); alert({ alertType: "success", @@ -544,14 +675,24 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: await fetchData(); }; - const handleLoadSignedPdf = async (sessionId: string, documentName: string) => { - const response = await apiClient.get(`/api/v1/security/cert-sign/sessions/${sessionId}/signed-pdf`, { - responseType: "blob", - }); + const handleLoadSignedPdf = async ( + sessionId: string, + documentName: string, + ) => { + const response = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${sessionId}/signed-pdf`, + { + responseType: "blob", + }, + ); const contentDisposition = response.headers["content-disposition"]; const filenameMatch = contentDisposition?.match(/filename="?(.+?)"?$/); - const filename = filenameMatch ? filenameMatch[1] : `${documentName}_signed.pdf`; - const signedFile = new File([response.data], filename, { type: "application/pdf" }); + const filename = filenameMatch + ? filenameMatch[1] + : `${documentName}_signed.pdf`; + const signedFile = new File([response.data], filename, { + type: "application/pdf", + }); await fileActions.addFiles([signedFile]); alert({ alertType: "success", @@ -564,18 +705,30 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: navigationActions.setWorkbench("viewer"); }; - const handleAddParticipants = async (sessionId: string, userIds: number[], defaultReason?: string) => { + const handleAddParticipants = async ( + sessionId: string, + userIds: number[], + defaultReason?: string, + ) => { const requests = userIds.map((userId) => ({ userId, defaultReason: defaultReason || undefined, sendNotification: true, })); - await apiClient.post(`/api/v1/security/cert-sign/sessions/${sessionId}/participants`, requests); + await apiClient.post( + `/api/v1/security/cert-sign/sessions/${sessionId}/participants`, + requests, + ); await handleRefreshSession(sessionId); }; - const handleRemoveParticipant = async (sessionId: string, participantId: number) => { - await apiClient.delete(`/api/v1/security/cert-sign/sessions/${sessionId}/participants/${participantId}`); + const handleRemoveParticipant = async ( + sessionId: string, + participantId: number, + ) => { + await apiClient.delete( + `/api/v1/security/cert-sign/sessions/${sessionId}/participants/${participantId}`, + ); await handleRefreshSession(sessionId); }; @@ -594,19 +747,29 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: }; const handleRefreshSession = async (sessionId: string) => { - const response = await apiClient.get(`/api/v1/security/cert-sign/sessions/${sessionId}`); + const response = await apiClient.get( + `/api/v1/security/cert-sign/sessions/${sessionId}`, + ); // Update workbench data, preserving PDF and callbacks - setCustomWorkbenchViewData(SESSION_DETAIL_WORKBENCH_ID, (prevData: any) => ({ - ...prevData, - session: response.data, - })); + setCustomWorkbenchViewData( + SESSION_DETAIL_WORKBENCH_ID, + (prevData: any) => ({ + ...prevData, + session: response.data, + }), + ); }; if (typeof document === "undefined") return null; // Shared card content — rendered inside either the portal (desktop/tablet) or Drawer (phone) const popoutCard = ( -
+
{/* Header */}
@@ -685,7 +854,9 @@ const SignPopout = ({ isOpen, onClose, buttonRef, isRTL, groupSigningEnabled }: {!showCreatePanel && groupSigningEnabled && ( <>
- {t("quickAccess.signatureRequests", "Signature Requests")} + + {t("quickAccess.signatureRequests", "Signature Requests")} + diff --git a/frontend/src/core/components/shared/signing/steps/ReviewSessionStep.tsx b/frontend/src/core/components/shared/signing/steps/ReviewSessionStep.tsx index af773dcc7..f236ec593 100644 --- a/frontend/src/core/components/shared/signing/steps/ReviewSessionStep.tsx +++ b/frontend/src/core/components/shared/signing/steps/ReviewSessionStep.tsx @@ -41,7 +41,9 @@ export const ReviewSessionStep: React.FC = ({ {/* Document Info */}
- + {t("groupSigning.steps.review.document", "Document")} @@ -88,33 +90,54 @@ export const ReviewSessionStep: React.FC = ({ - {t("groupSigning.steps.review.signatureSettings", "Signature Settings")} + {t( + "groupSigning.steps.review.signatureSettings", + "Signature Settings", + )} - {t("groupSigning.steps.review.visibility", "Visibility:")}{" "} + + {t("groupSigning.steps.review.visibility", "Visibility:")} + {" "} {signatureSettings.showSignature - ? t("groupSigning.steps.review.visible", "Visible on page {{page}}", { - page: signatureSettings.pageNumber || 1, - }) - : t("groupSigning.steps.review.invisible", "Invisible (metadata only)")} + ? t( + "groupSigning.steps.review.visible", + "Visible on page {{page}}", + { + page: signatureSettings.pageNumber || 1, + }, + ) + : t( + "groupSigning.steps.review.invisible", + "Invisible (metadata only)", + )} {signatureSettings.showSignature && signatureSettings.reason && ( - {t("groupSigning.steps.review.reason", "Reason:")} {signatureSettings.reason} + + {t("groupSigning.steps.review.reason", "Reason:")} + {" "} + {signatureSettings.reason} )} {signatureSettings.showSignature && signatureSettings.location && ( - {t("groupSigning.steps.review.location", "Location:")} {signatureSettings.location} + + {t("groupSigning.steps.review.location", "Location:")} + {" "} + {signatureSettings.location} )} {signatureSettings.showSignature && ( {t("groupSigning.steps.review.logo", "Logo:")}{" "} {signatureSettings.showLogo - ? t("groupSigning.steps.review.logoShown", "Stirling PDF logo shown") + ? t( + "groupSigning.steps.review.logoShown", + "Stirling PDF logo shown", + ) : t("groupSigning.steps.review.logoHidden", "No logo")} )} @@ -136,12 +159,19 @@ export const ReviewSessionStep: React.FC = ({ value={dueDate} onChange={(e) => onDueDateChange(e.target.value)} disabled={disabled} - placeholder={t("groupSigning.steps.review.dueDatePlaceholder", "Select due date...")} + placeholder={t( + "groupSigning.steps.review.dueDatePlaceholder", + "Select due date...", + )} />
- )} diff --git a/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx b/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx index 650cf7e53..e32458937 100644 --- a/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx +++ b/frontend/src/core/components/shared/signing/steps/SelectParticipantsStep.tsx @@ -26,12 +26,18 @@ export const SelectParticipantsStep: React.FC = ({
- {t("groupSigning.steps.selectParticipants.label", "Select participants")} + {t( + "groupSigning.steps.selectParticipants.label", + "Select participants", + )}
@@ -46,11 +52,22 @@ export const SelectParticipantsStep: React.FC = ({ )} - -
diff --git a/frontend/src/core/components/shared/sliderWithInput/SliderWithInput.tsx b/frontend/src/core/components/shared/sliderWithInput/SliderWithInput.tsx index c09699eb9..fa729664a 100644 --- a/frontend/src/core/components/shared/sliderWithInput/SliderWithInput.tsx +++ b/frontend/src/core/components/shared/sliderWithInput/SliderWithInput.tsx @@ -28,7 +28,14 @@ export default function SliderWithInput({
- +
= ({ content, tips, extraRightPadding = 0 }) => { +export const TooltipContent: React.FC = ({ + content, + tips, + extraRightPadding = 0, +}) => { return (
= ({ content, tips, e {tips ? ( <> {tips.map((tip, index) => ( -
+
{tip.title && (
= ({ content, tips, e )} {tip.description && (

)} {tip.bullets && tip.bullets.length > 0 && ( -

    +
      {tip.bullets.map((bullet, bulletIndex) => ( -
    • +
    • ))}
    )} - {tip.body &&
    {tip.body}
    } + {tip.body && ( +
    {tip.body}
    + )}
))} {content &&
{content}
} diff --git a/frontend/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx b/frontend/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx index 4b2b4ce42..d316dd521 100644 --- a/frontend/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx +++ b/frontend/src/core/components/shared/wetSignature/DrawSignatureCanvas.tsx @@ -9,7 +9,11 @@ interface DrawSignatureCanvasProps { disabled?: boolean; } -export const DrawSignatureCanvas: React.FC = ({ signature, onChange, disabled = false }) => { +export const DrawSignatureCanvas: React.FC = ({ + signature, + onChange, + disabled = false, +}) => { const { t } = useTranslation(); const canvasRef = useRef(null); const [isDrawing, setIsDrawing] = useState(false); @@ -90,7 +94,10 @@ export const DrawSignatureCanvas: React.FC = ({ signat return ( - {t("certSign.collab.signRequest.drawSignature", "Draw your signature below")} + {t( + "certSign.collab.signRequest.drawSignature", + "Draw your signature below", + )} = ({ signat {t("certSign.collab.signRequest.penColor", "Pen Color")} - +
- {t("certSign.collab.signRequest.penSize", "Pen Size: {{size}}px", { size: penSize })} + {t("certSign.collab.signRequest.penSize", "Pen Size: {{size}}px", { + size: penSize, + })} = ({ value, onChange, disabled = false }) => { +export const SignatureTypeSelector: React.FC = ({ + value, + onChange, + disabled = false, +}) => { const { t } = useTranslation(); return ( @@ -24,7 +28,10 @@ export const SignatureTypeSelector: React.FC = ({ va }, { value: "upload", - label: t("certSign.collab.signRequest.signatureType.upload", "Upload"), + label: t( + "certSign.collab.signRequest.signatureType.upload", + "Upload", + ), }, { value: "type", diff --git a/frontend/src/core/components/shared/wetSignature/TypeSignatureText.tsx b/frontend/src/core/components/shared/wetSignature/TypeSignatureText.tsx index 0ae90cbf8..fc13d85cf 100644 --- a/frontend/src/core/components/shared/wetSignature/TypeSignatureText.tsx +++ b/frontend/src/core/components/shared/wetSignature/TypeSignatureText.tsx @@ -1,4 +1,11 @@ -import { Stack, TextInput, Select, ColorPicker, Slider, Text } from "@mantine/core"; +import { + Stack, + TextInput, + Select, + ColorPicker, + Slider, + Text, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; import { useEffect, useRef } from "react"; @@ -73,12 +80,18 @@ export const TypeSignatureText: React.FC = ({ return ( - {t("certSign.collab.signRequest.typeSignature", "Type your name to create a signature")} + {t( + "certSign.collab.signRequest.typeSignature", + "Type your name to create a signature", + )} onTextChange(e.target.value)} disabled={disabled} @@ -94,7 +107,9 @@ export const TypeSignatureText: React.FC = ({
- {t("certSign.collab.signRequest.fontSize", "Font Size: {{size}}px", { size: fontSize })} + {t("certSign.collab.signRequest.fontSize", "Font Size: {{size}}px", { + size: fontSize, + })} = ({ )} {/* Hidden canvas for image generation */} - + ); }; diff --git a/frontend/src/core/components/shared/wetSignature/UploadSignatureImage.tsx b/frontend/src/core/components/shared/wetSignature/UploadSignatureImage.tsx index e9ed17efa..49d4c22ca 100644 --- a/frontend/src/core/components/shared/wetSignature/UploadSignatureImage.tsx +++ b/frontend/src/core/components/shared/wetSignature/UploadSignatureImage.tsx @@ -10,7 +10,11 @@ interface UploadSignatureImageProps { disabled?: boolean; } -export const UploadSignatureImage: React.FC = ({ signature, onChange, disabled = false }) => { +export const UploadSignatureImage: React.FC = ({ + signature, + onChange, + disabled = false, +}) => { const { t } = useTranslation(); const fileInputRef = useRef(null); const [error, setError] = useState(null); @@ -21,13 +25,23 @@ export const UploadSignatureImage: React.FC = ({ sign // Validate file type if (!file.type.startsWith("image/")) { - setError(t("certSign.collab.signRequest.invalidFileType", "Please select an image file")); + setError( + t( + "certSign.collab.signRequest.invalidFileType", + "Please select an image file", + ), + ); return; } // Validate file size (max 5MB) if (file.size > 5 * 1024 * 1024) { - setError(t("certSign.collab.signRequest.fileTooLarge", "File size must be less than 5MB")); + setError( + t( + "certSign.collab.signRequest.fileTooLarge", + "File size must be less than 5MB", + ), + ); return; } @@ -57,7 +71,10 @@ export const UploadSignatureImage: React.FC = ({ sign return ( - {t("certSign.collab.signRequest.uploadSignature", "Upload your signature image")} + {t( + "certSign.collab.signRequest.uploadSignature", + "Upload your signature image", + )} {signature ? ( @@ -74,7 +91,12 @@ export const UploadSignatureImage: React.FC = ({ sign minHeight: "150px", }} > - Signature + Signature
)} -
@@ -92,17 +112,25 @@ export default function ToastRenderer() { {/* Progress bar - always show when present */} {typeof t.progress === "number" && (
-
+
)} {/* Body content - only show when expanded */} - {(t.isExpanded || !t.expandable) &&
{t.body}
} + {(t.isExpanded || !t.expandable) && ( +
{t.body}
+ )} {/* Button - always show when present, positioned below body */} {t.buttonText && t.buttonCallback && (
-
diff --git a/frontend/src/core/components/toast/index.ts b/frontend/src/core/components/toast/index.ts index e93e2ed26..08fc95d2c 100644 --- a/frontend/src/core/components/toast/index.ts +++ b/frontend/src/core/components/toast/index.ts @@ -1,4 +1,8 @@ -import { ToastApi, ToastInstance, ToastOptions } from "@app/components/toast/types"; +import { + ToastApi, + ToastInstance, + ToastOptions, +} from "@app/components/toast/types"; import { useToast, ToastProvider } from "@app/components/toast/ToastContext"; import ToastRenderer from "@app/components/toast/ToastRenderer"; diff --git a/frontend/src/core/components/toast/types.ts b/frontend/src/core/components/toast/types.ts index 7306dcc02..1377cda4b 100644 --- a/frontend/src/core/components/toast/types.ts +++ b/frontend/src/core/components/toast/types.ts @@ -1,6 +1,11 @@ import { ReactNode } from "react"; -export type ToastLocation = "top-left" | "top-right" | "bottom-left" | "bottom-right" | "bottom-center"; +export type ToastLocation = + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "bottom-center"; export type ToastAlertType = "success" | "error" | "warning" | "neutral"; export interface ToastOptions { @@ -22,7 +27,10 @@ export interface ToastOptions { expandable?: boolean; } -export interface ToastInstance extends Omit { +export interface ToastInstance extends Omit< + ToastOptions, + "id" | "progressBarPercentage" +> { id: string; alertType: ToastAlertType; isPersistentPopup: boolean; diff --git a/frontend/src/core/components/tools/FullscreenToolList.tsx b/frontend/src/core/components/tools/FullscreenToolList.tsx index 417739443..ef7818ff9 100644 --- a/frontend/src/core/components/tools/FullscreenToolList.tsx +++ b/frontend/src/core/components/tools/FullscreenToolList.tsx @@ -1,7 +1,12 @@ import { useMemo } from "react"; import { Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; -import { ToolRegistryEntry, getSubcategoryLabel, getSubcategoryColor, getSubcategoryIcon } from "@app/data/toolsTaxonomy"; +import { + ToolRegistryEntry, + getSubcategoryLabel, + getSubcategoryColor, + getSubcategoryIcon, +} from "@app/data/toolsTaxonomy"; import { ToolId } from "@app/types/toolId"; import { useToolSections } from "@app/hooks/useToolSections"; import NoToolsFound from "@app/components/tools/shared/NoToolsFound"; @@ -15,7 +20,10 @@ import CompactToolItem from "@app/components/tools/fullscreen/CompactToolItem"; import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems"; interface FullscreenToolListProps { - filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; + filteredTools: Array<{ + item: [ToolId, ToolRegistryEntry]; + matchedText?: string; + }>; searchQuery: string; showDescriptions: boolean; selectedToolKey: string | null; @@ -34,22 +42,34 @@ const FullscreenToolList = ({ const { t } = useTranslation(); const { toolRegistry, favoriteTools } = useToolWorkflow(); - const { sections, searchGroups } = useToolSections(filteredTools, searchQuery); + const { sections, searchGroups } = useToolSections( + filteredTools, + searchQuery, + ); - const tooltipPortalTarget = typeof document !== "undefined" ? document.body : undefined; + const tooltipPortalTarget = + typeof document !== "undefined" ? document.body : undefined; const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry); - const quickSection = useMemo(() => sections.find((section) => section.key === "quick"), [sections]); + const quickSection = useMemo( + () => sections.find((section) => section.key === "quick"), + [sections], + ); const recommendedItems = useMemo(() => { - if (!quickSection) return [] as Array<{ id: string; tool: ToolRegistryEntry }>; + if (!quickSection) + return [] as Array<{ id: string; tool: ToolRegistryEntry }>; const items: Array<{ id: string; tool: ToolRegistryEntry }> = []; - quickSection.subcategories.forEach((sc) => sc.tools.forEach((t) => items.push(t))); + quickSection.subcategories.forEach((sc) => + sc.tools.forEach((t) => items.push(t)), + ); return items; }, [quickSection]); // Show recommended/favorites section only when not searching - const showRecentFavorites = searchQuery.trim().length === 0 && (recommendedItems.length > 0 || favoriteToolItems.length > 0); + const showRecentFavorites = + searchQuery.trim().length === 0 && + (recommendedItems.length > 0 || favoriteToolItems.length > 0); const subcategoryGroups = useMemo(() => { if (searchQuery.trim().length > 0) { @@ -64,7 +84,10 @@ const FullscreenToolList = ({
- {t("toolPanel.fullscreen.noResults", "Try adjusting your search or toggle descriptions to find what you need.")} + {t( + "toolPanel.fullscreen.noResults", + "Try adjusting your search or toggle descriptions to find what you need.", + )}
); @@ -79,7 +102,8 @@ const FullscreenToolList = ({ const isSelected = selectedToolKey === id; const handleClick = () => { - if (!tool.component && !tool.link && id !== "read" && id !== "multiTool") return; + if (!tool.component && !tool.link && id !== "read" && id !== "multiTool") + return; if (tool.link) { window.open(tool.link, "_blank", "noopener,noreferrer"); return; @@ -88,7 +112,15 @@ const FullscreenToolList = ({ }; if (showDescriptions) { - return ; + return ( + + ); } return ( @@ -129,17 +161,25 @@ const FullscreenToolList = ({ {t("toolPanel.fullscreen.favorites", "Favourites")}
- + {favoriteToolItems.length} {showDescriptions ? (
- {favoriteToolItems.map((item) => item && renderToolItem(item.id, item.tool))} + {favoriteToolItems.map( + (item) => item && renderToolItem(item.id, item.tool), + )}
) : (
- {favoriteToolItems.map((item) => item && renderToolItem(item.id, item.tool))} + {favoriteToolItems.map( + (item) => item && renderToolItem(item.id, item.tool), + )}
)} @@ -167,17 +207,25 @@ const FullscreenToolList = ({ {t("toolPanel.fullscreen.recommended", "Recommended")}
- + {recommendedItems.length} {showDescriptions ? (
- {recommendedItems.map((item: any) => renderToolItem(item.id, item.tool))} + {recommendedItems.map((item: any) => + renderToolItem(item.id, item.tool), + )}
) : (
- {recommendedItems.map((item: any) => renderToolItem(item.id, item.tool))} + {recommendedItems.map((item: any) => + renderToolItem(item.id, item.tool), + )}
)} @@ -226,11 +274,15 @@ const FullscreenToolList = ({ {showDescriptions ? (
- {tools.map(({ id, tool }) => renderToolItem(id as ToolId, tool))} + {tools.map(({ id, tool }) => + renderToolItem(id as ToolId, tool), + )}
) : (
- {tools.map(({ id, tool }) => renderToolItem(id as ToolId, tool))} + {tools.map(({ id, tool }) => + renderToolItem(id as ToolId, tool), + )}
)} diff --git a/frontend/src/core/components/tools/FullscreenToolSurface.tsx b/frontend/src/core/components/tools/FullscreenToolSurface.tsx index 708755729..d42e62895 100644 --- a/frontend/src/core/components/tools/FullscreenToolSurface.tsx +++ b/frontend/src/core/components/tools/FullscreenToolSurface.tsx @@ -1,5 +1,10 @@ import { useState, useRef } from "react"; -import { ActionIcon, ScrollArea, Switch, useMantineColorScheme } from "@mantine/core"; +import { + ActionIcon, + ScrollArea, + Switch, + useMantineColorScheme, +} from "@mantine/core"; import DoubleArrowIcon from "@mui/icons-material/DoubleArrow"; import { useTranslation } from "react-i18next"; import ToolSearch from "@app/components/tools/toolPicker/ToolSearch"; @@ -17,7 +22,10 @@ import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from "@app/styles/zIndex"; interface FullscreenToolSurfaceProps { searchQuery: string; toolRegistry: Partial>; - filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; + filteredTools: Array<{ + item: [ToolId, ToolRegistryEntry]; + matchedText?: string; + }>; selectedToolKey: string | null; showDescriptions: boolean; matchedTextMap: Map; @@ -47,7 +55,8 @@ const FullscreenToolSurface = ({ const { colorScheme } = useMantineColorScheme(); const [isExiting, setIsExiting] = useState(false); const surfaceRef = useRef(null); - const isRTL = typeof document !== "undefined" && document.documentElement.dir === "rtl"; + const isRTL = + typeof document !== "undefined" && document.documentElement.dir === "rtl"; // Enable focus trap when surface is active useFocusTrap(surfaceRef, !isExiting); @@ -58,7 +67,9 @@ const FullscreenToolSurface = ({ const brandTextSrc = colorScheme === "dark" ? wordmark.white : wordmark.black; const handleExit = () => { - const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const prefersReducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ).matches; if (prefersReducedMotion) { onExitFullscreenMode(); @@ -95,7 +106,10 @@ const FullscreenToolSurface = ({ className="tool-panel__fullscreen-surface" style={style} role="region" - aria-label={t("toolPanel.fullscreen.heading", "All tools (fullscreen view)")} + aria-label={t( + "toolPanel.fullscreen.heading", + "All tools (fullscreen view)", + )} data-tour="tool-panel" >
- - {brandAltText} + + {brandAltText}
- +
- + onToggleDescriptions()} @@ -141,7 +172,10 @@ const FullscreenToolSurface = ({
- + ; + filteredTools: Array<{ + item: [ToolId, ToolRegistryEntry]; + matchedText?: string; + }>; onSelect: (id: string) => void; searchQuery?: string; } -const SearchResults: React.FC = ({ filteredTools, onSelect, searchQuery }) => { +const SearchResults: React.FC = ({ + filteredTools, + onSelect, + searchQuery, +}) => { const { t } = useTranslation(); const { searchGroups } = useToolSections(filteredTools, searchQuery); @@ -35,15 +45,22 @@ const SearchResults: React.FC = ({ filteredTools, onSelect, {searchGroups.map((group) => ( - + {group.tools.map(({ id, tool }) => { const matchedText = matchedTextMap.get(id); // Check if the match was from synonyms and show the actual synonym that matched const isSynonymMatch = - matchedText && tool.synonyms?.some((synonym) => matchedText.toLowerCase().includes(synonym.toLowerCase())); + matchedText && + tool.synonyms?.some((synonym) => + matchedText.toLowerCase().includes(synonym.toLowerCase()), + ); const matchedSynonym = isSynonymMatch - ? tool.synonyms?.find((synonym) => matchedText.toLowerCase().includes(synonym.toLowerCase())) + ? tool.synonyms?.find((synonym) => + matchedText.toLowerCase().includes(synonym.toLowerCase()), + ) : undefined; return ( diff --git a/frontend/src/core/components/tools/ToolLoadingFallback.tsx b/frontend/src/core/components/tools/ToolLoadingFallback.tsx index 9a2a4de14..442715f34 100644 --- a/frontend/src/core/components/tools/ToolLoadingFallback.tsx +++ b/frontend/src/core/components/tools/ToolLoadingFallback.tsx @@ -1,6 +1,10 @@ import { Center, Stack, Loader, Text } from "@mantine/core"; -export default function ToolLoadingFallback({ toolName }: { toolName?: string }) { +export default function ToolLoadingFallback({ + toolName, +}: { + toolName?: string; +}) { return (
diff --git a/frontend/src/core/components/tools/ToolPanel.css b/frontend/src/core/components/tools/ToolPanel.css index d64aed369..dd5c3c15e 100644 --- a/frontend/src/core/components/tools/ToolPanel.css +++ b/frontend/src/core/components/tools/ToolPanel.css @@ -1,32 +1,124 @@ /* CSS Custom Properties for Fullscreen Mode */ .tool-panel__fullscreen-surface-inner { - --fullscreen-bg-surface-1: color-mix(in srgb, var(--bg-toolbar) 96%, transparent); - --fullscreen-bg-surface-2: color-mix(in srgb, var(--bg-background) 90%, transparent); + --fullscreen-bg-surface-1: color-mix( + in srgb, + var(--bg-toolbar) 96%, + transparent + ); + --fullscreen-bg-surface-2: color-mix( + in srgb, + var(--bg-background) 90%, + transparent + ); --fullscreen-bg-header: var(--bg-toolbar); --fullscreen-bg-controls-1: var(--bg-toolbar); - --fullscreen-bg-controls-2: color-mix(in srgb, var(--bg-toolbar) 95%, var(--bg-background)); - --fullscreen-bg-body-1: color-mix(in srgb, var(--bg-background) 86%, transparent); - --fullscreen-bg-body-2: color-mix(in srgb, var(--bg-toolbar) 78%, transparent); + --fullscreen-bg-controls-2: color-mix( + in srgb, + var(--bg-toolbar) 95%, + var(--bg-background) + ); + --fullscreen-bg-body-1: color-mix( + in srgb, + var(--bg-background) 86%, + transparent + ); + --fullscreen-bg-body-2: color-mix( + in srgb, + var(--bg-toolbar) 78%, + transparent + ); --fullscreen-bg-group: color-mix(in srgb, var(--bg-toolbar) 82%, transparent); --fullscreen-bg-item: color-mix(in srgb, var(--bg-toolbar) 88%, transparent); - --fullscreen-bg-list-item: color-mix(in srgb, var(--bg-toolbar) 86%, transparent); - --fullscreen-bg-icon-detailed: color-mix(in srgb, var(--bg-muted) 75%, transparent); - --fullscreen-bg-icon-compact: color-mix(in srgb, var(--bg-muted) 70%, transparent); - --fullscreen-border-subtle-75: color-mix(in srgb, var(--border-subtle) 75%, transparent); - --fullscreen-border-subtle-70: color-mix(in srgb, var(--border-subtle) 70%, transparent); - --fullscreen-border-subtle-65: color-mix(in srgb, var(--border-subtle) 65%, transparent); - --fullscreen-border-favorites: color-mix(in srgb, var(--special-color-favorites) 25%, var(--border-subtle)); - --fullscreen-border-recommended: color-mix(in srgb, var(--special-color-recommended) 25%, var(--border-subtle)); - --fullscreen-shadow-primary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, transparent); - --fullscreen-shadow-secondary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, transparent); - --fullscreen-shadow-group: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, transparent); - --fullscreen-accent-hover: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle)); - --fullscreen-accent-selected: color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle)); - --fullscreen-accent-ring: color-mix(in srgb, var(--text-primary) 15%, transparent); - --fullscreen-accent-list-bg: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar)); - --fullscreen-accent-list-border: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle)); - --fullscreen-text-icon: color-mix(in srgb, var(--text-primary) 90%, var(--text-muted)); - --fullscreen-text-icon-compact: color-mix(in srgb, var(--text-primary) 88%, var(--text-muted)); + --fullscreen-bg-list-item: color-mix( + in srgb, + var(--bg-toolbar) 86%, + transparent + ); + --fullscreen-bg-icon-detailed: color-mix( + in srgb, + var(--bg-muted) 75%, + transparent + ); + --fullscreen-bg-icon-compact: color-mix( + in srgb, + var(--bg-muted) 70%, + transparent + ); + --fullscreen-border-subtle-75: color-mix( + in srgb, + var(--border-subtle) 75%, + transparent + ); + --fullscreen-border-subtle-70: color-mix( + in srgb, + var(--border-subtle) 70%, + transparent + ); + --fullscreen-border-subtle-65: color-mix( + in srgb, + var(--border-subtle) 65%, + transparent + ); + --fullscreen-border-favorites: color-mix( + in srgb, + var(--special-color-favorites) 25%, + var(--border-subtle) + ); + --fullscreen-border-recommended: color-mix( + in srgb, + var(--special-color-recommended) 25%, + var(--border-subtle) + ); + --fullscreen-shadow-primary: color-mix( + in srgb, + var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, + transparent + ); + --fullscreen-shadow-secondary: color-mix( + in srgb, + var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, + transparent + ); + --fullscreen-shadow-group: color-mix( + in srgb, + var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, + transparent + ); + --fullscreen-accent-hover: color-mix( + in srgb, + var(--text-primary) 20%, + var(--border-subtle) + ); + --fullscreen-accent-selected: color-mix( + in srgb, + var(--text-primary) 30%, + var(--border-subtle) + ); + --fullscreen-accent-ring: color-mix( + in srgb, + var(--text-primary) 15%, + transparent + ); + --fullscreen-accent-list-bg: color-mix( + in srgb, + var(--text-primary) 8%, + var(--bg-toolbar) + ); + --fullscreen-accent-list-border: color-mix( + in srgb, + var(--text-primary) 20%, + var(--border-subtle) + ); + --fullscreen-text-icon: color-mix( + in srgb, + var(--text-primary) 90%, + var(--text-muted) + ); + --fullscreen-text-icon-compact: color-mix( + in srgb, + var(--text-primary) 88%, + var(--text-muted) + ); } .tool-panel { @@ -92,7 +184,12 @@ display: flex; flex-direction: column; border-radius: 0; - background: linear-gradient(140deg, var(--fullscreen-bg-surface-1), var(--fullscreen-bg-surface-2)) padding-box; + background: linear-gradient( + 140deg, + var(--fullscreen-bg-surface-1), + var(--fullscreen-bg-surface-2) + ) + padding-box; border: 1px solid var(--fullscreen-border-subtle-75); box-shadow: none; backdrop-filter: blur(18px); @@ -100,18 +197,21 @@ /* Shared animation durations for JS + CSS (sourced from theme.css) */ --fullscreen-anim-in-duration: var(--fullscreen-anim-duration-in); --fullscreen-anim-out-duration: var(--fullscreen-anim-duration-out); - animation: tool-panel-fullscreen-slide-in var(--fullscreen-anim-in-duration) ease forwards; + animation: tool-panel-fullscreen-slide-in var(--fullscreen-anim-in-duration) + ease forwards; } .tool-panel__fullscreen-surface-inner--exiting { - animation: tool-panel-fullscreen-slide-out var(--fullscreen-anim-out-duration) ease forwards; + animation: tool-panel-fullscreen-slide-out var(--fullscreen-anim-out-duration) + ease forwards; } :root[dir="rtl"] .tool-panel__fullscreen-surface-inner { animation-name: tool-panel-fullscreen-slide-in-rtl; } -:root[dir="rtl"] .tool-panel__fullscreen-surface-inner.tool-panel__fullscreen-surface-inner--exiting { +:root[dir="rtl"] + .tool-panel__fullscreen-surface-inner.tool-panel__fullscreen-surface-inner--exiting { animation-name: tool-panel-fullscreen-slide-out-rtl; } @@ -339,12 +439,15 @@ border-radius: 0.75rem; } -.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-icon { +.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) + .tool-panel__fullscreen-icon { transform: scale(1.08); - box-shadow: 0 4px 12px color-mix(in srgb, var(--text-primary) 15%, transparent); + box-shadow: 0 4px 12px + color-mix(in srgb, var(--text-primary) 15%, transparent); } -.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-icon::before { +.tool-panel__fullscreen-item:hover:not([aria-disabled="true"]) + .tool-panel__fullscreen-icon::before { opacity: 1; } @@ -425,7 +528,9 @@ cursor: not-allowed; } -.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]):not(:disabled), +.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]):not( + :disabled + ), .tool-panel__fullscreen-list-item--selected { background: var(--fullscreen-accent-list-bg); border-color: var(--fullscreen-accent-list-border); @@ -466,12 +571,14 @@ border-radius: 0.6rem; } -.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-list-icon { +.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) + .tool-panel__fullscreen-list-icon { transform: scale(1.06); box-shadow: 0 2px 8px color-mix(in srgb, var(--text-primary) 12%, transparent); } -.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) .tool-panel__fullscreen-list-icon::before { +.tool-panel__fullscreen-list-item:hover:not([aria-disabled="true"]) + .tool-panel__fullscreen-list-icon::before { opacity: 1; } diff --git a/frontend/src/core/components/tools/ToolPanel.tsx b/frontend/src/core/components/tools/ToolPanel.tsx index 37d7f2a91..8be8d84c4 100644 --- a/frontend/src/core/components/tools/ToolPanel.tsx +++ b/frontend/src/core/components/tools/ToolPanel.tsx @@ -49,8 +49,13 @@ export default function ToolPanel() { const isFullscreenMode = toolPanelMode === "fullscreen"; const toolPickerVisible = !readerMode; - const fullscreenExpanded = isFullscreenMode && leftPanelView === "toolPicker" && !isMobile && toolPickerVisible; - const isRTL = typeof document !== "undefined" && document.documentElement.dir === "rtl"; + const fullscreenExpanded = + isFullscreenMode && + leftPanelView === "toolPicker" && + !isMobile && + toolPickerVisible; + const isRTL = + typeof document !== "undefined" && document.documentElement.dir === "rtl"; // Disable right rail buttons when fullscreen mode is active useEffect(() => { @@ -129,9 +134,19 @@ export default function ToolPanel() { borderBottom: "1px solid var(--tool-panel-search-border-bottom)", }} > - + {!isMobile && leftPanelView === "toolPicker" && ( - + - + )} @@ -160,7 +178,9 @@ export default function ToolPanel() { selectedToolKey={selectedToolKey} onSelect={(id) => handleToolSelect(id as ToolId)} filteredTools={filteredTools} - isSearching={Boolean(searchQuery && searchQuery.trim().length > 0)} + isSearching={Boolean( + searchQuery && searchQuery.trim().length > 0, + )} />
) : ( @@ -168,9 +188,17 @@ export default function ToolPanel() {
{selectedToolKey ? ( - + ) : ( -
{t("toolPanel.placeholder", "Choose a tool to get started")}
+
+ {t( + "toolPanel.placeholder", + "Choose a tool to get started", + )} +
)}
@@ -189,7 +217,12 @@ export default function ToolPanel() { matchedTextMap={matchedTextMap} onSearchChange={setSearchQuery} onSelect={(id: ToolId) => handleToolSelect(id)} - onToggleDescriptions={() => updatePreference("showLegacyToolDescriptions", !preferences.showLegacyToolDescriptions)} + onToggleDescriptions={() => + updatePreference( + "showLegacyToolDescriptions", + !preferences.showLegacyToolDescriptions, + ) + } onExitFullscreenMode={() => setToolPanelMode("sidebar")} toggleLabel={toggleLabel} geometry={fullscreenGeometry} diff --git a/frontend/src/core/components/tools/ToolPanelModePrompt.css b/frontend/src/core/components/tools/ToolPanelModePrompt.css index 2561c2383..2749abe53 100644 --- a/frontend/src/core/components/tools/ToolPanelModePrompt.css +++ b/frontend/src/core/components/tools/ToolPanelModePrompt.css @@ -1,7 +1,12 @@ .tool-panel-mode-prompt__modal { background: color-mix(in srgb, var(--bg-toolbar) 94%, transparent); border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent); - box-shadow: 0 32px 64px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 20%, transparent); + box-shadow: 0 32px 64px + color-mix( + in srgb, + var(--shadow-color, rgba(15, 23, 42, 0.55)) 20%, + transparent + ); max-width: min(46rem, 100%); } @@ -25,18 +30,31 @@ } .tool-panel-mode-prompt__card--sidebar { - border: 1px solid color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 18%, var(--border-subtle)); + border: 1px solid + color-mix( + in srgb, + var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 18%, + var(--border-subtle) + ); background: linear-gradient( 165deg, color-mix(in srgb, var(--bg-surface) 96%, transparent), - color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 8%, transparent) + color-mix( + in srgb, + var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 8%, + transparent + ) ); } .tool-panel-mode-prompt__preview { border-radius: 0.9rem; border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent); - background: linear-gradient(135deg, color-mix(in srgb, var(--bg-muted) 82%, transparent), transparent 75%); + background: linear-gradient( + 135deg, + color-mix(in srgb, var(--bg-muted) 82%, transparent), + transparent 75% + ); padding: 0.75rem; width: 100%; display: flex; @@ -74,7 +92,11 @@ .tool-panel-mode-prompt__sidebar-item { height: 0.55rem; border-radius: 0.35rem; - background: color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 32%, var(--bg-muted)); + background: color-mix( + in srgb, + var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 32%, + var(--bg-muted) + ); } .tool-panel-mode-prompt__sidebar-item--muted { @@ -100,7 +122,8 @@ border-radius: 0.45rem; background: color-mix(in srgb, var(--bg-surface) 96%, transparent); border: 1px solid color-mix(in srgb, var(--border-subtle) 55%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bg-background) 60%, transparent); + box-shadow: inset 0 0 0 1px + color-mix(in srgb, var(--bg-background) 60%, transparent); } .tool-panel-mode-prompt__workspace-page--secondary { @@ -187,7 +210,12 @@ } .tool-panel-mode-prompt__action:hover { - box-shadow: 0 10px 18px color-mix(in srgb, var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 25%, transparent); + box-shadow: 0 10px 18px + color-mix( + in srgb, + var(--accent-primary, var(--mantine-color-blue-6, #228be6)) 25%, + transparent + ); } .tool-panel-mode-prompt__maybe-later { diff --git a/frontend/src/core/components/tools/ToolPanelModePrompt.tsx b/frontend/src/core/components/tools/ToolPanelModePrompt.tsx index e956be01d..3e3f072b6 100644 --- a/frontend/src/core/components/tools/ToolPanelModePrompt.tsx +++ b/frontend/src/core/components/tools/ToolPanelModePrompt.tsx @@ -18,14 +18,18 @@ interface ToolPanelModePromptProps { * The orchestrator controls this via forceOpen prop. When shown standalone (legacy), * it uses internal state based on preferences. */ -const ToolPanelModePrompt = ({ onComplete, forceOpen }: ToolPanelModePromptProps = {}) => { +const ToolPanelModePrompt = ({ + onComplete, + forceOpen, +}: ToolPanelModePromptProps = {}) => { const { t } = useTranslation(); const { toolPanelMode, setToolPanelMode } = useToolWorkflow(); const { preferences, updatePreference } = usePreferences(); const [internalOpened, setInternalOpened] = useState(false); // Only show after the intro onboarding has been completed (legacy standalone mode) - const shouldShowPrompt = !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding; + const shouldShowPrompt = + !preferences.toolPanelModePromptSeen && preferences.hasSeenIntroOnboarding; useEffect(() => { if (shouldShowPrompt && forceOpen === undefined) { @@ -87,16 +91,24 @@ const ToolPanelModePrompt = ({ onComplete, forceOpen }: ToolPanelModePromptProps - {t("toolPanel.modePrompt.sidebarTitle", "Sidebar mode")} + + {t("toolPanel.modePrompt.sidebarTitle", "Sidebar mode")} + - {t("toolPanel.modePrompt.sidebarDescription", "Keep tools alongside your workspace for quick switching.")} + {t( + "toolPanel.modePrompt.sidebarDescription", + "Keep tools alongside your workspace for quick switching.", + )} {t("toolPanel.modePrompt.recommended", "Recommended")} -
+
@@ -120,10 +132,18 @@ const ToolPanelModePrompt = ({ onComplete, forceOpen }: ToolPanelModePromptProps - + - {t("toolPanel.modePrompt.fullscreenTitle", "Fullscreen mode")} + + {t("toolPanel.modePrompt.fullscreenTitle", "Fullscreen mode")} + {t( "toolPanel.modePrompt.fullscreenDescription", @@ -131,7 +151,10 @@ const ToolPanelModePrompt = ({ onComplete, forceOpen }: ToolPanelModePromptProps )} -
+
@@ -157,7 +180,10 @@ const ToolPanelModePrompt = ({ onComplete, forceOpen }: ToolPanelModePromptProps className="tool-panel-mode-prompt__action" onClick={() => handleSelect("fullscreen")} > - {t("toolPanel.modePrompt.chooseFullscreen", "Use fullscreen mode")} + {t( + "toolPanel.modePrompt.chooseFullscreen", + "Use fullscreen mode", + )} diff --git a/frontend/src/core/components/tools/ToolPicker.tsx b/frontend/src/core/components/tools/ToolPicker.tsx index 59c7819f4..02ba7625a 100644 --- a/frontend/src/core/components/tools/ToolPicker.tsx +++ b/frontend/src/core/components/tools/ToolPicker.tsx @@ -17,11 +17,19 @@ import { ToolPickerFooterExtensions } from "@app/components/tools/toolPicker/Too interface ToolPickerProps { selectedToolKey: string | null; onSelect: (id: string) => void; - filteredTools: Array<{ item: [ToolId, ToolRegistryEntry]; matchedText?: string }>; + filteredTools: Array<{ + item: [ToolId, ToolRegistryEntry]; + matchedText?: string; + }>; isSearching?: boolean; } -const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => { +const ToolPicker = ({ + selectedToolKey, + onSelect, + filteredTools, + isSearching = false, +}: ToolPickerProps) => { const { t } = useTranslation(); const scrollableRef = useRef(null); @@ -31,20 +39,30 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry); - const quickSection = useMemo(() => visibleSections.find((s) => s.key === "quick"), [visibleSections]); + const quickSection = useMemo( + () => visibleSections.find((s) => s.key === "quick"), + [visibleSections], + ); const recommendedItems = useMemo(() => { - if (!quickSection) return [] as Array<{ id: string; tool: ToolRegistryEntry }>; + if (!quickSection) + return [] as Array<{ id: string; tool: ToolRegistryEntry }>; const items: Array<{ id: string; tool: ToolRegistryEntry }> = []; - quickSection.subcategories.forEach((sc: SubcategoryGroup) => sc.tools.forEach((toolEntry) => items.push(toolEntry))); + quickSection.subcategories.forEach((sc: SubcategoryGroup) => + sc.tools.forEach((toolEntry) => items.push(toolEntry)), + ); return items; }, [quickSection]); - const allSection = useMemo(() => visibleSections.find((s) => s.key === "all"), [visibleSections]); + const allSection = useMemo( + () => visibleSections.find((s) => s.key === "all"), + [visibleSections], + ); // Build flat list by subcategory for search mode const emptyFilteredTools: ToolPickerProps["filteredTools"] = []; - const effectiveFilteredForSearch: ToolPickerProps["filteredTools"] = isSearching ? filteredTools : emptyFilteredTools; + const effectiveFilteredForSearch: ToolPickerProps["filteredTools"] = + isSearching ? filteredTools : emptyFilteredTools; const { searchGroups } = useToolSections(effectiveFilteredForSearch); const headerTextStyle: React.CSSProperties = { fontSize: "0.75rem", @@ -54,7 +72,11 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa color: "var(--text-secondary, rgba(0, 0, 0, 0.6))", opacity: 0.7, }; - const toTitleCase = (s: string) => s.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()); + const toTitleCase = (s: string) => + s.replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase(), + ); return ( ) : ( searchGroups.map((group) => - renderToolButtons(t, group, selectedToolKey, onSelect, true, false, filteredTools, true), + renderToolButtons( + t, + group, + selectedToolKey, + onSelect, + true, + false, + filteredTools, + true, + ), ) )} @@ -93,7 +124,9 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa {favoriteToolItems.length > 0 && ( -
{t("toolPanel.fullscreen.favorites", "Favourites")}
+
+ {t("toolPanel.fullscreen.favorites", "Favourites")} +
{favoriteToolItems.map(({ id, tool }) => ( 0 && ( -
{t("toolPanel.fullscreen.recommended", "Recommended")}
+
+ {t("toolPanel.fullscreen.recommended", "Recommended")} +
{recommendedItems.map(({ id, tool }) => ( ( -
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
- {renderToolButtons(t, sc, selectedToolKey, onSelect, false, false, undefined, true)} +
+ {toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))} +
+ {renderToolButtons( + t, + sc, + selectedToolKey, + onSelect, + false, + false, + undefined, + true, + )}
))} diff --git a/frontend/src/core/components/tools/ToolRenderer.tsx b/frontend/src/core/components/tools/ToolRenderer.tsx index 229ea6b0a..0231252f7 100644 --- a/frontend/src/core/components/tools/ToolRenderer.tsx +++ b/frontend/src/core/components/tools/ToolRenderer.tsx @@ -8,10 +8,18 @@ interface ToolRendererProps extends BaseToolProps { selectedToolKey: ToolId; } -const ToolRenderer = ({ selectedToolKey, onPreviewFile, onComplete, onError }: ToolRendererProps) => { +const ToolRenderer = ({ + selectedToolKey, + onPreviewFile, + onComplete, + onError, +}: ToolRendererProps) => { // Get the tool from context (instead of direct hook call) const { toolRegistry } = useToolWorkflow(); - const selectedTool = selectedToolKey in toolRegistry ? toolRegistry[selectedToolKey as ToolId] : undefined; + const selectedTool = + selectedToolKey in toolRegistry + ? toolRegistry[selectedToolKey as ToolId] + : undefined; // Handle tools that only work in workbenches (read, multiTool) if (selectedTool && !selectedTool.component && selectedTool.workbench) { @@ -27,7 +35,11 @@ const ToolRenderer = ({ selectedToolKey, onPreviewFile, onComplete, onError }: T // Wrap lazy-loaded component with Suspense return ( }> - + ); }; diff --git a/frontend/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx b/frontend/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx index 45611c2a6..9df438b42 100644 --- a/frontend/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx +++ b/frontend/src/core/components/tools/addAttachments/AddAttachmentsSettings.tsx @@ -4,7 +4,15 @@ * Allows selecting files to attach to PDFs with optional PDF/A-3b conversion support. */ -import { Stack, Text, Group, ActionIcon, ScrollArea, Button, Checkbox } from "@mantine/core"; +import { + Stack, + Text, + Group, + ActionIcon, + ScrollArea, + Button, + Checkbox, +} from "@mantine/core"; import { useTranslation } from "react-i18next"; import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters"; import LocalIcon from "@app/components/shared/LocalIcon"; @@ -12,11 +20,18 @@ import { Tooltip } from "@app/components/shared/Tooltip"; interface AddAttachmentsSettingsProps { parameters: AddAttachmentsParameters; - onParameterChange: (key: K, value: AddAttachmentsParameters[K]) => void; + onParameterChange: ( + key: K, + value: AddAttachmentsParameters[K], + ) => void; disabled?: boolean; } -const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = false }: AddAttachmentsSettingsProps) => { +const AddAttachmentsSettings = ({ + parameters, + onParameterChange, + disabled = false, +}: AddAttachmentsSettingsProps) => { const { t } = useTranslation(); return ( @@ -28,7 +43,10 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals onChange={(e) => { const files = Array.from(e.target.files || []); // Append to existing attachments instead of replacing - const newAttachments = [...(parameters.attachments || []), ...files]; + const newAttachments = [ + ...(parameters.attachments || []), + ...files, + ]; onParameterChange("attachments", newAttachments); // Reset the input so the same file can be selected again e.target.value = ""; @@ -54,9 +72,15 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals {parameters.attachments?.length > 0 && ( - {t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ({parameters.attachments.length}) + {t("AddAttachmentsRequest.selectedFiles", "Selected Files")} ( + {parameters.attachments.length}) - + {parameters.attachments.map((file, index) => ( - + {/* Filename (two-line clamp, wraps, no icon on the left) */}
{ - const newAttachments = (parameters.attachments || []).filter((_, i) => i !== index); + const newAttachments = ( + parameters.attachments || [] + ).filter((_, i) => i !== index); onParameterChange("attachments", newAttachments); }} disabled={disabled} @@ -118,14 +147,22 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals - {t("attachments.convertToPdfA3b", "Convert to PDF/A-3b")} + + {t("attachments.convertToPdfA3b", "Convert to PDF/A-3b")} + } - description={t("attachments.convertToPdfA3bDescription", "Creates an archival PDF with embedded attachments")} + description={t( + "attachments.convertToPdfA3bDescription", + "Creates an archival PDF with embedded attachments", + )} checked={parameters.convertToPdfA3b} - onChange={(event) => onParameterChange("convertToPdfA3b", event.currentTarget.checked)} + onChange={(event) => + onParameterChange("convertToPdfA3b", event.currentTarget.checked) + } disabled={disabled} styles={{ root: { flex: 1 } }} /> diff --git a/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx b/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx index 946ea4e89..79931d129 100644 --- a/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx +++ b/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersAppearanceSettings.tsx @@ -10,7 +10,10 @@ import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; interface AddPageNumbersAppearanceSettingsProps { parameters: AddPageNumbersParameters; - onParameterChange: (key: K, value: AddPageNumbersParameters[K]) => void; + onParameterChange: ( + key: K, + value: AddPageNumbersParameters[K], + ) => void; disabled?: boolean; } @@ -23,11 +26,18 @@ const AddPageNumbersAppearanceSettings = ({ return ( - + @@ -88,8 +118,13 @@ const AddPageNumbersAppearanceSettings = ({ onParameterChange("customText", e.currentTarget.value)} - placeholder={t("addPageNumbers.customNumberDesc", 'e.g., "Page {n}" or leave blank for just numbers')} + onChange={(e) => + onParameterChange("customText", e.currentTarget.value) + } + placeholder={t( + "addPageNumbers.customNumberDesc", + 'e.g., "Page {n}" or leave blank for just numbers', + )} disabled={disabled} /> diff --git a/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersAutomationSettings.tsx b/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersAutomationSettings.tsx index 6cf36954e..06c8b94a2 100644 --- a/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersAutomationSettings.tsx +++ b/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersAutomationSettings.tsx @@ -12,7 +12,10 @@ import AddPageNumbersAppearanceSettings from "@app/components/tools/addPageNumbe interface AddPageNumbersAutomationSettingsProps { parameters: AddPageNumbersParameters; - onParameterChange: (key: K, value: AddPageNumbersParameters[K]) => void; + onParameterChange: ( + key: K, + value: AddPageNumbersParameters[K], + ) => void; disabled?: boolean; } @@ -46,7 +49,11 @@ const AddPageNumbersAutomationSettings = ({ {t("addPageNumbers.customize", "Customize Appearance")} - + ); diff --git a/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx b/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx index a04be013d..735eda3e1 100644 --- a/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx +++ b/frontend/src/core/components/tools/addPageNumbers/AddPageNumbersPositionSettings.tsx @@ -10,7 +10,10 @@ import PageNumberPreview from "@app/components/tools/addPageNumbers/PageNumberPr interface AddPageNumbersPositionSettingsProps { parameters: AddPageNumbersParameters; - onParameterChange: (key: K, value: AddPageNumbersParameters[K]) => void; + onParameterChange: ( + key: K, + value: AddPageNumbersParameters[K], + ) => void; disabled?: boolean; file?: File | null; showQuickGrid?: boolean; @@ -54,8 +57,13 @@ const AddPageNumbersPositionSettings = ({ onParameterChange("pagesToNumber", e.currentTarget.value)} - placeholder={t("addPageNumbers.numberPagesDesc", "e.g., 1,3,5-8 or leave blank for all pages")} + onChange={(e) => + onParameterChange("pagesToNumber", e.currentTarget.value) + } + placeholder={t( + "addPageNumbers.numberPagesDesc", + "e.g., 1,3,5-8 or leave blank for all pages", + )} disabled={disabled} /> @@ -69,7 +77,9 @@ const AddPageNumbersPositionSettings = ({ onParameterChange("startingNumber", typeof v === "number" ? v : 1)} + onChange={(v) => + onParameterChange("startingNumber", typeof v === "number" ? v : 1) + } min={1} disabled={disabled} /> diff --git a/frontend/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx b/frontend/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx index 1741f45bf..3b5b42cb2 100644 --- a/frontend/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx +++ b/frontend/src/core/components/tools/addPageNumbers/PageNumberPreview.tsx @@ -26,7 +26,9 @@ const getFirstSelectedPage = (input: string): number => { return 1; }; -const detectOverallBackgroundColor = async (thumbnailSrc: string | null): Promise<"light" | "dark"> => { +const detectOverallBackgroundColor = async ( + thumbnailSrc: string | null, +): Promise<"light" | "dark"> => { if (!thumbnailSrc) { return "light"; // Default to light background if no thumbnail } @@ -59,7 +61,10 @@ const detectOverallBackgroundColor = async (thumbnailSrc: string | null): Promis let pixelCount = 0; // Sample every nth pixel for performance - const step = Math.max(1, Math.floor((img.width * img.height) / (sampleWidth * sampleHeight))); + const step = Math.max( + 1, + Math.floor((img.width * img.height) / (sampleWidth * sampleHeight)), + ); for (let i = 0; i < data.length; i += 4 * step) { const r = data[i]; @@ -89,16 +94,30 @@ const detectOverallBackgroundColor = async (thumbnailSrc: string | null): Promis type Props = { parameters: AddPageNumbersParameters; - onParameterChange: (key: K, value: AddPageNumbersParameters[K]) => void; + onParameterChange: ( + key: K, + value: AddPageNumbersParameters[K], + ) => void; file?: File | null; showQuickGrid?: boolean; }; -export default function PageNumberPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) { +export default function PageNumberPreview({ + parameters, + onParameterChange, + file, + showQuickGrid, +}: Props) { const { t } = useTranslation(); const containerRef = useRef(null); - const [, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); - const [pageSize, setPageSize] = useState<{ widthPts: number; heightPts: number } | null>(null); + const [, setContainerSize] = useState<{ width: number; height: number }>({ + width: 0, + height: 0, + }); + const [pageSize, setPageSize] = useState<{ + widthPts: number; + heightPts: number; + } | null>(null); const [pageThumbnail, setPageThumbnail] = useState(null); const { requestThumbnail } = useThumbnailGeneration(); const [hoverTile, setHoverTile] = useState(null); @@ -109,8 +128,13 @@ export default function PageNumberPreview({ parameters, onParameterChange, file, const node = containerRef.current; if (!node) return; const resize = () => { - const aspect = pageSize ? pageSize.widthPts / pageSize.heightPts : A4_ASPECT_RATIO; - setContainerSize({ width: node.clientWidth, height: node.clientWidth / aspect }); + const aspect = pageSize + ? pageSize.widthPts / pageSize.heightPts + : A4_ASPECT_RATIO; + setContainerSize({ + width: node.clientWidth, + height: node.clientWidth / aspect, + }); }; resize(); const ro = new ResizeObserver(resize); @@ -128,7 +152,10 @@ export default function PageNumberPreview({ parameters, onParameterChange, file, } try { const buffer = await file.arrayBuffer(); - const pdf = await pdfWorkerManager.createDocument(buffer, { disableAutoFetch: true, disableStream: true }); + const pdf = await pdfWorkerManager.createDocument(buffer, { + disableAutoFetch: true, + disableStream: true, + }); const page = await pdf.getPage(1); const viewport = page.getViewport({ scale: 1 }); if (!cancelled) { @@ -154,7 +181,10 @@ export default function PageNumberPreview({ parameters, onParameterChange, file, return; } try { - const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pagesToNumber || "1")); + const pageNumber = Math.max( + 1, + getFirstSelectedPage(parameters.pagesToNumber || "1"), + ); const pageId = `${file.name}:${file.size}:${file.lastModified}:page:${pageNumber}`; const thumb = await requestThumbnail(pageId, file, pageNumber); if (isActive) setPageThumbnail(thumb || null); @@ -199,7 +229,9 @@ export default function PageNumberPreview({ parameters, onParameterChange, file,
-
{t("addPageNumbers.preview", "Preview Page Numbers")}
+
+ {t("addPageNumbers.preview", "Preview Page Numbers")} +
{pageThumbnail && ( - page preview + page preview )} @@ -229,7 +266,9 @@ export default function PageNumberPreview({ parameters, onParameterChange, file, style={{ color: textColor, textShadow: - textColor === "#fff" ? "1px 1px 2px rgba(0, 0, 0, 0.8)" : "1px 1px 2px rgba(255, 255, 255, 0.8)", + textColor === "#fff" + ? "1px 1px 2px rgba(0, 0, 0, 0.8)" + : "1px 1px 2px rgba(255, 255, 255, 0.8)", }} > {idx} @@ -240,7 +279,10 @@ export default function PageNumberPreview({ parameters, onParameterChange, file, )}
- {t("addPageNumbers.previewDisclaimer", "Preview is approximate. Final output may vary due to PDF font metrics.")} + {t( + "addPageNumbers.previewDisclaimer", + "Preview is approximate. Final output may vary due to PDF font metrics.", + )}
); diff --git a/frontend/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts b/frontend/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts index 42b38f6d0..f9bf73f4c 100644 --- a/frontend/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts +++ b/frontend/src/core/components/tools/addPageNumbers/useAddPageNumbersOperation.ts @@ -1,9 +1,18 @@ import { useTranslation } from "react-i18next"; -import { ToolType, useToolOperation } from "@app/hooks/tools/shared/useToolOperation"; +import { + ToolType, + useToolOperation, +} from "@app/hooks/tools/shared/useToolOperation"; import { createStandardErrorHandler } from "@app/utils/toolErrorHandler"; -import { AddPageNumbersParameters, defaultParameters } from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters"; +import { + AddPageNumbersParameters, + defaultParameters, +} from "@app/components/tools/addPageNumbers/useAddPageNumbersParameters"; -export const buildAddPageNumbersFormData = (parameters: AddPageNumbersParameters, file: File): FormData => { +export const buildAddPageNumbersFormData = ( + parameters: AddPageNumbersParameters, + file: File, +): FormData => { const formData = new FormData(); formData.append("fileInput", file); formData.append("customMargin", parameters.customMargin); @@ -32,7 +41,10 @@ export const useAddPageNumbersOperation = () => { return useToolOperation({ ...addPageNumbersOperationConfig, getErrorMessage: createStandardErrorHandler( - t("addPageNumbers.error.failed", "An error occurred while adding page numbers to the PDF."), + t( + "addPageNumbers.error.failed", + "An error occurred while adding page numbers to the PDF.", + ), ), }); }; diff --git a/frontend/src/core/components/tools/addPageNumbers/useAddPageNumbersParameters.ts b/frontend/src/core/components/tools/addPageNumbers/useAddPageNumbersParameters.ts index f83e8b0f0..43f2d18ec 100644 --- a/frontend/src/core/components/tools/addPageNumbers/useAddPageNumbersParameters.ts +++ b/frontend/src/core/components/tools/addPageNumbers/useAddPageNumbersParameters.ts @@ -1,5 +1,8 @@ import { BaseParameters } from "@app/types/parameters"; -import { useBaseParameters, type BaseParametersHook } from "@app/hooks/tools/shared/useBaseParameters"; +import { + useBaseParameters, + type BaseParametersHook, +} from "@app/hooks/tools/shared/useBaseParameters"; export interface AddPageNumbersParameters extends BaseParameters { customMargin: "small" | "medium" | "large" | "x-large"; @@ -24,7 +27,8 @@ export const defaultParameters: AddPageNumbersParameters = { zeroPad: 0, }; -export type AddPageNumbersParametersHook = BaseParametersHook; +export type AddPageNumbersParametersHook = + BaseParametersHook; export const useAddPageNumbersParameters = (): AddPageNumbersParametersHook => { return useBaseParameters({ diff --git a/frontend/src/core/components/tools/addPassword/AddPasswordSettings.test.tsx b/frontend/src/core/components/tools/addPassword/AddPasswordSettings.test.tsx index 2c03dba39..626092c70 100644 --- a/frontend/src/core/components/tools/addPassword/AddPasswordSettings.test.tsx +++ b/frontend/src/core/components/tools/addPassword/AddPasswordSettings.test.tsx @@ -11,7 +11,9 @@ vi.mock("react-i18next", () => ({ })); // Wrapper component to provide Mantine context -const TestWrapper = ({ children }: { children: React.ReactNode }) => {children}; +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); describe("AddPasswordSettings", () => { const mockOnParameterChange = vi.fn(); @@ -23,61 +25,92 @@ describe("AddPasswordSettings", () => { test("should render password input fields", () => { render( - + , ); // Should render user and owner password fields labels - expect(screen.getByText("mock-addPassword.passwords.user.label")).toBeInTheDocument(); - expect(screen.getByText("mock-addPassword.passwords.owner.label")).toBeInTheDocument(); + expect( + screen.getByText("mock-addPassword.passwords.user.label"), + ).toBeInTheDocument(); + expect( + screen.getByText("mock-addPassword.passwords.owner.label"), + ).toBeInTheDocument(); }); test("should render encryption key length select", () => { render( - + , ); // Should render key length select input - expect(screen.getByRole("textbox", { name: /keyLength/i })).toBeInTheDocument(); + expect( + screen.getByRole("textbox", { name: /keyLength/i }), + ).toBeInTheDocument(); }); test("should render main component sections", () => { render( - + , ); // Check that main section titles are rendered - expect(screen.getByText("mock-addPassword.passwords.user.label")).toBeInTheDocument(); - expect(screen.getByText("mock-addPassword.encryption.keyLength.label")).toBeInTheDocument(); + expect( + screen.getByText("mock-addPassword.passwords.user.label"), + ).toBeInTheDocument(); + expect( + screen.getByText("mock-addPassword.encryption.keyLength.label"), + ).toBeInTheDocument(); }); test("should call onParameterChange when password fields are modified", () => { render( - + , ); // This test is complex with Mantine's PasswordInput, just verify the component renders - expect(screen.getByText("mock-addPassword.passwords.user.label")).toBeInTheDocument(); + expect( + screen.getByText("mock-addPassword.passwords.user.label"), + ).toBeInTheDocument(); }); test("should call onParameterChange when key length is changed", () => { render( - + , ); // Find key length select and change it - const keyLengthSelect = screen.getByText("mock-addPassword.encryption.keyLength.128bit"); + const keyLengthSelect = screen.getByText( + "mock-addPassword.encryption.keyLength.128bit", + ); fireEvent.mouseDown(keyLengthSelect); - const option256 = screen.getByText("mock-addPassword.encryption.keyLength.256bit"); + const option256 = screen.getByText( + "mock-addPassword.encryption.keyLength.256bit", + ); fireEvent.click(option256); expect(mockOnParameterChange).toHaveBeenCalledWith("keyLength", 256); @@ -86,7 +119,11 @@ describe("AddPasswordSettings", () => { test("should disable all form elements when disabled prop is true", () => { render( - + , ); @@ -97,13 +134,19 @@ describe("AddPasswordSettings", () => { }); // Check key length select is disabled - simplified test due to Mantine complexity - expect(screen.getByText("mock-addPassword.encryption.keyLength.128bit")).toBeInTheDocument(); + expect( + screen.getByText("mock-addPassword.encryption.keyLength.128bit"), + ).toBeInTheDocument(); }); test("should enable all form elements when disabled prop is false", () => { render( - + , ); @@ -114,32 +157,58 @@ describe("AddPasswordSettings", () => { }); // Check key length select is enabled - simplified test due to Mantine complexity - expect(screen.getByText("mock-addPassword.encryption.keyLength.128bit")).toBeInTheDocument(); + expect( + screen.getByText("mock-addPassword.encryption.keyLength.128bit"), + ).toBeInTheDocument(); }); test("should call translation function with correct keys", () => { render( - + , ); // Verify that translation keys are being called - expect(mockT).toHaveBeenCalledWith("addPassword.passwords.user.label", "User Password"); - expect(mockT).toHaveBeenCalledWith("addPassword.passwords.owner.label", "Owner Password"); + expect(mockT).toHaveBeenCalledWith( + "addPassword.passwords.user.label", + "User Password", + ); + expect(mockT).toHaveBeenCalledWith( + "addPassword.passwords.owner.label", + "Owner Password", + ); }); test.each([ - { keyLength: 40, expectedLabel: "mock-addPassword.encryption.keyLength.40bit" }, - { keyLength: 128, expectedLabel: "mock-addPassword.encryption.keyLength.128bit" }, - { keyLength: 256, expectedLabel: "mock-addPassword.encryption.keyLength.256bit" }, - ])("should handle key length $keyLength correctly", ({ keyLength, expectedLabel }) => { - render( - - - , - ); + { + keyLength: 40, + expectedLabel: "mock-addPassword.encryption.keyLength.40bit", + }, + { + keyLength: 128, + expectedLabel: "mock-addPassword.encryption.keyLength.128bit", + }, + { + keyLength: 256, + expectedLabel: "mock-addPassword.encryption.keyLength.256bit", + }, + ])( + "should handle key length $keyLength correctly", + ({ keyLength, expectedLabel }) => { + render( + + + , + ); - expect(screen.getByText(expectedLabel)).toBeInTheDocument(); - }); + expect(screen.getByText(expectedLabel)).toBeInTheDocument(); + }, + ); }); diff --git a/frontend/src/core/components/tools/addPassword/AddPasswordSettings.tsx b/frontend/src/core/components/tools/addPassword/AddPasswordSettings.tsx index 7eed7dcc8..9c8010d41 100644 --- a/frontend/src/core/components/tools/addPassword/AddPasswordSettings.tsx +++ b/frontend/src/core/components/tools/addPassword/AddPasswordSettings.tsx @@ -5,11 +5,18 @@ import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex"; interface AddPasswordSettingsProps { parameters: AddPasswordParameters; - onParameterChange: (key: K, value: AddPasswordParameters[K]) => void; + onParameterChange: ( + key: K, + value: AddPasswordParameters[K], + ) => void; disabled?: boolean; } -const AddPasswordSettings = ({ parameters, onParameterChange, disabled = false }: AddPasswordSettingsProps) => { +const AddPasswordSettings = ({ + parameters, + onParameterChange, + disabled = false, +}: AddPasswordSettingsProps) => { const { t } = useTranslation(); return ( @@ -18,14 +25,20 @@ const AddPasswordSettings = ({ parameters, onParameterChange, disabled = false } onParameterChange("password", e.target.value)} disabled={disabled} /> onParameterChange("ownerPassword", e.target.value)} disabled={disabled} @@ -35,7 +48,10 @@ const AddPasswordSettings = ({ parameters, onParameterChange, disabled = false } {/* Encryption Settings */} onParameterChange("customMargin", (v as any) || "medium")} + onChange={(v) => + onParameterChange("customMargin", (v as any) || "medium") + } data={[ { value: "small", label: t("margin.small", "Small") }, { value: "medium", label: t("margin.medium", "Medium") }, @@ -210,7 +260,10 @@ const StampPositionFormattingSettings = ({ { value: "x-large", label: t("margin.xLarge", "Extra Large") }, ]} disabled={disabled} - comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_AUTOMATE_DROPDOWN }} + comboboxProps={{ + withinPortal: true, + zIndex: Z_INDEX_AUTOMATE_DROPDOWN, + }} /> )} diff --git a/frontend/src/core/components/tools/addStamp/StampPreview.tsx b/frontend/src/core/components/tools/addStamp/StampPreview.tsx index 7fd5053aa..9659c4317 100644 --- a/frontend/src/core/components/tools/addStamp/StampPreview.tsx +++ b/frontend/src/core/components/tools/addStamp/StampPreview.tsx @@ -14,16 +14,34 @@ import { PrivateContent } from "@app/components/shared/PrivateContent"; type Props = { parameters: AddStampParameters; - onParameterChange: (key: K, value: AddStampParameters[K]) => void; + onParameterChange: ( + key: K, + value: AddStampParameters[K], + ) => void; file?: File | null; showQuickGrid?: boolean; }; -export default function StampPreview({ parameters, onParameterChange, file, showQuickGrid }: Props) { +export default function StampPreview({ + parameters, + onParameterChange, + file, + showQuickGrid, +}: Props) { const containerRef = useRef(null); - const [containerSize, setContainerSize] = useState<{ width: number; height: number }>({ width: 0, height: 0 }); - const [imageMeta, setImageMeta] = useState<{ url: string; width: number; height: number } | null>(null); - const [pageSize, setPageSize] = useState<{ widthPts: number; heightPts: number } | null>(null); + const [containerSize, setContainerSize] = useState<{ + width: number; + height: number; + }>({ width: 0, height: 0 }); + const [imageMeta, setImageMeta] = useState<{ + url: string; + width: number; + height: number; + } | null>(null); + const [pageSize, setPageSize] = useState<{ + widthPts: number; + heightPts: number; + } | null>(null); const [pageThumbnail, setPageThumbnail] = useState(null); const { requestThumbnail } = useThumbnailGeneration(); const [hoverTile, setHoverTile] = useState(null); @@ -48,8 +66,13 @@ export default function StampPreview({ parameters, onParameterChange, file, show const node = containerRef.current; if (!node) return; const resize = () => { - const aspect = pageSize ? pageSize.widthPts / pageSize.heightPts : A4_ASPECT_RATIO; - setContainerSize({ width: node.clientWidth, height: node.clientWidth / aspect }); + const aspect = pageSize + ? pageSize.widthPts / pageSize.heightPts + : A4_ASPECT_RATIO; + setContainerSize({ + width: node.clientWidth, + height: node.clientWidth / aspect, + }); }; resize(); const ro = new ResizeObserver(resize); @@ -67,7 +90,10 @@ export default function StampPreview({ parameters, onParameterChange, file, show } try { const buffer = await file.arrayBuffer(); - const pdf = await pdfWorkerManager.createDocument(buffer, { disableAutoFetch: true, disableStream: true }); + const pdf = await pdfWorkerManager.createDocument(buffer, { + disableAutoFetch: true, + disableStream: true, + }); const page = await pdf.getPage(1); // Unrotated viewport keeps points to pixels aligned and avoids width and height swaps const viewport = page.getViewport({ scale: 1, rotation: 0 }); @@ -95,7 +121,10 @@ export default function StampPreview({ parameters, onParameterChange, file, show return; } try { - const pageNumber = Math.max(1, getFirstSelectedPage(parameters.pageNumbers)); + const pageNumber = Math.max( + 1, + getFirstSelectedPage(parameters.pageNumbers), + ); const pageId = `${file.name}:${file.size}:${file.lastModified}:page:${pageNumber}`; const thumb = await requestThumbnail(pageId, file, pageNumber); if (isActive) setPageThumbnail(thumb || null); @@ -110,17 +139,39 @@ export default function StampPreview({ parameters, onParameterChange, file, show }, [file, parameters.pageNumbers, requestThumbnail]); const style = useMemo( - () => computeStampPreviewStyle(parameters, imageMeta, pageSize, containerSize, showQuickGrid, hoverTile, !!pageThumbnail), - [containerSize, parameters, imageMeta, pageSize, showQuickGrid, hoverTile, pageThumbnail], + () => + computeStampPreviewStyle( + parameters, + imageMeta, + pageSize, + containerSize, + showQuickGrid, + hoverTile, + !!pageThumbnail, + ), + [ + containerSize, + parameters, + imageMeta, + pageSize, + showQuickGrid, + hoverTile, + pageThumbnail, + ], ); // Keep center fixed when scaling via slider (or any fontSize changes) - const prevDimsRef = useRef<{ fontSize: number; widthPx: number; heightPx: number; leftPx: number; bottomPx: number } | null>( - null, - ); + const prevDimsRef = useRef<{ + fontSize: number; + widthPx: number; + heightPx: number; + leftPx: number; + bottomPx: number; + } | null>(null); useEffect(() => { const itemStyle = style.item as any; - if (!itemStyle || containerSize.width <= 0 || containerSize.height <= 0) return; + if (!itemStyle || containerSize.width <= 0 || containerSize.height <= 0) + return; const parse = (v: any) => parseFloat(String(v).replace("px", "")) || 0; const leftPx = parse(itemStyle.left); @@ -152,12 +203,19 @@ export default function StampPreview({ parameters, onParameterChange, file, show const maxLeftPx = Math.max(0, containerSize.width - widthPx); const maxBottomPx = Math.max(0, containerSize.height - heightPx); const newLeftPts = Math.max(0, Math.min(maxLeftPx, newLeftPx)) / scaleX; - const newBottomPts = Math.max(0, Math.min(maxBottomPx, newBottomPx)) / scaleY; + const newBottomPts = + Math.max(0, Math.min(maxBottomPx, newBottomPx)) / scaleY; onParameterChange("overrideX", newLeftPts as any); onParameterChange("overrideY", newBottomPts as any); } - prevDimsRef.current = { fontSize: parameters.fontSize, widthPx, heightPx, leftPx, bottomPx }; + prevDimsRef.current = { + fontSize: parameters.fontSize, + widthPx, + heightPx, + leftPx, + bottomPx, + }; }, [ parameters.fontSize, style.item, @@ -190,9 +248,11 @@ export default function StampPreview({ parameters, onParameterChange, file, show // Recompute current x,y from style (so that we start from visual position) const itemStyle = style.item as any; const leftPx = parseFloat(String(itemStyle.left).replace("px", "")) || 0; - const bottomPx = parseFloat(String(itemStyle.bottom).replace("px", "")) || 0; + const bottomPx = + parseFloat(String(itemStyle.bottom).replace("px", "")) || 0; const widthPx = parseFloat(String(itemStyle.width).replace("px", "")) || 0; - const heightPx = parseFloat(String(itemStyle.height).replace("px", "")) || 0; + const heightPx = + parseFloat(String(itemStyle.height).replace("px", "")) || 0; const widthPts = pageSize?.widthPts ?? 595.28; const heightPts = pageSize?.heightPts ?? 841.89; const scaleX = containerSize.width / widthPts; @@ -200,22 +260,35 @@ export default function StampPreview({ parameters, onParameterChange, file, show if (parameters.overrideX < 0 || parameters.overrideY < 0) { const maxLeftPx = Math.max(0, pageWidth - widthPx); const maxBottomPx = Math.max(0, pageHeight - heightPx); - onParameterChange("overrideX", (Math.max(0, Math.min(maxLeftPx, leftPx)) / scaleX) as any); - onParameterChange("overrideY", (Math.max(0, Math.min(maxBottomPx, bottomPx)) / scaleY) as any); + onParameterChange( + "overrideX", + (Math.max(0, Math.min(maxLeftPx, leftPx)) / scaleX) as any, + ); + onParameterChange( + "overrideY", + (Math.max(0, Math.min(maxBottomPx, bottomPx)) / scaleY) as any, + ); } }; - const handlePointerDown = (e: React.PointerEvent, type: "move" | "resize" | "rotate") => { + const handlePointerDown = ( + e: React.PointerEvent, + type: "move" | "resize" | "rotate", + ) => { e.preventDefault(); ensureOverrides(); const item = style.item as any; const left = parseFloat(String(item.left).replace("px", "")) || 0; const bottom = parseFloat(String(item.bottom).replace("px", "")) || 0; - const width = parseFloat(String(item.width).replace("px", "")) || parameters.fontSize; - const height = parseFloat(String(item.height).replace("px", "")) || parameters.fontSize; + const width = + parseFloat(String(item.width).replace("px", "")) || parameters.fontSize; + const height = + parseFloat(String(item.height).replace("px", "")) || parameters.fontSize; - const rect = (e.currentTarget.parentElement as HTMLElement)?.getBoundingClientRect(); + const rect = ( + e.currentTarget.parentElement as HTMLElement + )?.getBoundingClientRect(); const centerX = left + width / 2; const centerY = bottom + height / 2; @@ -250,7 +323,10 @@ export default function StampPreview({ parameters, onParameterChange, file, show const maxLeftPx = Math.max(0, containerSize.width - drag.initWidth); const maxBottomPx = Math.max(0, containerSize.height - drag.initHeight); const newLeftPx = Math.max(0, Math.min(maxLeftPx, drag.initLeft + dx)); - const newBottomPx = Math.max(0, Math.min(maxBottomPx, drag.initBottom + dy)); + const newBottomPx = Math.max( + 0, + Math.min(maxBottomPx, drag.initBottom + dy), + ); const widthPts = pageSize?.widthPts ?? 595.28; const heightPts = pageSize?.heightPts ?? 841.89; const scaleX = containerSize.width / widthPts; @@ -271,7 +347,8 @@ export default function StampPreview({ parameters, onParameterChange, file, show } if (drag.type === "rotate") { - const angle = Math.atan2(y - drag.centerY, x - drag.centerX) * (180 / Math.PI); + const angle = + Math.atan2(y - drag.centerY, x - drag.centerX) * (180 / Math.PI); onParameterChange("rotation", angle as any); } }; @@ -299,11 +376,19 @@ export default function StampPreview({ parameters, onParameterChange, file, show > {pageThumbnail && ( - page preview + page preview )} {parameters.stampType === "text" && ( -
+
{(parameters.stampText || "").split("\n").map((line, idx) => ( handlePointerDown(e, "move")} > - stamp preview + stamp preview {itemHandles}
)} @@ -336,7 +425,9 @@ export default function StampPreview({ parameters, onParameterChange, file, show
{Array.from({ length: 9 }).map((_, i) => { const idx = (i + 1) as 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; - const selected = parameters.position === idx && (parameters.overrideX < 0 || parameters.overrideY < 0); + const selected = + parameters.position === idx && + (parameters.overrideX < 0 || parameters.overrideY < 0); return (
-
Preview is approximate. Final output may vary due to PDF font metrics.
+
+ Preview is approximate. Final output may vary due to PDF font metrics. +
); } diff --git a/frontend/src/core/components/tools/addStamp/StampPreviewUtils.ts b/frontend/src/core/components/tools/addStamp/StampPreviewUtils.ts index 1ffe709e7..97120c0fc 100644 --- a/frontend/src/core/components/tools/addStamp/StampPreviewUtils.ts +++ b/frontend/src/core/components/tools/addStamp/StampPreviewUtils.ts @@ -5,7 +5,10 @@ export type PageSizePts = { widthPts: number; heightPts: number } | null; export type ImageMeta = { url: string; width: number; height: number } | null; // Map UI margin option to backend margin factor -export const marginFactorMap: Record = { +export const marginFactorMap: Record< + AddStampParameters["customMargin"], + number +> = { small: 0.02, medium: 0.035, large: 0.05, @@ -54,7 +57,13 @@ export const getFirstSelectedPage = (input: string): number => { export type StampPreviewStyle = { container: any; item: any }; // Unified per-alphabet preview adjustments -export type Alphabet = "roman" | "arabic" | "japanese" | "korean" | "chinese" | "thai"; +export type Alphabet = + | "roman" + | "arabic" + | "japanese" + | "korean" + | "chinese" + | "thai"; export type AlphabetTweaks = { scale: number; rowOffsetRem: [number, number, number]; @@ -64,14 +73,51 @@ export type AlphabetTweaks = { }; export const ALPHABET_PREVIEW_TWEAKS: Record = { // [top, middle, bottom] row offsets in rem - roman: { scale: 1.0 / 1.18, rowOffsetRem: [0, 1, 2.2], lineHeight: 1.28, capHeightRatio: 0.7, defaultFontSize: 80 }, - arabic: { scale: 1.2, rowOffsetRem: [0, 1.5, 2.5], lineHeight: 1, capHeightRatio: 0.68, defaultFontSize: 80 }, - japanese: { scale: 1 / 1.2, rowOffsetRem: [-0.1, 1, 2], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 80 }, - korean: { scale: 1.0 / 1.05, rowOffsetRem: [-0.2, 0.5, 1.4], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 80 }, - chinese: { scale: 1 / 1.2, rowOffsetRem: [0, 2, 2.8], lineHeight: 1, capHeightRatio: 0.72, defaultFontSize: 30 }, // temporary default font size so that it fits on the PDF - thai: { scale: 1 / 1.2, rowOffsetRem: [-1, 0, 0.8], lineHeight: 1, capHeightRatio: 0.66, defaultFontSize: 80 }, + roman: { + scale: 1.0 / 1.18, + rowOffsetRem: [0, 1, 2.2], + lineHeight: 1.28, + capHeightRatio: 0.7, + defaultFontSize: 80, + }, + arabic: { + scale: 1.2, + rowOffsetRem: [0, 1.5, 2.5], + lineHeight: 1, + capHeightRatio: 0.68, + defaultFontSize: 80, + }, + japanese: { + scale: 1 / 1.2, + rowOffsetRem: [-0.1, 1, 2], + lineHeight: 1, + capHeightRatio: 0.72, + defaultFontSize: 80, + }, + korean: { + scale: 1.0 / 1.05, + rowOffsetRem: [-0.2, 0.5, 1.4], + lineHeight: 1, + capHeightRatio: 0.72, + defaultFontSize: 80, + }, + chinese: { + scale: 1 / 1.2, + rowOffsetRem: [0, 2, 2.8], + lineHeight: 1, + capHeightRatio: 0.72, + defaultFontSize: 30, + }, // temporary default font size so that it fits on the PDF + thai: { + scale: 1 / 1.2, + rowOffsetRem: [-1, 0, 0.8], + lineHeight: 1, + capHeightRatio: 0.66, + defaultFontSize: 80, + }, }; -export const getAlphabetPreviewScale = (alphabet: string): number => (ALPHABET_PREVIEW_TWEAKS as any)[alphabet]?.scale ?? 1.0; +export const getAlphabetPreviewScale = (alphabet: string): number => + (ALPHABET_PREVIEW_TWEAKS as any)[alphabet]?.scale ?? 1.0; export const getDefaultFontSizeForAlphabet = (alphabet: string): number => { return (ALPHABET_PREVIEW_TWEAKS as any)[alphabet]?.defaultFontSize ?? 80; @@ -92,9 +138,12 @@ export function computeStampPreviewStyle( const heightPts = pageSize?.heightPts ?? 841.89; // A4 height at 72 DPI const scaleX = pageWidthPx / widthPts; const scaleY = pageHeightPx / heightPts; - if (pageWidthPx <= 0 || pageHeightPx <= 0) return { item: {}, container: {} } as any; + if (pageWidthPx <= 0 || pageHeightPx <= 0) + return { item: {}, container: {} } as any; - const marginPts = ((widthPts + heightPts) / 2) * (marginFactorMap[parameters.customMargin] ?? 0.035); + const marginPts = + ((widthPts + heightPts) / 2) * + (marginFactorMap[parameters.customMargin] ?? 0.035); // Compute content dimensions const heightPtsContent = @@ -146,7 +195,8 @@ export function computeStampPreviewStyle( // Positioning helpers - mirror backend logic const position = parameters.position; const calcX = () => { - if (parameters.overrideX >= 0 && parameters.overrideY >= 0) return parameters.overrideX; + if (parameters.overrideX >= 0 && parameters.overrideY >= 0) + return parameters.overrideX; switch (position % 3) { case 1: // Left return marginPts; @@ -159,11 +209,14 @@ export function computeStampPreviewStyle( } }; const calcY = () => { - if (parameters.overrideX >= 0 && parameters.overrideY >= 0) return parameters.overrideY; + if (parameters.overrideX >= 0 && parameters.overrideY >= 0) + return parameters.overrideY; // For text, backend positions using cap height, not full font size const heightForY = parameters.stampType === "text" - ? heightPtsContent * ((ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.capHeightRatio ?? 0.7) + ? heightPtsContent * + ((ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet] + ?.capHeightRatio ?? 0.7) : heightPtsContent; switch (Math.floor((position - 1) / 3)) { case 0: // Top @@ -183,9 +236,13 @@ export function computeStampPreviewStyle( let yPx = yPts * scaleY; if (parameters.stampType === "text") { try { - const rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16") || 16; + const rootFontSizePx = + parseFloat( + getComputedStyle(document.documentElement).fontSize || "16", + ) || 16; const rowIndex = Math.floor((position - 1) / 3); // 0 top, 1 middle, 2 bottom - const offsets = (ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.rowOffsetRem ?? [0, 0, 0]; + const offsets = (ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet] + ?.rowOffsetRem ?? [0, 0, 0]; const offsetRem = offsets[rowIndex] ?? 0; yPx += offsetRem * rootFontSizePx; } catch (e) { @@ -234,12 +291,14 @@ export function computeStampPreviewStyle( height: `${heightPx}px`, opacity: displayOpacity, transform: `rotate(${-parameters.rotation}deg)`, - transformOrigin: parameters.stampType === "image" ? "left bottom" : "center center", + transformOrigin: + parameters.stampType === "image" ? "left bottom" : "center center", color: parameters.customColor, display: "flex", flexDirection: "column", justifyContent: "flex-start", - lineHeight: (ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.lineHeight ?? 1, + lineHeight: + (ALPHABET_PREVIEW_TWEAKS as any)[parameters.alphabet]?.lineHeight ?? 1, alignItems, cursor: showQuickGrid ? "default" : "move", pointerEvents: showQuickGrid ? "none" : "auto", diff --git a/frontend/src/core/components/tools/addStamp/StampSetupSettings.tsx b/frontend/src/core/components/tools/addStamp/StampSetupSettings.tsx index 9129abac6..1625affa5 100644 --- a/frontend/src/core/components/tools/addStamp/StampSetupSettings.tsx +++ b/frontend/src/core/components/tools/addStamp/StampSetupSettings.tsx @@ -60,7 +60,10 @@ const STAMP_TEMPLATES = [ }, ]; -const resolveVariablesForPreview = (text: string, filename?: string): string => { +const resolveVariablesForPreview = ( + text: string, + filename?: string, +): string => { const now = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); @@ -130,7 +133,11 @@ interface ClickableCodeProps { block?: boolean; } -const ClickableCode = ({ children, onClick, block = false }: ClickableCodeProps) => ( +const ClickableCode = ({ + children, + onClick, + block = false, +}: ClickableCodeProps) => ( ); -const StampTextPreview = ({ stampText, filename }: { stampText: string; filename?: string }) => { +const StampTextPreview = ({ + stampText, + filename, +}: { + stampText: string; + filename?: string; +}) => { const { t } = useTranslation(); const resolvedText = useMemo(() => { @@ -165,7 +178,14 @@ const StampTextPreview = ({ stampText, filename }: { stampText: string; filename {t("AddStampRequest.preview", "Preview:")} - + {resolvedText} @@ -174,20 +194,33 @@ const StampTextPreview = ({ stampText, filename }: { stampText: string; filename interface StampSetupSettingsProps { parameters: AddStampParameters; - onParameterChange: (key: K, value: AddStampParameters[K]) => void; + onParameterChange: ( + key: K, + value: AddStampParameters[K], + ) => void; disabled?: boolean; filename?: string; } -const StampSetupSettings = ({ parameters, onParameterChange, disabled = false, filename }: StampSetupSettingsProps) => { +const StampSetupSettings = ({ + parameters, + onParameterChange, + disabled = false, + filename, +}: StampSetupSettingsProps) => { const { t } = useTranslation(); return ( onParameterChange("pageNumbers", e.currentTarget.value)} + onChange={(e) => + onParameterChange("pageNumbers", e.currentTarget.value) + } disabled={disabled} /> @@ -213,11 +246,17 @@ const StampSetupSettings = ({ parameters, onParameterChange, disabled = false, f {/* Template Selector - always shows placeholder, doesn't persist selection */}