diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e74347837..387138b05 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,7 @@ "@mantine/hooks": "^8.3.1", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", + "@reactour/tour": "^3.8.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "autoprefixer": "^10.4.21", @@ -87,6 +88,7 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", + "puppeteer": "^24.25.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", @@ -2554,6 +2556,79 @@ "integrity": "sha512-igElrcnRPJh2nWYACschjH4OwGwzSa6xVFzRDVzpnjirUivdJ8nv4hE+H31nvwE56MFhvvglfHuotnWLMcRW7w==", "license": "MIT" }, + "node_modules/@puppeteer/browsers": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.12.tgz", + "integrity": "sha512-mP9iLFZwH+FapKJLeA7/fLqOlSUwYpMwjR1P5J23qd4e7qGJwecJccJqHYrjw33jmIZYV4dtiTHPD/J+1e7cEw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "extract-zip": "^2.0.1", + "progress": "^2.0.3", + "proxy-agent": "^6.5.0", + "semver": "^7.7.3", + "tar-fs": "^3.1.1", + "yargs": "^17.7.2" + }, + "bin": { + "browsers": "lib/cjs/main-cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@reactour/mask": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@reactour/mask/-/mask-1.2.0.tgz", + "integrity": "sha512-XLgBLWfKJybtZjNTSO5lt/SIvRlCZBadB6JfE/hO1ErqURRjYhnv+edC0Ki1haUCqMGFppWk3lwcPCjmK0xNog==", + "license": "MIT", + "dependencies": { + "@reactour/utils": "*" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, + "node_modules/@reactour/popover": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@reactour/popover/-/popover-1.3.0.tgz", + "integrity": "sha512-YdyjSmHPvEeQEcJM4gcGFa5pI/Yf4nZGqwG4JnT+rK1SyUJBIPnm4Gkl/h7/+1g0KCFMkwNwagS3ZiXvZB7ThA==", + "license": "MIT", + "dependencies": { + "@reactour/utils": "*" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, + "node_modules/@reactour/tour": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/@reactour/tour/-/tour-3.8.0.tgz", + "integrity": "sha512-KZTFi1pAvoTVKKRdBN5+XCYxXBp4k4Ql/acZcXyPvec8VU24fkMSEeV+v8krfYQpoVcewxIu3gM6xWZZLjxi7w==", + "license": "MIT", + "dependencies": { + "@reactour/mask": "*", + "@reactour/popover": "*", + "@reactour/utils": "*" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, + "node_modules/@reactour/utils": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@reactour/utils/-/utils-0.6.0.tgz", + "integrity": "sha512-GqaLjQi7MJsgtAKjdiw2Eak1toFkADoLRnm1+HZpaD+yl+DkaHpC1N7JAl+kVOO5I17bWInPA+OFbXjO9Co8Qg==", + "license": "MIT", + "dependencies": { + "@rooks/use-mutation-observer": "^4.11.2", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": "16.x || 17.x || 18.x || 19.x" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.35", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", @@ -2869,6 +2944,15 @@ "win32" ] }, + "node_modules/@rooks/use-mutation-observer": { + "version": "4.11.2", + "resolved": "https://registry.npmjs.org/@rooks/use-mutation-observer/-/use-mutation-observer-4.11.2.tgz", + "integrity": "sha512-vpsdrZdr6TkB1zZJcHx+fR1YC/pHs2BaqcuYiEGjBVbwY5xcC49+h0hAUtQKHth3oJqXfIX/Ng8S7s5HFHdM/A==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", @@ -3502,6 +3586,13 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tootallnate/quickjs-emscripten": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", + "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", + "dev": true, + "license": "MIT" + }, "node_modules/@ts-graphviz/adapter": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@ts-graphviz/adapter/-/adapter-2.0.6.tgz", @@ -3734,6 +3825,17 @@ "@types/react": "*" } }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.44.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz", @@ -4428,6 +4530,26 @@ "node": ">=18" } }, + "node_modules/ast-types": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", + "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ast-types/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.5.tgz", @@ -4510,6 +4632,21 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/b4a": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.7.3.tgz", + "integrity": "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -4532,6 +4669,103 @@ "dev": true, "license": "MIT" }, + "node_modules/bare-events": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.0.tgz", + "integrity": "sha512-AOhh6Bg5QmFIXdViHbMc2tLDsBIRxdkIaIddPslJF9Z5De3APBScuqGP2uThXnIpqFrgoxMNC6km7uXNIMLHXA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } + }, + "node_modules/bare-fs": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.4.11.tgz", + "integrity": "sha512-Bejmm9zRMvMTRoHS+2adgmXw1ANZnCNx+B5dgZpGwlP1E3x6Yuxea8RToddHUbWtVV0iUMWqsgZr8+jcgUI2SA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-events": "^2.5.4", + "bare-path": "^3.0.0", + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" + }, + "engines": { + "bare": ">=1.16.0" + }, + "peerDependencies": { + "bare-buffer": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + } + } + }, + "node_modules/bare-os": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.2.tgz", + "integrity": "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "bare": ">=1.14.0" + } + }, + "node_modules/bare-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", + "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-os": "^3.0.1" + } + }, + "node_modules/bare-stream": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.7.0.tgz", + "integrity": "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "streamx": "^2.21.0" + }, + "peerDependencies": { + "bare-buffer": "*", + "bare-events": "*" + }, + "peerDependenciesMeta": { + "bare-buffer": { + "optional": true + }, + "bare-events": { + "optional": true + } + } + }, + "node_modules/bare-url": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.0.tgz", + "integrity": "sha512-c+RCqMSZbkz97Mw1LWR0gcOqwK82oyYKfLoHJ8k13ybi1+I80ffdDzUy0TdAburdrR/kI0/VuN8YgEnJqX+Nyw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -4562,6 +4796,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/basic-ftp": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz", + "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -4699,6 +4943,16 @@ "ieee754": "^1.1.13" } }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -4879,6 +5133,20 @@ "node": ">=18" } }, + "node_modules/chromium-bidi": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-9.1.0.tgz", + "integrity": "sha512-rlUzQ4WzIAWdIbY/viPShhZU2n21CxDUgazXVbw4Hu1MwaeUSEksSeM6DqPgpRjCLXRk702AVRxJxoOz0dw4OA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, "node_modules/cli-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", @@ -5138,6 +5406,16 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/data-uri-to-buffer": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz", + "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/data-urls": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", @@ -5269,6 +5547,21 @@ "node": ">=10" } }, + "node_modules/degenerator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", + "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ast-types": "^0.13.4", + "escodegen": "^2.1.0", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5483,6 +5776,13 @@ "typescript": "^5.4.4" } }, + "node_modules/devtools-protocol": { + "version": "0.0.1508733", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz", + "integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/dezalgo": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", @@ -5545,6 +5845,16 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.3", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", @@ -5571,6 +5881,16 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/eol": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/eol/-/eol-0.10.0.tgz", @@ -5989,6 +6309,16 @@ "node": ">=0.10.0" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -6006,6 +6336,43 @@ "dev": true, "license": "MIT" }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extract-zip/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6013,6 +6380,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", @@ -6067,6 +6441,16 @@ "reusify": "^1.0.4" } }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, "node_modules/fflate": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", @@ -6445,6 +6829,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-uri": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", + "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "basic-ftp": "^5.0.2", + "data-uri-to-buffer": "^6.0.2", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -6900,6 +7299,16 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -8056,6 +8465,13 @@ "node": ">= 18" } }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -8178,6 +8594,16 @@ "dev": true, "license": "MIT" }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/no-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", @@ -8480,6 +8906,40 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pac-proxy-agent": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", + "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/quickjs-emscripten": "^0.23.0", + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "get-uri": "^6.0.1", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.6", + "pac-resolver": "^7.0.1", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/pac-resolver": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz", + "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "degenerator": "^5.0.0", + "netmask": "^2.0.2" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -8663,6 +9123,13 @@ "@napi-rs/canvas": "^0.1.77" } }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9178,6 +9645,16 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9195,12 +9672,53 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "license": "MIT" }, + "node_modules/proxy-agent": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", + "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "http-proxy-agent": "^7.0.1", + "https-proxy-agent": "^7.0.6", + "lru-cache": "^7.14.1", + "pac-proxy-agent": "^7.1.0", + "proxy-from-env": "^1.1.0", + "socks-proxy-agent": "^8.0.5" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/proxy-agent/node_modules/lru-cache": { + "version": "7.18.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", + "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -9211,6 +9729,74 @@ "node": ">=6" } }, + "node_modules/puppeteer": { + "version": "24.25.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.25.0.tgz", + "integrity": "sha512-P3rUaom2w/Ubrnz3v3kSbxGkN7SpbtQeGRPb7iO86Bv/dAz2WUmGQBHr37W/Rp1fbAocMvu0rHFbCIJvjiNhGw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.12", + "chromium-bidi": "9.1.0", + "cosmiconfig": "^9.0.0", + "devtools-protocol": "0.0.1508733", + "puppeteer-core": "24.25.0", + "typed-query-selector": "^2.12.0" + }, + "bin": { + "puppeteer": "lib/cjs/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer-core": { + "version": "24.25.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.25.0.tgz", + "integrity": "sha512-8Xs6q3Ut+C8y7sAaqjIhzv1QykGWG4gc2mEZ2mYE7siZFuRp4xQVehOf8uQKSQAkeL7jXUs3mknEeiqnRqUKvQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "2.10.12", + "chromium-bidi": "9.1.0", + "debug": "^4.4.3", + "devtools-protocol": "0.0.1508733", + "typed-query-selector": "^2.12.0", + "webdriver-bidi-protocol": "0.3.7", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/puppeteer/node_modules/cosmiconfig": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", + "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -9699,6 +10285,12 @@ "node": ">=10.13.0" } }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -9917,9 +10509,9 @@ "license": "MIT" }, "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -10023,6 +10615,47 @@ "node": "*" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -10145,6 +10778,18 @@ "any-promise": "^1.1.0" } }, + "node_modules/streamx": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", + "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "events-universal": "^1.0.0", + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + } + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -10424,6 +11069,33 @@ "node": ">=18" } }, + "node_modules/tar-fs": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", + "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0", + "tar-stream": "^3.1.5" + }, + "optionalDependencies": { + "bare-fs": "^4.0.1", + "bare-path": "^3.0.0" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, "node_modules/test-exclude": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", @@ -10460,6 +11132,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -10719,6 +11401,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-query-selector": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz", + "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", + "dev": true, + "license": "MIT" + }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", @@ -11273,6 +11962,13 @@ "integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==", "license": "Apache-2.0" }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.3.7.tgz", + "integrity": "sha512-wIx5Gu/LLTeexxilpk8WxU2cpGAKlfbWRO5h+my6EMD1k5PYqM1qQO1MHUFf4f3KRnhBvpbZU7VkizAgeSEf7g==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", @@ -11507,6 +12203,17 @@ "node": ">=12" } }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -11519,6 +12226,16 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index f11184471..a508f7f9b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -33,6 +33,7 @@ "@mantine/hooks": "^8.3.1", "@mui/icons-material": "^7.3.2", "@mui/material": "^7.3.2", + "@reactour/tour": "^3.8.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-virtual": "^3.13.12", "autoprefixer": "^10.4.21", @@ -66,6 +67,7 @@ "generate-licenses": "node scripts/generate-licenses.js", "generate-icons": "node scripts/generate-icons.js", "generate-icons:verbose": "node scripts/generate-icons.js --verbose", + "generate-sample-pdf": "node scripts/sample-pdf/generate.mjs", "test": "vitest", "test:run": "vitest run", "test:watch": "vitest --watch", @@ -126,6 +128,7 @@ "postcss-cli": "^11.0.1", "postcss-preset-mantine": "^1.18.0", "postcss-simple-vars": "^7.0.1", + "puppeteer": "^24.25.0", "typescript": "^5.9.2", "typescript-eslint": "^8.44.1", "vite": "^7.1.7", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 20c3590e8..5cb16aacd 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -94,8 +94,8 @@ "save": "Save", "saveToBrowser": "Save to Browser", "download": "Download", - "pin": "Pin", - "unpin": "Unpin", + "pin": "Pin File (keep active after tool run)", + "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", "undo": "Undo", "moreOptions": "More Options", @@ -455,6 +455,9 @@ "alphabetical": "Alphabetical", "globalPopularity": "Global Popularity", "sortBy": "Sort by:", + "mobile": { + "brandAlt": "Stirling PDF logo" + }, "multiTool": { "tags": "multiple,tools", "title": "PDF Multi Tool", @@ -3469,6 +3472,7 @@ "automate": "Automate", "files": "Files", "activity": "Activity", + "help": "Help", "account": "Account", "config": "Config", "allTools": "All Tools" @@ -3969,5 +3973,38 @@ "undoQuotaError": "Cannot undo: insufficient storage space", "undoStorageError": "Undo completed but some files could not be saved to storage", "undoSuccess": "Operation undone successfully", - "unsupported": "Unsupported" + "unsupported": "Unsupported", + "onboarding": { + "welcomeModal": { + "title": "Welcome to Stirling PDF!", + "description": "Would you like to take a quick 1-minute tour to learn the key features and how to get started?", + "helpHint": "You can always access this tour later from the Help button in the bottom left.", + "startTour": "Start Tour", + "maybeLater": "Maybe Later", + "dontShowAgain": "Don't Show Again" + }, + "allTools": "This is the All Tools panel, where you can browse and select from all available PDF tools.", + "selectCropTool": "Let's select the Crop tool to demonstrate how to use one of the tools.", + "toolInterface": "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.", + "filesButton": "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on.", + "fileSources": "You can upload new files or access recent files from here. For the tour, we'll just use a sample file.", + "workbench": "This is the Workbench - the main area where you view and edit your PDFs.", + "viewSwitcher": "Use these controls to select how you want to view your PDFs.", + "viewer": "The Viewer lets you read and annotate your PDFs.", + "pageEditor": "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting.", + "activeFiles": "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process.", + "fileCheckbox": "Clicking one of the files selects it for processing. You can select multiple files for batch operations.", + "selectControls": "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language.", + "cropSettings": "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to.", + "runButton": "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs.", + "results": "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. ", + "fileReplacement": "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools.", + "pinButton": "You can use the Pin button if you'd rather your files stay active after running tools on them.", + "wrapUp": "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again.", + "previous": "Previous", + "next": "Next", + "finish": "Finish", + "startTour": "Start Tour", + "startTourDescription": "Take a guided tour of Stirling PDF's key features" + } } diff --git a/frontend/public/samples/Sample.pdf b/frontend/public/samples/Sample.pdf new file mode 100644 index 000000000..d78d9e1ef Binary files /dev/null and b/frontend/public/samples/Sample.pdf differ diff --git a/frontend/scripts/sample-pdf/generate.mjs b/frontend/scripts/sample-pdf/generate.mjs new file mode 100755 index 000000000..93e5cf7ee --- /dev/null +++ b/frontend/scripts/sample-pdf/generate.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +/** + * Stirling PDF Sample Document Generator + * + * This script uses Puppeteer to generate a sample PDF from a HTML template. + * The output is used in the onboarding tour and as a demo document + * for users to experiment with Stirling PDF's features. + */ + +import puppeteer from 'puppeteer'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { existsSync, mkdirSync, statSync } from 'fs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const TEMPLATE_PATH = join(__dirname, 'template.html'); +const OUTPUT_DIR = join(__dirname, '../../public/samples'); +const OUTPUT_PATH = join(OUTPUT_DIR, 'Sample.pdf'); + +async function generatePDF() { + console.log('šŸš€ Starting Stirling PDF sample document generation...\n'); + + // Ensure output directory exists + if (!existsSync(OUTPUT_DIR)) { + mkdirSync(OUTPUT_DIR, { recursive: true }); + console.log(`āœ… Created output directory: ${OUTPUT_DIR}`); + } + + // Check if template exists + if (!existsSync(TEMPLATE_PATH)) { + console.error(`āŒ Template file not found: ${TEMPLATE_PATH}`); + process.exit(1); + } + + console.log(`šŸ“„ Reading template: ${TEMPLATE_PATH}`); + + let browser; + try { + // Launch Puppeteer + console.log('🌐 Launching browser...'); + browser = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox', '--disable-setuid-sandbox'] + }); + + const page = await browser.newPage(); + + // Set viewport to match A4 proportions + await page.setViewport({ + width: 794, // A4 width in pixels at 96 DPI + height: 1123, // A4 height in pixels at 96 DPI + deviceScaleFactor: 2 // Higher quality rendering + }); + + // Navigate to the template file + const fileUrl = `file://${TEMPLATE_PATH}`; + console.log('šŸ“– Loading HTML template...'); + await page.goto(fileUrl, { + waitUntil: 'networkidle0' // Wait for all resources to load + }); + + // Generate PDF with A4 dimensions + console.log('šŸ“ Generating PDF...'); + await page.pdf({ + path: OUTPUT_PATH, + format: 'A4', + printBackground: true, + margin: { + top: 0, + right: 0, + bottom: 0, + left: 0 + }, + preferCSSPageSize: true + }); + + console.log('\nāœ… PDF generated successfully!'); + console.log(`šŸ“¦ Output: ${OUTPUT_PATH}`); + + // Get file size + const stats = statSync(OUTPUT_PATH); + const fileSizeInKB = (stats.size / 1024).toFixed(2); + console.log(`šŸ“Š File size: ${fileSizeInKB} KB`); + + } catch (error) { + console.error('\nāŒ Error generating PDF:', error.message); + process.exit(1); + } finally { + if (browser) { + await browser.close(); + console.log('šŸ”’ Browser closed.'); + } + } + + console.log('\nšŸŽ‰ Done! Sample PDF is ready for use in Stirling PDF.\n'); +} + +// Run the generator +generatePDF().catch(error => { + console.error('Fatal error:', error); + process.exit(1); +}); diff --git a/frontend/scripts/sample-pdf/styles.css b/frontend/scripts/sample-pdf/styles.css new file mode 100644 index 000000000..067452833 --- /dev/null +++ b/frontend/scripts/sample-pdf/styles.css @@ -0,0 +1,432 @@ +/* Stirling PDF Sample Document Styles */ + +:root { + /* Brand Colors */ + --brand-red: #8e3231; + --brand-blue: #3b82f6; + + /* Category Colors */ + --color-general: #3b82f6; + --color-security: #f59e0b; + --color-formatting: #8b5cf6; + --color-automation: #ec4899; + + /* Neutral Colors */ + --color-black: #111827; + --color-gray-dark: #4b5563; + --color-gray-medium: #6b7280; + --color-gray-light: #e5e7eb; + --color-gray-lighter: #f3f4f6; + --color-white: #ffffff; + + /* Font Stack */ + --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: var(--font-family); + color: var(--color-black); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Page Structure - A4 Dimensions */ +.page { + width: 210mm; + height: 297mm; + background: white; + page-break-after: always; + position: relative; + overflow: hidden; +} + +.page:last-child { + page-break-after: auto; +} + +/* Page 1: Hero / Cover */ +.page-1 { + background: var(--brand-red); + display: flex; + align-items: center; + justify-content: center; + position: relative; + overflow: hidden; +} + +/* Decorative shapes container */ +.decorative-shapes { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; + z-index: 0; +} + +.shape { + position: absolute; +} + +/* Logo SVG shape - top-right */ +.shape-1 { + top: -120px; + right: -100px; + width: 450px; + height: auto; + opacity: 0.12; +} + +/* Logo SVG shape - top-left */ +.shape-2 { + top: -80px; + left: -80px; + width: 350px; + height: auto; + opacity: 0.08; +} + +/* Logo SVG shape - bottom-left */ +.shape-3 { + bottom: -180px; + left: -150px; + width: 550px; + height: auto; + opacity: 0.15; +} + +/* Logo SVG shape - bottom-right */ +.shape-4 { + bottom: -100px; + right: -120px; + width: 400px; + height: auto; + opacity: 0.1; +} + +/* Small accent shape center-right */ +.shape-5 { + top: 50%; + right: -30px; + width: 200px; + height: auto; + opacity: 0.08; + transform: translateY(-50%); +} + +.hero-content { + text-align: center; + padding: 60px; + position: relative; + z-index: 1; +} + +.logo-container { + margin-bottom: 48px; + position: relative; +} + +.hero-logo { + width: 280px; + height: auto; +} + +.hero-tagline { + font-size: 32px; + font-weight: 600; + color: var(--color-white); + margin-bottom: 32px; + line-height: 1.3; +} + +.hero-stats { + margin-bottom: 40px; +} + +.stat-badge { + display: inline-flex; + flex-direction: column; + align-items: center; + padding: 24px 48px; + background: rgba(255, 255, 255, 0.95); + border-radius: 16px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} + +.stat-number { + font-size: 48px; + font-weight: 700; + color: var(--brand-red); + line-height: 1; +} + +.stat-label { + font-size: 18px; + color: var(--color-gray-dark); + margin-top: 8px; + font-weight: 500; +} + +.hero-features { + display: flex; + justify-content: center; + gap: 16px; + flex-wrap: wrap; +} + +.feature-pill { + padding: 12px 24px; + background: rgba(255, 255, 255, 0.2); + color: white; + border-radius: 24px; + font-size: 16px; + font-weight: 500; + border: 2px solid rgba(255, 255, 255, 0.3); + backdrop-filter: blur(10px); +} + +/* Page 2: What is Stirling PDF */ +.page-2 { + padding: 60px; +} + +.content-wrapper { + max-width: 700px; + margin: 0 auto; +} + +.page-title { + font-size: 36px; + font-weight: 700; + color: var(--brand-red); + margin-bottom: 24px; + border-bottom: 4px solid var(--brand-red); + padding-bottom: 16px; +} + +.intro-text { + font-size: 16px; + color: var(--color-gray-dark); + margin-bottom: 48px; + line-height: 1.8; +} + +.value-props { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; +} + +.value-prop { + display: flex; + flex-direction: column; + gap: 12px; +} + +.value-icon { + width: 48px; + height: 48px; + color: var(--brand-red); + margin-bottom: 8px; +} + +.value-icon svg { + width: 100%; + height: 100%; +} + +.value-prop h3 { + font-size: 20px; + font-weight: 600; + color: var(--color-black); +} + +.value-prop p { + font-size: 14px; + color: var(--color-gray-dark); + line-height: 1.6; +} + +/* Page 3: Key Features */ +.page-3 { + padding: 60px; +} + +.features-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: 32px; +} + +.feature-card { + background: white; + border: 2px solid var(--color-gray-light); + border-radius: 12px; + padding: 24px; + transition: all 0.2s ease; +} + +.feature-card[data-category="general"] { + border-color: var(--color-general); +} + +.feature-card[data-category="security"] { + border-color: var(--color-security); +} + +.feature-card[data-category="formatting"] { + border-color: var(--color-formatting); +} + +.feature-card[data-category="automation"] { + border-color: var(--color-automation); +} + +.feature-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.feature-icon-large { + width: 40px; + height: 40px; + flex-shrink: 0; +} + +.feature-card[data-category="general"] .feature-icon-large { + color: var(--color-general); +} + +.feature-card[data-category="security"] .feature-icon-large { + color: var(--color-security); +} + +.feature-card[data-category="formatting"] .feature-icon-large { + color: var(--color-formatting); +} + +.feature-card[data-category="automation"] .feature-icon-large { + color: var(--color-automation); +} + +.feature-icon-large svg { + width: 100%; + height: 100%; +} + +.feature-card h3 { + font-size: 18px; + font-weight: 600; + color: var(--color-black); +} + +.feature-list { + list-style: none; + padding: 0; +} + +.feature-list li { + font-size: 14px; + color: var(--color-gray-dark); + padding: 6px 0; + padding-left: 20px; + position: relative; +} + +.feature-list li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--brand-red); + font-weight: bold; +} + +.additional-features { + background: white; + border: 2px solid var(--brand-red); + padding: 24px; + border-radius: 12px; + margin-top: 24px; +} + +.additional-features-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.additional-features-icon { + width: 40px; + height: 40px; + color: var(--brand-red); + flex-shrink: 0; +} + +.additional-features-icon svg { + width: 100%; + height: 100%; +} + +.additional-features h3 { + font-size: 18px; + font-weight: 600; + color: var(--color-black); + margin: 0; +} + +.additional-features-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 32px; +} + +.additional-features-grid ul { + list-style: none; + padding: 0; + margin: 0; +} + +.additional-features-grid li { + font-size: 15px; + color: var(--color-gray-dark); + padding: 4px 0; + padding-left: 24px; + position: relative; + line-height: 1.5; +} + +.additional-features-grid li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--brand-red); + font-weight: bold; + font-size: 18px; +} + +/* Print Styles */ +@media print { + body { + margin: 0; + padding: 0; + } + + .page { + margin: 0; + border: none; + box-shadow: none; + } +} diff --git a/frontend/scripts/sample-pdf/template.html b/frontend/scripts/sample-pdf/template.html new file mode 100644 index 000000000..e4ae57e50 --- /dev/null +++ b/frontend/scripts/sample-pdf/template.html @@ -0,0 +1,234 @@ + + + + + + Stirling PDF - Sample Document + + + + +
+
+ + + + + +
+
+
+ +
+

The Free Adobe Acrobat Alternative

+
+
+ 10M+ + Downloads +
+
+
+
Open Source
+
Privacy First
+
Self-Hosted
+
+
+
+ + +
+
+

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. +

+ +
+
+
+ + + +
+

50+ PDF Operations

+

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

+
+ +
+
+ + + +
+

Workflow Automation

+

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.

+
+ +
+
+ + + + + +
+

Privacy First

+

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.

+
+ +
+
+ + + + +
+

API Access

+

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

+
+
+
+
+ + +
+
+

Key Features

+ +
+
+
+
+ + + + + + + +
+

Page Operations

+
+
    +
  • Merge & split PDFs
  • +
  • Rearrange pages
  • +
  • Rotate & crop
  • +
  • Extract pages
  • +
  • Multi-page layout
  • +
+
+ +
+
+
+ + + + +
+

Security & Signing

+
+
    +
  • Password protection
  • +
  • Digital signatures
  • +
  • Watermarks
  • +
  • Permission controls
  • +
  • Redaction tools
  • +
+
+ +
+
+
+ + + +
+

File Conversions

+
+
    +
  • PDF to/from images
  • +
  • Office documents
  • +
  • HTML to PDF
  • +
  • Markdown to PDF
  • +
  • PDF to Word/Excel
  • +
+
+ +
+
+
+ + + +
+

Automation

+
+
    +
  • Multi-step workflows
  • +
  • Chain PDF operations
  • +
  • Save recurring tasks
  • +
  • Batch file processing
  • +
  • API integration
  • +
+
+
+ +
+
+
+ + + +
+

Plus Many More

+
+
+
    +
  • OCR text recognition
  • +
  • Compress PDFs
  • +
  • Add images & stamps
  • +
  • Detect blank pages
  • +
  • Extract images
  • +
  • Edit metadata
  • +
+
    +
  • Flatten forms
  • +
  • PDF/A conversion
  • +
  • Add page numbers
  • +
  • Remove pages
  • +
  • Repair PDFs
  • +
  • And 40+ more tools
  • +
+
+
+
+
+ + + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5db513d4a..406eef5d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,8 +7,11 @@ import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext"; import { HotkeyProvider } from "./contexts/HotkeyContext"; import { SidebarProvider } from "./contexts/SidebarContext"; import { PreferencesProvider } from "./contexts/PreferencesContext"; +import { OnboardingProvider } from "./contexts/OnboardingContext"; +import { TourOrchestrationProvider } from "./contexts/TourOrchestrationContext"; import ErrorBoundary from "./components/shared/ErrorBoundary"; import HomePage from "./pages/HomePage"; +import OnboardingTour from "./components/onboarding/OnboardingTour"; // Import global styles import "./styles/tailwind.css"; @@ -43,25 +46,30 @@ export default function App() { - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index 5f14779a6..7f6bf0950 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -247,6 +247,7 @@ const FileEditorThumbnail = ({ ref={fileElementRef} data-file-id={file.id} data-testid="file-thumbnail" + data-tour="file-card-checkbox" data-selected={isSelected} data-supported={isSupported} className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`} @@ -293,11 +294,12 @@ const FileEditorThumbnail = ({ {/* Action buttons group */}
{/* Pin/Unpin icon */} - + { e.stopPropagation(); if (actualFile) { diff --git a/frontend/src/components/fileManager/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx index 8d1e32ffc..78f90a97a 100644 --- a/frontend/src/components/fileManager/DesktopLayout.tsx +++ b/frontend/src/components/fileManager/DesktopLayout.tsx @@ -23,7 +23,7 @@ const DesktopLayout: React.FC = () => { width: '13.625rem', flexShrink: 0, height: '100%', - }}> + }} data-tour="file-sources"> diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index 8d69a9b71..74356b97d 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -153,6 +153,7 @@ export default function Workbench() { return ( { + const wasClosedNowOpen = !previousIsOpenRef.current && isOpen; + previousIsOpenRef.current = isOpen; + + if (wasClosedNowOpen) { + // Tour is being opened (Help button pressed), reset to first step + setCurrentStep(0); + } + setIsOpen(isOpen); + }, [isOpen, setIsOpen, setCurrentStep]); + + return null; +} + +export default function OnboardingTour() { + const { t } = useTranslation(); + const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour } = useOnboarding(); + const { openFilesModal, closeFilesModal } = useFilesModalContext(); + const { + saveWorkbenchState, + restoreWorkbenchState, + backToAllTools, + selectCropTool, + loadSampleFile, + switchToViewer, + switchToPageEditor, + switchToActiveFiles, + selectFirstFile, + pinFile, + modifyCropSettings, + executeTool, + } = useTourOrchestration(); + + // Define steps as object keyed by enum - TypeScript ensures all keys are present + const stepsConfig: Record = { + [TourStep.ALL_TOOLS]: { + selector: '[data-tour="tool-panel"]', + content: t('onboarding.allTools', 'This is the All Tools panel, where you can browse and select from all available PDF tools.'), + position: 'center', + padding: 0, + action: () => { + saveWorkbenchState(); + closeFilesModal(); + backToAllTools(); + }, + }, + [TourStep.SELECT_CROP_TOOL]: { + selector: '[data-tour="tool-button-crop"]', + content: t('onboarding.selectCropTool', "Let's select the Crop tool to demonstrate how to use one of the tools."), + position: 'right', + padding: 0, + actionAfter: () => selectCropTool(), + }, + [TourStep.TOOL_INTERFACE]: { + selector: '[data-tour="tool-panel"]', + content: t('onboarding.toolInterface', "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."), + position: 'center', + padding: 0, + }, + [TourStep.FILES_BUTTON]: { + selector: '[data-tour="files-button"]', + content: t('onboarding.filesButton', "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on."), + position: 'right', + padding: 10, + action: () => openFilesModal(), + }, + [TourStep.FILE_SOURCES]: { + selector: '[data-tour="file-sources"]', + content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."), + position: 'right', + padding: 0, + actionAfter: () => { + loadSampleFile(); + closeFilesModal(); + } + }, + [TourStep.WORKBENCH]: { + selector: '[data-tour="workbench"]', + content: t('onboarding.workbench', 'This is the Workbench - the main area where you view and edit your PDFs.'), + position: 'center', + padding: 0, + }, + [TourStep.VIEW_SWITCHER]: { + selector: '[data-tour="view-switcher"]', + content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'), + position: 'bottom', + padding: 0, + }, + [TourStep.VIEWER]: { + selector: '[data-tour="workbench"]', + content: t('onboarding.viewer', "The Viewer lets you read and annotate your PDFs."), + position: 'center', + padding: 0, + action: () => switchToViewer(), + }, + [TourStep.PAGE_EDITOR]: { + selector: '[data-tour="workbench"]', + content: t('onboarding.pageEditor', "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."), + position: 'center', + padding: 0, + action: () => switchToPageEditor(), + }, + [TourStep.ACTIVE_FILES]: { + selector: '[data-tour="workbench"]', + content: t('onboarding.activeFiles', "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."), + position: 'center', + padding: 0, + action: () => switchToActiveFiles(), + }, + [TourStep.FILE_CHECKBOX]: { + selector: '[data-tour="file-card-checkbox"]', + content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."), + position: 'top', + padding: 10, + }, + [TourStep.SELECT_CONTROLS]: { + selector: '[data-tour="right-rail-controls"]', + highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'], + content: t('onboarding.selectControls', "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."), + position: 'left', + padding: 5, + action: () => selectFirstFile(), + }, + [TourStep.CROP_SETTINGS]: { + selector: '[data-tour="crop-settings"]', + content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."), + position: 'left', + padding: 10, + action: () => modifyCropSettings(), + }, + [TourStep.RUN_BUTTON]: { + selector: '[data-tour="run-button"]', + content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."), + position: 'top', + padding: 10, + actionAfter: () => executeTool(), + }, + [TourStep.RESULTS]: { + selector: '[data-tour="tool-panel"]', + content: t('onboarding.results', "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "), + position: 'center', + padding: 0, + }, + [TourStep.FILE_REPLACEMENT]: { + selector: '[data-tour="file-card-checkbox"]', + content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."), + position: 'left', + padding: 10, + }, + [TourStep.PIN_BUTTON]: { + selector: '[data-tour="file-card-pin"]', + content: t('onboarding.pinButton', "You can use the Pin button if you'd rather your files stay active after running tools on them."), + position: 'left', + padding: 10, + action: () => pinFile(), + }, + [TourStep.WRAP_UP]: { + selector: '[data-tour="help-button"]', + content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again."), + position: 'right', + padding: 10, + }, + }; + + // Convert to array using enum's numeric ordering + const steps = Object.values(stepsConfig); + + const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: { + setCurrentStep: (value: number | ((prev: number) => number)) => void; + currentStep: number; + steps?: StepType[]; + setIsOpen: (value: boolean) => void; + }) => { + if (steps && currentStep === steps.length - 1) { + setIsOpen(false); + restoreWorkbenchState(); + completeTour(); + } else if (steps) { + setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); + } + }; + + const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => { + setIsOpen(false); + restoreWorkbenchState(); + completeTour(); + }; + + return ( + <> + { + setShowWelcomeModal(false); + startTour(); + }} + onMaybeLater={() => { + setShowWelcomeModal(false); + }} + onDontShowAgain={() => { + setShowWelcomeModal(false); + completeTour(); + }} + /> + { + e.stopPropagation(); + advanceTour(clickProps); + }} + keyboardHandler={(e, clickProps, status) => { + // Handle right arrow key to advance tour + if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) { + e.preventDefault(); + advanceTour(clickProps); + } + // Handle escape key to close tour + else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) { + e.preventDefault(); + handleCloseTour(clickProps); + } + }} + styles={{ + popover: (base) => ({ + ...base, + backgroundColor: 'var(--mantine-color-body)', + color: 'var(--mantine-color-text)', + borderRadius: '8px', + padding: '20px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + maxWidth: '400px', + }), + maskArea: (base) => ({ + ...base, + rx: 8, + }), + badge: (base) => ({ + ...base, + backgroundColor: 'var(--mantine-primary-color-filled)', + }), + controls: (base) => ({ + ...base, + justifyContent: 'center', + }), + }} + highlightedMaskClassName="tour-highlight-glow" + showNavigation={true} + showBadge={false} + showCloseButton={true} + disableInteraction={true} + disableDotsNavigation={true} + prevButton={() => null} + nextButton={({ currentStep, stepsLength, setCurrentStep, setIsOpen }) => { + const isLast = currentStep === stepsLength - 1; + + return ( + { + advanceTour({ setCurrentStep, currentStep, steps, setIsOpen }); + }} + variant="subtle" + size="lg" + aria-label={isLast ? t('onboarding.finish', 'Finish') : t('onboarding.next', 'Next')} + > + {isLast ? : } + + ); + }} + components={{ + Close: ({ onClick }) => ( + + ), + Content: ({ content } : {content: string}) => ( +
+ ), + }} + > + + + + ); +} diff --git a/frontend/src/components/onboarding/TourWelcomeModal.tsx b/frontend/src/components/onboarding/TourWelcomeModal.tsx new file mode 100644 index 000000000..82aeaf26a --- /dev/null +++ b/frontend/src/components/onboarding/TourWelcomeModal.tsx @@ -0,0 +1,82 @@ +import { Modal, Title, Text, Button, Stack, Group } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '../../styles/zIndex'; + +interface TourWelcomeModalProps { + opened: boolean; + onStartTour: () => void; + onMaybeLater: () => void; + onDontShowAgain: () => void; +} + +export default function TourWelcomeModal({ + opened, + onStartTour, + onMaybeLater, + onDontShowAgain, +}: TourWelcomeModalProps) { + const { t } = useTranslation(); + + return ( + + + + + {t('onboarding.welcomeModal.title', 'Welcome to Stirling PDF!')} + + + {t('onboarding.welcomeModal.description', + "Would you like to take a quick 1-minute tour to learn the key features and how to get started?" + )} + + Help button in the bottom left.' + ) + }} + /> + + + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/shared/AppConfigModal.tsx b/frontend/src/components/shared/AppConfigModal.tsx index 160547180..eb7125b72 100644 --- a/frontend/src/components/shared/AppConfigModal.tsx +++ b/frontend/src/components/shared/AppConfigModal.tsx @@ -155,4 +155,4 @@ const AppConfigModal: React.FC = ({ opened, onClose }) => { ); }; -export default AppConfigModal; \ No newline at end of file +export default AppConfigModal; diff --git a/frontend/src/components/shared/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx index 173cfa404..50d43dc06 100644 --- a/frontend/src/components/shared/FileCard.tsx +++ b/frontend/src/components/shared/FileCard.tsx @@ -51,6 +51,7 @@ const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isS onMouseLeave={() => setIsHovered(false)} onClick={onSelect} data-testid="file-card" + data-tour="file-card-checkbox" > ((_, ref) => { const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow(); const { getToolNavigation } = useSidebarNavigation(); const { config } = useAppConfig(); + const { startTour } = useOnboarding(); const [configModalOpen, setConfigModalOpen] = useState(false); const [activeButton, setActiveButton] = useState('tools'); const scrollableRef = useRef(null); @@ -60,7 +62,12 @@ const QuickAccessBar = forwardRef((_, ref) => { // Render navigation button with conditional URL support return ( -
+
((_, ref) => { ); }; - - const buttonConfigs: ButtonConfig[] = [ + const mainButtons: ButtonConfig[] = [ { id: 'read', name: t("quickAccess.read", "Read"), @@ -131,6 +137,9 @@ const QuickAccessBar = forwardRef((_, ref) => { } } }, + ]; + + const middleButtons: ButtonConfig[] = [ { id: 'files', name: t("quickAccess.files", "Files"), @@ -150,6 +159,20 @@ const QuickAccessBar = forwardRef((_, ref) => { // type: 'navigation', // onClick: () => setActiveButton('activity') //}, + ]; + + const bottomButtons: ButtonConfig[] = [ + { + id: 'help', + name: t("quickAccess.help", "Help"), + icon: , + isRound: true, + size: 'lg', + type: 'action', + onClick: () => { + startTour(); + }, + }, { id: 'config', name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"), @@ -162,8 +185,6 @@ const QuickAccessBar = forwardRef((_, ref) => { } ]; - - return (
((_, ref) => { }} >
- {/* Top section with main buttons */} + {/* Main navigation section */} - {buttonConfigs.slice(0, -1).map((config, index) => ( + {mainButtons.map((config, index) => ( {renderNavButton(config, index)} - - {/* Add divider after Automate button (index 1) and Files button (index 2) */} - {index === 1 && ( - - )} ))} - {/* Spacer to push Config button to bottom */} + {/* Divider after main buttons */} + + + {/* Middle section */} + + {middleButtons.map((config, index) => ( + + {renderNavButton(config, index)} + + ))} + + + {/* Spacer to push bottom buttons to bottom */}
- {/* Config button at the bottom */} - {buttonConfigs - .filter(config => config.id === 'config') - .map(config => ( -
- - - {config.icon} - - - - {config.name} - -
+ {/* Bottom section */} + + {bottomButtons.map((config, index) => ( + + {renderNavButton(config, index)} + ))} +
diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index c8168f373..149aa9eaa 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -168,7 +168,7 @@ export default function RightRail() {
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => ( -
+
{sectionButtons.map((btn, index) => { const content = renderButton(btn); if (!content) return null; @@ -186,7 +186,7 @@ export default function RightRail() { ))} -
+
{renderWithTooltip( view.data != null) .map((view) => ({ label: ( @@ -169,6 +169,7 @@ const TopControls = ({
void }>, - onLogoutClick: () => void + onLogoutClick: () => void, ): ConfigNavSection[] => { const sections: ConfigNavSection[] = [ { @@ -61,4 +61,4 @@ export const createConfigNavSections = ( ]; return sections; -}; \ No newline at end of file +}; diff --git a/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css b/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css index 05f226417..e2c261b52 100644 --- a/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css +++ b/frontend/src/components/shared/quickAccessBar/QuickAccessBar.css @@ -145,6 +145,7 @@ .content-divider { width: 3.75rem; border-color: var(--color-gray-300); + margin: 1rem 0; } /* Spacer */ diff --git a/frontend/src/components/tools/FullscreenToolSurface.tsx b/frontend/src/components/tools/FullscreenToolSurface.tsx index 69b3c205d..05cf47571 100644 --- a/frontend/src/components/tools/FullscreenToolSurface.tsx +++ b/frontend/src/components/tools/FullscreenToolSurface.tsx @@ -94,6 +94,7 @@ const FullscreenToolSurface = ({ style={style} role="region" aria-label={t('toolPanel.fullscreen.heading', 'All tools (fullscreen view)')} + data-tour="tool-panel" >
{!isMobile && leftPanelView === 'toolPicker' && ( - = ({ border: `2px solid ${theme.other.crop.overlayBorder}`, backgroundColor: theme.other.crop.overlayBackground, cursor: 'move', - pointerEvents: 'auto' + pointerEvents: 'auto', + transition: (isDragging || isResizing) ? undefined : 'all 1s ease-in-out' }} onMouseDown={handleOverlayMouseDown} > diff --git a/frontend/src/components/tools/crop/CropSettings.tsx b/frontend/src/components/tools/crop/CropSettings.tsx index 624ab3b88..9643b39ae 100644 --- a/frontend/src/components/tools/crop/CropSettings.tsx +++ b/frontend/src/components/tools/crop/CropSettings.tsx @@ -93,6 +93,19 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { loadPDFDimensions(); }, [selectedStub, selectedFile, parameters]); + // Listen for tour events to set crop area + useEffect(() => { + const handleSetCropArea = (event: Event) => { + const customEvent = event as CustomEvent; + if (customEvent.detail && pdfBounds) { + parameters.setCropArea(customEvent.detail, pdfBounds); + } + }; + + window.addEventListener('tour:setCropArea', handleSetCropArea); + return () => window.removeEventListener('tour:setCropArea', handleSetCropArea); + }, [parameters, pdfBounds]); + // Current crop area const cropArea = parameters.getCropArea(); @@ -137,7 +150,7 @@ const CropSettings = ({ parameters, disabled = false }: CropSettingsProps) => { const isFullCrop = parameters.isFullPDFCrop(pdfBounds); return ( - + {/* PDF Preview with Crop Selector */} diff --git a/frontend/src/components/tools/fullscreen/CompactToolItem.tsx b/frontend/src/components/tools/fullscreen/CompactToolItem.tsx index 896d70439..31323ee81 100644 --- a/frontend/src/components/tools/fullscreen/CompactToolItem.tsx +++ b/frontend/src/components/tools/fullscreen/CompactToolItem.tsx @@ -42,6 +42,7 @@ const CompactToolItem: React.FC = ({ id, tool, isSelected, onClick={onClick} aria-disabled={disabled} disabled={disabled} + data-tour={`tool-button-${id}`} > {tool.icon ? ( = ({ id, tool, isSelecte onClick={onClick} aria-disabled={disabled} disabled={disabled} + data-tour={`tool-button-${id}`} > {tool.icon ? ( { const { t } = useTranslation(); @@ -43,6 +45,7 @@ const OperationButton = ({ variant={variant} color={color} data-testid={dataTestId} + data-tour={dataTour} style={{ minHeight: '2.5rem' }} > {isLoading diff --git a/frontend/src/components/tools/shared/createToolFlow.tsx b/frontend/src/components/tools/shared/createToolFlow.tsx index ffa4b0db6..53d74c47b 100644 --- a/frontend/src/components/tools/shared/createToolFlow.tsx +++ b/frontend/src/components/tools/shared/createToolFlow.tsx @@ -105,6 +105,7 @@ export function createToolFlow(config: ToolFlowConfig) { loadingText={config.executeButton.loadingText} submitText={config.executeButton.text} data-testid={config.executeButton.testId} + data-tour="run-button" /> )} diff --git a/frontend/src/components/tools/toolPicker/ToolButton.tsx b/frontend/src/components/tools/toolPicker/ToolButton.tsx index 72affa2d0..fc3963128 100644 --- a/frontend/src/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/components/tools/toolPicker/ToolButton.tsx @@ -112,10 +112,11 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, fullWidth justify="flex-start" className="tool-button" - styles={{ - root: { - borderRadius: 0, - color: "var(--tools-text-and-icon-color)", + data-tour={`tool-button-${id}`} + styles={{ + root: { + borderRadius: 0, + color: "var(--tools-text-and-icon-color)", overflow: 'visible' }, label: { overflow: 'visible' } @@ -137,10 +138,11 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, fullWidth justify="flex-start" className="tool-button" - styles={{ - root: { - borderRadius: 0, - color: "var(--tools-text-and-icon-color)", + data-tour={`tool-button-${id}`} + styles={{ + root: { + borderRadius: 0, + color: "var(--tools-text-and-icon-color)", overflow: 'visible' }, label: { overflow: 'visible' } @@ -159,14 +161,15 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, justify="flex-start" className="tool-button" aria-disabled={isUnavailable} + data-tour={`tool-button-${id}`} styles={{ - root: { - borderRadius: 0, - color: "var(--tools-text-and-icon-color)", - cursor: isUnavailable ? 'not-allowed' : undefined, + root: { + borderRadius: 0, + color: "var(--tools-text-and-icon-color)", + cursor: isUnavailable ? 'not-allowed' : undefined, overflow: 'visible' - }, - label: { overflow: 'visible' } + }, + label: { overflow: 'visible' } }} > {buttonContent} diff --git a/frontend/src/contexts/OnboardingContext.tsx b/frontend/src/contexts/OnboardingContext.tsx new file mode 100644 index 000000000..78a3461c1 --- /dev/null +++ b/frontend/src/contexts/OnboardingContext.tsx @@ -0,0 +1,80 @@ +import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'; +import { usePreferences } from './PreferencesContext'; +import { useMediaQuery } from '@mantine/hooks'; + +interface OnboardingContextValue { + isOpen: boolean; + currentStep: number; + setCurrentStep: (step: number) => void; + startTour: () => void; + closeTour: () => void; + completeTour: () => void; + resetTour: () => void; + showWelcomeModal: boolean; + setShowWelcomeModal: (show: boolean) => void; +} + +const OnboardingContext = createContext(undefined); + +export const OnboardingProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { preferences, updatePreference } = usePreferences(); + const [isOpen, setIsOpen] = useState(false); + const [currentStep, setCurrentStep] = useState(0); + const [showWelcomeModal, setShowWelcomeModal] = useState(false); + const isMobile = useMediaQuery("(max-width: 1024px)"); + + // Auto-show welcome modal for first-time users after preferences load + // Only show after user has seen the tool panel mode prompt + // Also, don't show tour on mobile devices because it feels clunky + useEffect(() => { + if (!preferences.hasCompletedOnboarding && preferences.toolPanelModePromptSeen && !isMobile) { + setShowWelcomeModal(true); + } + }, [preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, isMobile]); + + const startTour = useCallback(() => { + setCurrentStep(0); + setIsOpen(true); + }, []); + + const closeTour = useCallback(() => { + setIsOpen(false); + }, []); + + const completeTour = useCallback(() => { + setIsOpen(false); + updatePreference('hasCompletedOnboarding', true); + }, [updatePreference]); + + const resetTour = useCallback(() => { + updatePreference('hasCompletedOnboarding', false); + setCurrentStep(0); + setIsOpen(true); + }, [updatePreference]); + + return ( + + {children} + + ); +}; + +export const useOnboarding = (): OnboardingContextValue => { + const context = useContext(OnboardingContext); + if (!context) { + throw new Error('useOnboarding must be used within an OnboardingProvider'); + } + return context; +}; diff --git a/frontend/src/contexts/TourOrchestrationContext.tsx b/frontend/src/contexts/TourOrchestrationContext.tsx new file mode 100644 index 000000000..8c6c0c05b --- /dev/null +++ b/frontend/src/contexts/TourOrchestrationContext.tsx @@ -0,0 +1,207 @@ +import React, { createContext, useContext, useCallback, useRef } from 'react'; +import { useFileHandler } from '../hooks/useFileHandler'; +import { useFilesModalContext } from './FilesModalContext'; +import { useNavigationActions } from './NavigationContext'; +import { useToolWorkflow } from './ToolWorkflowContext'; +import { useAllFiles, useFileManagement } from './FileContext'; +import { StirlingFile } from '../types/fileContext'; +import { fileStorage } from '../services/fileStorage'; + +interface TourOrchestrationContextType { + // State management + saveWorkbenchState: () => void; + restoreWorkbenchState: () => Promise; + + // Tool deselection + backToAllTools: () => void; + + // Tool selection + selectCropTool: () => void; + + // File operations + loadSampleFile: () => Promise; + + // View switching + switchToViewer: () => void; + switchToPageEditor: () => void; + switchToActiveFiles: () => void; + + // File operations + selectFirstFile: () => void; + pinFile: () => void; + + // Crop settings (placeholder for now) + modifyCropSettings: () => void; + + // Tool execution + executeTool: () => void; +} + +const TourOrchestrationContext = createContext(undefined); + +export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const { addFiles } = useFileHandler(); + const { closeFilesModal } = useFilesModalContext(); + const { actions: navActions } = useNavigationActions(); + const { handleToolSelect, handleBackToTools } = useToolWorkflow(); + const { files } = useAllFiles(); + const { clearAllFiles } = useFileManagement(); + + // Store the user's files before tour starts + const savedFilesRef = useRef([]); + + // Keep a ref to always have the latest files + const filesRef = useRef(files); + React.useEffect(() => { + filesRef.current = files; + }, [files]); + + const saveWorkbenchState = useCallback(() => { + // Get fresh files from ref + const currentFiles = filesRef.current; + console.log('Saving workbench state, files count:', currentFiles.length); + savedFilesRef.current = [...currentFiles]; + // Clear all files for clean demo + clearAllFiles(); + }, [clearAllFiles]); + + const restoreWorkbenchState = useCallback(async () => { + console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length); + + // Go back to All Tools + handleBackToTools(); + + // Clear all files (including tour sample) + clearAllFiles(); + + // Delete all active files from storage (they're just the ones from the tour) + const currentFiles = filesRef.current; + if (currentFiles.length > 0) { + try { + await Promise.all(currentFiles.map(file => fileStorage.deleteStirlingFile(file.fileId))); + console.log(`Deleted ${currentFiles.length} file(s) from storage`); + } catch (error) { + console.error('Failed to delete files from storage:', error); + } + } + + // Restore saved files + if (savedFilesRef.current.length > 0) { + // Create fresh File objects from StirlingFile to avoid ID conflicts + const filesToRestore = await Promise.all( + savedFilesRef.current.map(async (sf) => { + const buffer = await sf.arrayBuffer(); + return new File([buffer], sf.name, { type: sf.type, lastModified: sf.lastModified }); + }) + ); + console.log('Restoring files:', filesToRestore.map(f => f.name)); + await addFiles(filesToRestore); + savedFilesRef.current = []; + } + }, [clearAllFiles, addFiles, handleBackToTools]); + + const backToAllTools = useCallback(() => { + handleBackToTools(); + }, [handleBackToTools]); + + const selectCropTool = useCallback(() => { + handleToolSelect('crop'); + }, [handleToolSelect]); + + const loadSampleFile = useCallback(async () => { + try { + const response = await fetch('/samples/Sample.pdf'); + const blob = await response.blob(); + const file = new File([blob], 'Sample.pdf', { type: 'application/pdf' }); + + await addFiles([file]); + closeFilesModal(); + } catch (error) { + console.error('Failed to load sample file:', error); + } + }, [addFiles, closeFilesModal]); + + const switchToViewer = useCallback(() => { + navActions.setWorkbench('viewer'); + }, [navActions]); + + const switchToPageEditor = useCallback(() => { + navActions.setWorkbench('pageEditor'); + }, [navActions]); + + const switchToActiveFiles = useCallback(() => { + navActions.setWorkbench('fileEditor'); + }, [navActions]); + + const selectFirstFile = useCallback(() => { + // File selection is handled by FileCard onClick + // This function could trigger a click event on the first file card + const firstFileCard = document.querySelector('[data-tour="file-card-checkbox"]') as HTMLElement; + if (firstFileCard) { + // Check if already selected (data-selected attribute) + const isSelected = firstFileCard.getAttribute('data-selected') === 'true'; + // Only click if not already selected (to avoid toggling off) + if (!isSelected) { + firstFileCard.click(); + } + } + }, []); + + const pinFile = useCallback(() => { + // Click the pin button directly + const pinButton = document.querySelector('[data-tour="file-card-pin"]') as HTMLElement; + if (pinButton) { + pinButton.click(); + } + }, []); + + const modifyCropSettings = useCallback(() => { + // Dispatch a custom event to modify crop settings + const event = new CustomEvent('tour:setCropArea', { + detail: { + x: 80, + y: 435, + width: 440, + height: 170, + } + }); + window.dispatchEvent(event); + }, []); + + const executeTool = useCallback(() => { + // Trigger the run button click + const runButton = document.querySelector('[data-tour="run-button"]') as HTMLElement; + if (runButton) { + runButton.click(); + } + }, []); + + const value: TourOrchestrationContextType = { + saveWorkbenchState, + restoreWorkbenchState, + backToAllTools, + selectCropTool, + loadSampleFile, + switchToViewer, + switchToPageEditor, + switchToActiveFiles, + selectFirstFile, + pinFile, + modifyCropSettings, + executeTool, + }; + + return ( + + {children} + + ); +}; + +export const useTourOrchestration = (): TourOrchestrationContextType => { + const context = useContext(TourOrchestrationContext); + if (!context) { + throw new Error('useTourOrchestration must be used within TourOrchestrationProvider'); + } + return context; +}; diff --git a/frontend/src/services/preferencesService.ts b/frontend/src/services/preferencesService.ts index 5a8ff6286..da5e4350b 100644 --- a/frontend/src/services/preferencesService.ts +++ b/frontend/src/services/preferencesService.ts @@ -8,6 +8,7 @@ export interface UserPreferences { theme: ThemeMode; toolPanelModePromptSeen: boolean; showLegacyToolDescriptions: boolean; + hasCompletedOnboarding: boolean; } export const DEFAULT_PREFERENCES: UserPreferences = { @@ -17,6 +18,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = { theme: getSystemTheme(), toolPanelModePromptSeen: false, showLegacyToolDescriptions: false, + hasCompletedOnboarding: false, }; const STORAGE_KEY = 'stirlingpdf_preferences'; diff --git a/frontend/src/tools/Crop.tsx b/frontend/src/tools/Crop.tsx index d185e3877..4cf6e6b6e 100644 --- a/frontend/src/tools/Crop.tsx +++ b/frontend/src/tools/Crop.tsx @@ -28,7 +28,7 @@ const Crop = (props: BaseToolProps) => { steps: [ { title: t("crop.steps.selectArea", "Select Crop Area"), - isCollapsed: !base.hasFiles, // Collapsed until files selected + isCollapsed: base.settingsCollapsed, onCollapsedClick: base.hasResults ? base.handleSettingsReset : undefined, tooltip: tooltips, content: (