Prettier 2: Electric Boogaloo (#6113)

# Description of Changes
When I added Prettier formatting in #6052, my aim was to use just the
default settings in Prettier. Turns out, Prettier looks _really hard_
for any config files if it's not explicitly given one, which means that
if a developer has some sort of Prettier config file lying around on
their system, Prettier might find it and use it. Also, Prettier changes
its defaults based on stuff in `.editorconfig` without any good way of
disabling that behaviour explicitly in its config file.

To solve both of these issues, I've introduced a `.prettierrc` file
which sets Prettier's defaults explicitly, and then reformatted all our
code _again_ in Prettier's actual default settings. This should achieve
the aim of #6052 and remove the possibility for it breaking on different
dev computers.
This commit is contained in:
James Brunton
2026-04-17 09:50:16 +00:00
committed by GitHub
parent de8c483054
commit 8ab060a4be
1207 changed files with 52646 additions and 15352 deletions
+1
View File
@@ -4,6 +4,7 @@ public/vendor/
public/pdfjs*/
public/js/thirdParty/
public/css/cookieconsent.css
src-tauri/target/
*.min.*
*.md
*.wxs
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"endOfLine": "lf"
}
+6 -2
View File
@@ -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.",
},
],
},
+4 -1
View File
@@ -6,7 +6,10 @@
<link rel="icon" href="modern-logo/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="The Free Adobe Acrobat alternative (10M+ Downloads)" />
<meta
name="description"
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
/>
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
<link rel="manifest" href="manifest.json" />
+24 -4
View File
@@ -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}`);
}
+54 -17
View File
@@ -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: <LocalIcon icon="icon-name" ...>
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
const localIconMatches = content.match(
/<LocalIcon\s+[^>]*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: <LocalIcon icon='icon-name' ...>
const localIconSingleQuoteMatches = content.match(/<LocalIcon\s+[^>]*icon='([^']+)'/g);
const localIconSingleQuoteMatches = content.match(
/<LocalIcon\s+[^>]*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: <span className="material-symbols-rounded">icon-name</span>
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
const spanMatches = content.match(
/<span[^>]*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: <Icon icon="material-symbols:icon-name" ...>
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
const iconMatches = content.match(
/<Icon\s+[^>]*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
+97 -28
View File
@@ -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({
+2 -2
View File
@@ -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;
}
* {
+104 -23
View File
@@ -10,15 +10,39 @@
<!-- Page 1: Hero / Cover Page -->
<div class="page page-1">
<div class="decorative-shapes">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="" />
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="" />
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
class="shape shape-1"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg"
class="shape shape-2"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
class="shape shape-3"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg"
class="shape shape-4"
alt=""
/>
<img
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
class="shape shape-5"
alt=""
/>
</div>
<div class="hero-content">
<div class="logo-container">
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo" />
<img
src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg"
alt="Stirling PDF"
class="hero-logo"
/>
</div>
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
<div class="hero-stats">
@@ -40,21 +64,31 @@
<div class="content-wrapper">
<h2 class="page-title">What is Stirling PDF?</h2>
<p class="intro-text">
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.
</p>
<div class="value-props">
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
/>
</svg>
</div>
<h3>50+ PDF Operations</h3>
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
<p>
Comprehensive toolkit covering all your PDF needs. From basic
operations to advanced processing.
</p>
</div>
<div class="value-prop">
@@ -66,24 +100,42 @@
</svg>
</div>
<h3>Workflow Automation</h3>
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
<p>
Chain multiple operations together and save them as reusable
workflows. Perfect for recurring tasks.
</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
</svg>
</div>
<h3>Multi-Language Support</h3>
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
<p>
Available in over 30 languages with community-contributed
translations. Accessible to users worldwide.
</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="5" y="11" width="14" height="10" rx="2" />
<circle cx="12" cy="16" r="1" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
@@ -91,30 +143,47 @@
</div>
<h3>Privacy First</h3>
<p>
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.
</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<h3>Open Source</h3>
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
<p>
Transparent, community-driven development. Inspect the code,
contribute features, and adapt as needed.
</p>
</div>
<div class="value-prop">
<div class="value-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
</div>
<h3>API Access</h3>
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
<p>
RESTful API for integration with external tools and scripts.
Automate PDF operations programmatically.
</p>
</div>
</div>
</div>
@@ -129,8 +198,15 @@
<div class="feature-card" data-category="general">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
/>
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
@@ -151,7 +227,12 @@
<div class="feature-card" data-category="security">
<div class="feature-header">
<div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
+11 -4
View File
@@ -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" +
+3 -1
View File
@@ -21,7 +21,9 @@ export function AppLayout({ children }: AppLayoutProps) {
height: 100% !important;
}
`}</style>
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<div
style={{ height: "100vh", display: "flex", flexDirection: "column" }}
>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
</div>
+26 -7
View File
@@ -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<AppConfigProviderProps, "children" | "retryOptions">;
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 (
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<BannerProvider>
<AppConfigProvider retryOptions={appConfigRetryOptions} {...appConfigProviderProps}>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<AppConfigLoader />
<ServerDefaultsSync />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<FileContextProvider
enableUrlSync={true}
enablePersistence={true}
>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
@@ -124,7 +141,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<AnnotationProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>{children}</AdminTourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</AnnotationProvider>
+19 -4
View File
@@ -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<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
const {
isFilesModalOpen,
closeFilesModal,
onFileUpload,
onRecentFileSelect,
} = useFilesModalContext();
const { config } = useAppConfig();
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
const [isDragging, setIsDragging] = useState(false);
@@ -101,7 +109,9 @@ const FileManager: React.FC<FileManagerProps> = ({ 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<FileManagerProps> = ({ 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(() => {
@@ -14,7 +14,12 @@ interface StorageStatsCardProps {
onReloadFiles: () => void;
}
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => {
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
storageStats,
filesCount,
onClearAll,
onReloadFiles,
}) => {
const { t } = useTranslation();
if (!storageStats) return null;
@@ -27,19 +32,27 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, files
<StorageIcon />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("fileManager.storage", "Storage")}: {formatFileSize(storageStats.used)}
{t("fileManager.storage", "Storage")}:{" "}
{formatFileSize(storageStats.used)}
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`}
</Text>
{storageStats.quota && (
<Progress
value={storageUsagePercent}
color={storageUsagePercent > 80 ? "red" : storageUsagePercent > 60 ? "yellow" : "blue"}
color={
storageUsagePercent > 80
? "red"
: storageUsagePercent > 60
? "yellow"
: "blue"
}
size="sm"
mt={4}
/>
)}
<Text size="xs" c="dimmed">
{storageStats.fileCount} {t("fileManager.filesStored", "files stored")}
{storageStats.fileCount}{" "}
{t("fileManager.filesStored", "files stored")}
</Text>
</div>
<Group gap="xs">
@@ -54,7 +67,12 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, files
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button variant="light" color="blue" size="xs" onClick={onReloadFiles}>
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
Reload Files
</Button>
</Group>
@@ -26,7 +26,9 @@ interface PDFAnnotationContextValue {
setSignatureConfig: (config: any | null) => void;
}
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
const PDFAnnotationContext = createContext<
PDFAnnotationContextValue | undefined
>(undefined);
interface PDFAnnotationProviderProps {
children: ReactNode;
@@ -75,13 +77,19 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
setSignatureConfig,
};
return <PDFAnnotationContext.Provider value={contextValue}>{children}</PDFAnnotationContext.Provider>;
return (
<PDFAnnotationContext.Provider value={contextValue}>
{children}
</PDFAnnotationContext.Provider>
);
};
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;
};
@@ -34,7 +34,10 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
const [selectedColor, setSelectedColor] = useState("#000000");
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(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<BaseAnnotationToolProps> = ({
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<BaseAnnotationToolProps> = ({
})}
{/* Instructions for placing signature */}
<Alert color="blue" title={t("sign.instructions.title", "How to add signature")}>
<Text size="sm">Click anywhere on the PDF to place your annotation.</Text>
<Alert
color="blue"
title={t("sign.instructions.title", "How to add signature")}
>
<Text size="sm">
Click anywhere on the PDF to place your annotation.
</Text>
</Alert>
{/* Color Picker Modal */}
@@ -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 (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow withinPortal>
<Popover
opened={opened}
onChange={setOpened}
position="bottom"
withArrow
withinPortal
>
<Popover.Target>
<Tooltip label={label}>
<ActionIcon
@@ -1,5 +1,14 @@
import React from "react";
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from "@mantine/core";
import {
Modal,
Stack,
ColorPicker as MantineColorPicker,
Group,
Button,
ColorSwatch,
Slider,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
interface ColorPickerProps {
@@ -27,16 +36,30 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
}) => {
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 (
<Modal opened={isOpen} onClose={onClose} title={resolvedTitle} size="sm" centered>
<Modal
opened={isOpen}
onClose={onClose}
title={resolvedTitle}
size="sm"
centered
>
<Stack gap="md">
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={onColorChange}
swatches={["#000000", "#0066cc", "#cc0000", "#cc6600", "#009900", "#6600cc"]}
swatches={[
"#000000",
"#0066cc",
"#cc0000",
"#cc6600",
"#009900",
"#6600cc",
]}
swatchesPerRow={6}
size="lg"
fullWidth
@@ -74,6 +97,18 @@ interface ColorSwatchButtonProps {
size?: number;
}
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({ color, onClick, size = 24 }) => {
return <ColorSwatch color={color} size={size} radius={0} style={{ cursor: "pointer" }} onClick={onClick} />;
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
color,
onClick,
size = 24,
}) => {
return (
<ColorSwatch
color={color}
size={size}
radius={0}
style={{ cursor: "pointer" }}
onClick={onClick}
/>
);
};
@@ -47,7 +47,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(null);
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(
null,
);
const initPad = (canvas: HTMLCanvasElement) => {
if (!padRef.current) {
@@ -125,7 +127,17 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
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<DrawingCanvasProps> = ({
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<DrawingCanvasProps> = ({
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<DrawingCanvasProps> = ({
<Paper withBorder p="md">
<Stack gap="sm">
<PrivateContent>
<Text fw={500}>{t("sign.canvas.heading", "Draw your signature")}</Text>
<Text fw={500}>
{t("sign.canvas.heading", "Draw your signature")}
</Text>
<canvas
ref={previewCanvasRef}
width={width}
@@ -264,7 +286,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<Text size="sm" fw={500}>
{t("sign.canvas.colorLabel", "Colour")}
</Text>
<ColorSwatchButton color={selectedColor} onClick={onColorSwatchClick} />
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</Stack>
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
@@ -44,7 +44,12 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
disabled={undoDisabled}
color={undoDisabled ? "gray" : "blue"}
>
<LocalIcon icon="undo" width={20} height={20} style={{ color: "currentColor" }} />
<LocalIcon
icon="undo"
width={20}
height={20}
style={{ color: "currentColor" }}
/>
</ActionIcon>
</Tooltip>
)}
@@ -58,7 +63,12 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
disabled={redoDisabled}
color={redoDisabled ? "gray" : "blue"}
>
<LocalIcon icon="redo" width={20} height={20} style={{ color: "currentColor" }} />
<LocalIcon
icon="redo"
width={20}
height={20}
style={{ color: "currentColor" }}
/>
</ActionIcon>
</Tooltip>
)}
@@ -67,7 +77,13 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
<Button variant="filled" color="blue" onClick={onPlaceSignature} disabled={disabled || !hasSignatureData} ml="auto">
<Button
variant="filled"
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
ml="auto"
>
{placeButtonText}
</Button>
)}
@@ -27,22 +27,33 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
const { t } = useTranslation();
const [removeBackground, setRemoveBackground] = useState(false);
const [currentFile, setCurrentFile] = useState<File | null>(null);
const [originalImageData, setOriginalImageData] = useState<string | null>(null);
const [originalImageData, setOriginalImageData] = useState<string | null>(
null,
);
const [isProcessing, setIsProcessing] = useState(false);
const processImage = async (imageSource: File | string, shouldRemoveBackground: boolean): Promise<void> => {
const processImage = async (
imageSource: File | string,
shouldRemoveBackground: boolean,
): Promise<void> => {
if (shouldRemoveBackground && allowBackgroundRemoval) {
setIsProcessing(true);
try {
const transparentImageDataUrl = await removeWhiteBackground(imageSource, {
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<ImageUploaderProps> = ({
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<ImageUploaderProps> = ({
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<ImageUploaderProps> = ({
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<ImageUploaderProps> = ({
}
// 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<ImageUploaderProps> = ({
// 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<ImageUploaderProps> = ({
<PrivateContent>
<FileInput
label={label}
placeholder={placeholder || t("sign.image.placeholder", "Select image file")}
placeholder={
placeholder || t("sign.image.placeholder", "Select image file")
}
accept="image/*,.svg"
onChange={handleImageChange}
disabled={disabled || isProcessing}
@@ -247,9 +275,14 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
</PrivateContent>
{allowBackgroundRemoval && (
<Checkbox
label={t("sign.image.removeBackground", "Remove white background (make transparent)")}
label={t(
"sign.image.removeBackground",
"Remove white background (make transparent)",
)}
checked={removeBackground}
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)}
onChange={(event) =>
handleBackgroundRemovalChange(event.currentTarget.checked)
}
disabled={disabled || !currentFile || isProcessing}
/>
)}
@@ -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
<Text size="xs" fw={500}>
{t("annotation.opacity", "Opacity")}
</Text>
<Slider value={value} onChange={onChange} min={10} max={100} label={(val) => `${val}%`} />
<Slider
value={value}
onChange={onChange}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</Stack>
</Popover.Dropdown>
</Popover>
@@ -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")}
</Button>
</Group>
</div>
@@ -207,7 +225,8 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
{(annotationType === "text" || annotationType === "note") && renderTextNoteControls()}
{(annotationType === "text" || annotationType === "note") &&
renderTextNoteControls()}
{annotationType === "shape" && renderShapeControls()}
</Popover.Dropdown>
</Popover>
@@ -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";
@@ -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
<Text size="xs" fw={500}>
{t("annotation.width", "Width")}
</Text>
<Slider value={value} onChange={onChange} min={min} max={max} label={(val) => `${val}pt`} />
<Slider
value={value}
onChange={onChange}
min={min}
max={max}
label={(val) => `${val}pt`}
/>
</Stack>
</Popover.Dropdown>
</Popover>
@@ -8,7 +8,10 @@ interface DrawingToolProps {
disabled?: boolean;
}
export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disabled = false }) => {
export const DrawingTool: React.FC<DrawingToolProps> = ({
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<DrawingToolProps> = ({ onDrawingChange, disab
};
return (
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onDrawingChange} disabled={disabled}>
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onDrawingChange}
disabled={disabled}
>
<Stack gap="sm">
<DrawingCanvas
selectedColor={selectedColor}
@@ -8,7 +8,10 @@ interface ImageToolProps {
disabled?: boolean;
}
export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled = false }) => {
export const ImageTool: React.FC<ImageToolProps> = ({
onImageChange,
disabled = false,
}) => {
const [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => {
@@ -45,7 +48,11 @@ export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled =
};
return (
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onImageChange} disabled={disabled}>
<BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onImageChange}
disabled={disabled}
>
<Stack gap="sm">
<ImageUploader
onImageChange={handleImageUpload}
@@ -16,7 +16,11 @@ interface AddFileCardProps {
multiple?: boolean;
}
const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps) => {
const AddFileCard = ({
onFileSelect,
accept,
multiple = true,
}: AddFileCardProps) => {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const { openFilesModal } = useFilesModalContext();
@@ -84,7 +88,9 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
<div className={styles.logoMark}>
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
</div>
<div className={styles.headerIndex}>{t("fileEditor.addFiles", "Add Files")}</div>
<div className={styles.headerIndex}>
{t("fileEditor.addFiles", "Add Files")}
</div>
<div className={styles.kebab} />
</div>
@@ -131,8 +137,15 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && <span>{t("landing.addFiles", "Add Files")}</span>}
<LocalIcon
icon="add"
width="1.5rem"
height="1.5rem"
className="text-[var(--accent-interactive)]"
/>
{!isUploadHover && (
<span>{t("landing.addFiles", "Add Files")}</span>
)}
</Button>
<Button
aria-label="Upload"
@@ -160,14 +173,22 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
height="1.25rem"
style={{ color: "var(--accent-interactive)" }}
/>
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
{isUploadHover && (
<span style={{ marginLeft: ".5rem" }}>
{terminology.uploadFromComputer}
</span>
)}
</Button>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: ".8rem", textAlign: "center", marginTop: "0.5rem" }}
style={{
fontSize: ".8rem",
textAlign: "center",
marginTop: "0.5rem",
}}
>
{terminology.dropFilesHere}
</span>
@@ -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<string | null>(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<FileId[]>([]);
@@ -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
</Text>
<Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed">
Upload PDF files, ZIP archives, or load from storage to get started
Upload PDF files, ZIP archives, or load from storage to get
started
</Text>
</Stack>
</Center>
@@ -432,7 +501,12 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
}}
>
{/* Add File Card - only show when files exist */}
{activeStirlingFileStubs.length > 0 && <AddFileCard key="add-file-card" onFileSelect={handleFileUpload} />}
{activeStirlingFileStubs.length > 0 && (
<AddFileCard
key="add-file-card"
onFileSelect={handleFileUpload}
/>
)}
{activeStirlingFileStubs.map((record, index) => {
return (
@@ -6,6 +6,8 @@ interface FileEditorFileNameProps {
file: StirlingFileStub;
}
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => <PrivateContent>{file.name}</PrivateContent>;
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
<PrivateContent>{file.name}</PrivateContent>
);
export default FileEditorFileName;
@@ -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 */}
<div className={`${styles.header} ${getHeaderClassName()}`} data-has-error={hasError}>
<div
className={`${styles.header} ${getHeaderClassName()}`}
data-has-error={hasError}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{hasError ? (
@@ -402,16 +469,27 @@ const FileEditorThumbnail = ({
</div>
{/* Centered index */}
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
<div
className={styles.headerIndex}
aria-label={`Position ${index + 1}`}
>
{index + 1}
</div>
{/* Action buttons group */}
<div className={styles.headerActions}>
{isEncrypted && (
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
<Tooltip
label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
>
<ActionIcon
aria-label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}
aria-label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
@@ -426,7 +504,9 @@ const FileEditorThumbnail = ({
{/* Pin/Unpin icon */}
<Tooltip
label={
isPinned ? t("unpin", "Unpin File (replace after tool run)") : t("pin", "Pin File (keep active after tool run)")
isPinned
? t("unpin", "Unpin File (replace after tool run)")
: t("pin", "Pin File (keep active after tool run)")
}
>
<ActionIcon
@@ -443,15 +523,29 @@ const FileEditorThumbnail = ({
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
alert({
alertType: "neutral",
title: `Unpinned ${file.name}`,
expandable: false,
durationMs: 3000,
});
} else {
pinFile(actualFile);
alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
alert({
alertType: "success",
title: `Pinned ${file.name}`,
expandable: false,
durationMs: 3000,
});
}
}
}}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
{isPinned ? (
<PushPinIcon fontSize="small" />
) : (
<PushPinOutlinedIcon fontSize="small" />
)}
</ActionIcon>
</Tooltip>
</div>
@@ -470,7 +564,13 @@ const FileEditorThumbnail = ({
<Text size="lg" fw={700} className={styles.title} title={file.name}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</Text>
<Text size="sm" c="dimmed" className={styles.meta} lineClamp={3} title={`${extUpper || "FILE"}${prettySize}`}>
<Text
size="sm"
c="dimmed"
className={styles.meta}
lineClamp={3}
title={`${extUpper || "FILE"}${prettySize}`}
>
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
{`v${file.versionNumber} - `}
{dateLabel}
@@ -482,7 +582,11 @@ const FileEditorThumbnail = ({
{/* Preview area */}
<div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
style={isSupported || hasError ? undefined : { filter: "grayscale(80%)", opacity: 0.6 }}
style={
isSupported || hasError
? undefined
: { filter: "grayscale(80%)", opacity: 0.6 }
}
>
<div className={styles.previewPaper}>
{file.thumbnailUrl ? (
@@ -513,7 +617,12 @@ const FileEditorThumbnail = ({
/>
</PrivateContent>
) : file.type?.startsWith("application/pdf") ? (
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
<Stack
align="center"
justify="center"
gap="xs"
style={{ height: "100%" }}
>
<Loader size="sm" />
<Text size="xs" c="dimmed">
Loading thumbnail...
@@ -554,7 +663,11 @@ const FileEditorThumbnail = ({
</div>
{/* Hover Menu */}
<HoverActionMenu show={showHoverMenu || isMobile} actions={hoverActions} position="outside" />
<HoverActionMenu
show={showHoverMenu || isMobile}
actions={hoverActions}
position="outside"
/>
{/* Close Confirmation Modal */}
<Modal
@@ -567,7 +680,9 @@ const FileEditorThumbnail = ({
<Stack gap="md">
{file.isDirty && file.localFilePath ? (
<>
<Text size="md">{t("confirmCloseUnsaved", "This file has unsaved changes.")}</Text>
<Text size="md">
{t("confirmCloseUnsaved", "This file has unsaved changes.")}
</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
@@ -575,7 +690,11 @@ const FileEditorThumbnail = ({
<Button variant="light" onClick={handleCancelClose}>
{t("confirmCloseCancel", "Cancel")}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
<Button
variant="filled"
color="red"
onClick={handleConfirmClose}
>
{t("confirmCloseDiscard", "Discard changes and close")}
</Button>
<Button variant="filled" onClick={handleSaveAndClose}>
@@ -585,7 +704,12 @@ const FileEditorThumbnail = ({
</>
) : (
<>
<Text size="md">{t("confirmCloseMessage", "Are you sure you want to close this file?")}</Text>
<Text size="md">
{t(
"confirmCloseMessage",
"Are you sure you want to close this file?",
)}
</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
@@ -593,7 +717,11 @@ const FileEditorThumbnail = ({
<Button variant="light" onClick={handleCancelClose}>
{t("confirmCloseCancel", "Cancel")}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
<Button
variant="filled"
color="red"
onClick={handleConfirmClose}
>
{t("confirmCloseConfirm", "Close File")}
</Button>
</Group>
@@ -623,8 +751,20 @@ const FileEditorThumbnail = ({
</Stack>
</Modal>
{canUpload && <UploadToServerModal opened={showUploadModal} onClose={() => setShowUploadModal(false)} file={file} />}
{canShare && <ShareFileModal opened={showShareModal} onClose={() => setShowShareModal(false)} file={file} />}
{canUpload && (
<UploadToServerModal
opened={showUploadModal}
onClose={() => setShowUploadModal(false)}
file={file}
/>
)}
{canShare && (
<ShareFileModal
opened={showShareModal}
onClose={() => setShowShareModal(false)}
file={file}
/>
)}
</div>
);
};
@@ -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: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
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: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="crop-square-outline"
width="1.5rem"
height="1.5rem"
/>
),
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: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
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);
@@ -35,9 +35,14 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
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 (
<Stack gap="xs" style={{ height: "100%" }}>
@@ -78,7 +83,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
borderRadius: 4,
}}
>
<PictureAsPdfIcon style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }} />
<PictureAsPdfIcon
style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }}
/>
</Center>
) : null}
</Box>
@@ -86,7 +93,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
<PrivateContent>{currentFile ? currentFile.name : "No file selected"}</PrivateContent>
<PrivateContent>
{currentFile ? currentFile.name : "No file selected"}
</PrivateContent>
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ""}
@@ -101,7 +110,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* Compact tool chain for mobile */}
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
<Text size="xs" c="dimmed">
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(" → ")}
{currentFile.toolHistory
.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId))
.join(" → ")}
</Text>
)}
{currentFile && showOwner && (
@@ -114,10 +125,20 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* Navigation arrows for multiple files */}
{hasMultipleFiles && (
<Box style={{ display: "flex", gap: "0.25rem" }}>
<ActionIcon variant="subtle" size="sm" onClick={onPrevious} disabled={isAnimating}>
<ActionIcon
variant="subtle"
size="sm"
onClick={onPrevious}
disabled={isAnimating}
>
<ChevronLeftIcon style={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon variant="subtle" size="sm" onClick={onNext} disabled={isAnimating}>
<ActionIcon
variant="subtle"
size="sm"
onClick={onNext}
disabled={isAnimating}
>
<ChevronRightIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Box>
@@ -131,7 +152,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
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",
}}
>
@@ -12,7 +12,12 @@ const DesktopLayout: React.FC = () => {
const { activeSource, recentFiles, modalHeight } = useFileManagerContext();
return (
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: "nowrap", minWidth: 0 }}>
<Grid
gutter="xs"
h="100%"
grow={false}
style={{ flexWrap: "nowrap", minWidth: 0 }}
>
{/* Column 1: File Sources */}
<Grid.Col
span="content"
@@ -74,9 +79,16 @@ const DesktopLayout: React.FC = () => {
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<FileListArea
scrollAreaHeight={activeSource === "recent" && recentFiles.length > 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,
@@ -32,7 +32,9 @@ const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
}}
>
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: "4rem", color: theme.colors.blue[6] }} />
<UploadFileIcon
style={{ fontSize: "4rem", color: theme.colors.blue[6] }}
/>
<Text size="xl" fw={500} c="blue.6">
{t("fileManager.dropFilesHere", "Drop files here to upload")}
</Text>
@@ -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 */}
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
<HistoryIcon
style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }}
/>
<Text c="dimmed" ta="center" size="lg">
{t("fileManager.noRecentFiles", "No recent files")}
</Text>
@@ -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 && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
{isUploadHover && (
<span style={{ marginLeft: ".5rem" }}>
{terminology.uploadFromComputer}
</span>
)}
</Button>
</div>
{/* Instruction Text */}
<span className="text-[var(--accent-interactive)]" style={{ fontSize: ".8rem", textAlign: "center" }}>
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: ".8rem", textAlign: "center" }}
>
{terminology.dropFilesHere}
</span>
</div>
@@ -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 (
<div
@@ -99,7 +124,11 @@ const FileActions: React.FC = () => {
{/* Left: Select All + Filter */}
<Group gap="xs">
<Tooltip
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
label={
allFilesSelected
? t("fileManager.deselectAll", "Deselect All")
: t("fileManager.selectAll", "Select All")
}
>
<ActionIcon
variant="light"
@@ -116,7 +145,11 @@ const FileActions: React.FC = () => {
<SegmentedControl
size="xs"
value={storageFilter}
onChange={(value) => 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 && (
<Text size="sm" c="dimmed" fw={500}>
{t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })}
{t("fileManager.selectedCount", "{{count}} selected", {
count: selectedFileIds.length,
})}
</Text>
)}
</div>
@@ -18,7 +18,8 @@ const FileDetails: React.FC<FileDetailsProps> = ({ 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<FileDetailsProps> = ({ 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<FileDetailsProps> = ({ 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<FileDetailsProps> = ({ compact = false }) => {
return (
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
{/* Section 1: Thumbnail Preview */}
<Box style={{ width: "100%", height: "min(35vh, 280px)", textAlign: "center", flexShrink: 0 }}>
<Box
style={{
width: "100%",
height: "min(35vh, 280px)",
textAlign: "center",
flexShrink: 0,
}}
>
<FilePreview
file={currentFile}
thumbnail={getCurrentThumbnail()}
@@ -95,7 +107,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ 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",
}}
>
@@ -39,7 +39,8 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
<Box ml="md" mt="xs" mb="sm">
<Group align="center" mb="sm">
<Text size="xs" fw={600} c="dimmed">
{t("fileManager.fileHistory", "File History")} ({sortedHistory.length})
{t("fileManager.fileHistory", "File History")} (
{sortedHistory.length})
</Text>
</Group>
@@ -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<FileInfoCardProps> = ({ currentFile, modalHeight }) => {
const FileInfoCard: React.FC<FileInfoCardProps> = ({
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<FileInfoCardProps> = ({ 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",
}}
>
<Box
bg="gray.4"
@@ -75,9 +102,16 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
<Stack gap="sm">
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">
<PrivateContent>{t("fileManager.fileName", "Name")}</PrivateContent>
<PrivateContent>
{t("fileManager.fileName", "Name")}
</PrivateContent>
</Text>
<Text size="sm" fw={500} style={{ maxWidth: "60%", textAlign: "right" }} truncate>
<Text
size="sm"
fw={500}
style={{ maxWidth: "60%", textAlign: "right" }}
truncate
>
{currentFile ? currentFile.name : ""}
</Text>
</Group>
@@ -112,7 +146,9 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
{t("fileManager.lastModified", "Last modified")}
</Text>
<Text size="sm" fw={500}>
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ""}
{currentFile
? new Date(currentFile.lastModified).toLocaleDateString()
: ""}
</Text>
</Group>
<Divider />
@@ -122,7 +158,11 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
{t("fileManager.fileVersion", "Version")}
</Text>
{currentFile && (
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? "blue" : "gray"}>
<Badge
size="sm"
variant="light"
color={currentFile?.versionNumber ? "blue" : "gray"}
>
v{currentFile ? currentFile.versionNumber || 1 : ""}
</Badge>
)}
@@ -154,7 +194,11 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
<Text size="xs" c="dimmed" mb="xs">
{t("fileManager.toolChain", "Tools Applied")}
</Text>
<ToolChain toolChain={currentFile.toolHistory} displayStyle="badges" size="xs" />
<ToolChain
toolChain={currentFile.toolHistory}
displayStyle="badges"
size="xs"
/>
</Box>
</>
)}
@@ -162,7 +206,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
{currentFile && isSharedWithYou && (
<>
<Divider />
<Button size="sm" variant="light" onClick={() => onMakeCopy(currentFile)} fullWidth>
<Button
size="sm"
variant="light"
onClick={() => onMakeCopy(currentFile)}
fullWidth
>
{t("fileManager.makeCopy", "Make a copy")}
</Button>
</>
@@ -177,7 +226,10 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
</Text>
{uploadEnabled && isOutOfSync ? (
<Badge size="xs" variant="light" color="yellow">
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
{t(
"fileManager.changesNotUploaded",
"Changes not uploaded",
)}
</Badge>
) : uploadEnabled ? (
<Badge size="xs" variant="light" color="teal">
@@ -206,7 +258,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
{t("fileManager.sharedByYou", "Shared by you")}
</Badge>
</Group>
<Button size="sm" variant="light" onClick={() => setShowShareManageModal(true)} fullWidth>
<Button
size="sm"
variant="light"
onClick={() => setShowShareManageModal(true)}
fullWidth
>
{t("storageShare.manage", "Manage sharing")}
</Button>
</>
@@ -12,7 +12,10 @@ interface FileListAreaProps {
scrollAreaStyle?: React.CSSProperties;
}
const FileListArea: React.FC<FileListAreaProps> = ({ scrollAreaHeight, scrollAreaStyle = {} }) => {
const FileListArea: React.FC<FileListAreaProps> = ({
scrollAreaHeight,
scrollAreaStyle = {},
}) => {
const {
activeSource,
recentFiles,
@@ -94,9 +97,14 @@ const FileListArea: React.FC<FileListAreaProps> = ({ scrollAreaHeight, scrollAre
return (
<Center style={{ height: "12.5rem" }}>
<Stack align="center" gap="sm">
<CloudIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
<CloudIcon
style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }}
/>
<Text c="dimmed" ta="center">
{t("fileManager.googleDriveNotAvailable", "Google Drive integration coming soon")}
{t(
"fileManager.googleDriveNotAvailable",
"Google Drive integration coming soon",
)}
</Text>
</Stack>
</Center>
@@ -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<FileListItemProps> = ({
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<FileListItemProps> = ({
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<FileListItemProps> = ({
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<FileListItemProps> = ({
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<FileListItemProps> = ({
{t("fileManager.sharedWithYou", "Shared with you")}
</Badge>
) : null}
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== "editor" ? (
{sharingEnabled &&
isSharedWithYou &&
accessRole &&
accessRole !== "editor" ? (
<Badge size="xs" variant="light" color="gray">
{accessRole === "commenter"
? t("storageShare.roleCommenter", "Commenter")
@@ -228,15 +274,27 @@ const FileListItem: React.FC<FileListItemProps> = ({
{t("fileManager.localOnly", "Local only")}
</Badge>
) : uploadEnabled && isOutOfSync ? (
<Badge size="xs" variant="light" color="yellow" leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}>
<Badge
size="xs"
variant="light"
color="yellow"
leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}
>
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
</Badge>
) : uploadEnabled && isUploaded ? (
<Badge size="xs" variant="light" color="teal" leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}>
<Badge
size="xs"
variant="light"
color="teal"
leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}
>
{t("fileManager.synced", "Synced")}
</Badge>
) : null}
{sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && (
{sharingEnabled &&
file.remoteOwnedByCurrentUser !== false &&
file.remoteHasShareLinks && (
<Badge size="xs" variant="light" color="blue">
{t("fileManager.sharedByYou", "Shared by you")}
</Badge>
@@ -249,7 +307,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
{/* Tool chain for processed files */}
{file.toolHistory && file.toolHistory.length > 0 && (
<ToolChain toolChain={file.toolHistory} maxWidth={"150px"} displayStyle="text" size="xs" />
<ToolChain
toolChain={file.toolHistory}
maxWidth={"150px"}
displayStyle="text"
size="xs"
/>
)}
</Group>
</Box>
@@ -368,7 +431,9 @@ const FileListItem: React.FC<FileListItemProps> = ({
onToggleExpansion(leafFileId);
}}
>
{isExpanded ? t("fileManager.hideHistory", "Hide History") : t("fileManager.showHistory", "Show History")}
{isExpanded
? t("fileManager.hideHistory", "Hide History")
: t("fileManager.showHistory", "Show History")}
</Menu.Item>
<Menu.Divider />
</>
@@ -436,7 +501,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
/>
)}
{canManageShare && (
<ShareManagementModal opened={showShareManageModal} onClose={() => setShowShareManageModal(false)} file={file} />
<ShareManagementModal
opened={showShareManageModal}
onClose={() => setShowShareManageModal(false)}
file={file}
/>
)}
</>
);
@@ -32,10 +32,19 @@ const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => (
/>
);
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = false }) => {
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
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<FileSourceButtonsProps> = ({ 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<FileSourceButtonsProps> = ({ 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")}
</Button>
<Button
@@ -136,17 +155,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
backgroundColor: "transparent",
border: "none",
"&:hover": {
backgroundColor: isGoogleDriveEnabled ? "var(--mantine-color-gray-0)" : "transparent",
backgroundColor: isGoogleDriveEnabled
? "var(--mantine-color-gray-0)"
: "transparent",
},
},
}}
title={
!isGoogleDriveEnabled
? t("fileManager.googleDriveNotAvailable", "Google Drive integration not available")
? t(
"fileManager.googleDriveNotAvailable",
"Google Drive integration not available",
)
: undefined
}
>
{horizontal ? t("fileManager.googleDriveShort", "Drive") : t("fileManager.googleDrive", "Google Drive")}
{horizontal
? t("fileManager.googleDriveShort", "Drive")
: t("fileManager.googleDrive", "Google Drive")}
</Button>
)}
@@ -165,13 +191,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ 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")}
</Button>
)}
</>
@@ -195,7 +232,14 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
return (
<>
<Stack gap="xs" style={{ height: "100%" }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: "1rem" }}>
<Text
size="sm"
pt="sm"
fw={500}
c="dimmed"
mb="xs"
style={{ paddingLeft: "1rem" }}
>
{t("fileManager.myFiles", "My Files")}
</Text>
{buttons}
@@ -27,7 +27,11 @@ const MobileLayout: React.FC = () => {
};
return (
<Box h="100%" p="sm" style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<Box
h="100%"
p="sm"
style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}
>
{/* Section 1: File Sources - Fixed at top */}
<Box style={{ flexShrink: 0 }}>
<FileSourceButtons horizontal={true} />
@@ -23,7 +23,11 @@ const baseKeyStyle: React.CSSProperties = {
color: "var(--mantine-color-text)",
};
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = "sm", muted = false }) => {
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({
binding,
size = "sm",
muted = false,
}) => {
const { getDisplayParts } = useHotkeys();
const parts = getDisplayParts(binding);
@@ -31,7 +35,10 @@ export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ 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 (
<span
@@ -4,7 +4,11 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { useNavigationState, useNavigationActions, useNavigationGuard } from "@app/contexts/NavigationContext";
import {
useNavigationState,
useNavigationActions,
useNavigationGuard,
} from "@app/contexts/NavigationContext";
import { isBaseWorkbench } from "@app/types/workbench";
import { useViewer } from "@app/contexts/ViewerContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -102,7 +106,9 @@ export default function Workbench() {
// Check if we're showing a custom workbench first
// Custom workbenches may not require files in FileContext (e.g., sign request workbench)
if (!isBaseWorkbench(currentView)) {
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
const customView = customWorkbenchViews.find(
(view) => view.workbenchId === currentView && view.data != null,
);
if (customView) {
const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />;
@@ -148,7 +154,15 @@ export default function Workbench() {
<div style={{ position: "relative", flex: "1 1 0", height: 0 }}>
<PageEditor onFunctionsReady={setPageEditorFunctions} />
{pageEditorFunctions && (
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, zIndex: 100 }}>
<div
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
zIndex: 100,
}}
>
<PageEditorControls
onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo}
@@ -190,14 +204,20 @@ export default function Workbench() {
}
>
{/* Top Controls */}
{activeFiles.length > 0 && !customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && (
{activeFiles.length > 0 &&
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
?.hideTopControls && (
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
customViews={customWorkbenchViews}
activeFiles={activeFiles.map((f) => {
const stub = selectors.getStirlingFileStub(f.fileId);
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
return {
fileId: f.fileId,
name: f.name,
versionNumber: stub?.versionNumber,
};
})}
currentFileIndex={activeFileIndex}
onFileSelect={handleFileSelect}
@@ -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" && <ChevronLeftIcon fontSize="small" />}
{button.icon === "chevron-left" && (
<ChevronLeftIcon fontSize="small" />
)}
</ActionIcon>
);
}
@@ -85,7 +99,12 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
const label = resolveButtonLabel(button);
return (
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
<Button
key={button.key}
onClick={() => onAction(button.action)}
disabled={disabled}
styles={buttonStyles(variant)}
>
{label}
</Button>
);
@@ -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<string | null>(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() {
<OnboardingModalSlide
slideDefinition={slideDefinition}
slideContent={slideContent}
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }}
runtimeState={{
...runtimeState,
licenseNotice: effectiveLicenseNotice,
}}
modalSlideCount={1}
currentModalSlideIndex={0}
onSkip={closeLicenseSlide}
@@ -524,7 +587,9 @@ export default function Onboarding() {
// Render the current onboarding step
switch (currentStep.type) {
case "tool-prompt":
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
return (
<ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />
);
case "modal-slide":
if (!currentSlideDefinition || !currentSlideContent) return null;
@@ -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 (
<div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}>
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
<img
src={`${BASE_PATH}/modern-logo/logo512.png`}
alt="Stirling icon"
className={styles.downloadIcon}
/>
</div>
</div>
);
@@ -56,20 +63,45 @@ export default function OnboardingModalSlide({
return (
<div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === "rocket" && (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
<LocalIcon
icon="rocket-launch"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "shield" && (
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
<LocalIcon
icon="verified-user-outline"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "lock" && (
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
<LocalIcon
icon="lock-outline"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "analytics" && (
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
<LocalIcon
icon="analytics"
width={64}
height={64}
className={styles.heroIcon}
/>
)}
{slideDefinition.hero.type === "diamond" && (
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
)}
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
{slideDefinition.hero.type === "logo" && (
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
<img
src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`}
alt="Stirling logo"
/>
)}
</div>
);
@@ -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",
},
}}
>
<Stack gap={0} className={styles.modalContent}>
@@ -129,20 +166,34 @@ export default function OnboardingModalSlide({
</div>
</div>
<div className={styles.modalBody} style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}>
<div
className={styles.modalBody}
style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}
>
<Stack gap={16}>
<div key={`title-${slideContent.key}`} className={`${styles.title} ${styles.titleText}`}>
<div
key={`title-${slideContent.key}`}
className={`${styles.title} ${styles.titleText}`}
>
{slideContent.title}
</div>
<div className={styles.bodyText}>
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
<div
key={`body-${slideContent.key}`}
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
>
{slideContent.body}
</div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div>
{modalSlideCount > 1 && <OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />}
{modalSlideCount > 1 && (
<OnboardingStepper
totalSteps={modalSlideCount}
activeStep={currentModalSlideIndex}
/>
)}
<div className={styles.buttonContainer}>
<SlideButtons
@@ -10,7 +10,11 @@ interface OnboardingStepperProps {
* Renders a progress indicator where the active step is a pill and others are dots.
* Colors come from theme.css variables.
*/
export function OnboardingStepper({ totalSteps, activeStep, className }: OnboardingStepperProps) {
export function OnboardingStepper({
totalSteps,
activeStep,
className,
}: OnboardingStepperProps) {
const items = Array.from({ length: totalSteps }, (_, index) => 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 (
@@ -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 (
<ActionIcon
onClick={() => 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 ? <CheckIcon /> : <ArrowIcon />}
</ActionIcon>
@@ -127,10 +151,17 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
}}
components={{
Close: ({ onClick }) => (
<CloseButton onClick={onClick} size="md" style={{ position: "absolute", top: "8px", right: "8px" }} />
<CloseButton
onClick={onClick}
size="md"
style={{ position: "absolute", top: "8px", right: "8px" }}
/>
),
Content: ({ content }: { content: string }) => (
<div style={{ paddingRight: "16px" }} dangerouslySetInnerHTML={{ __html: content }} />
<div
style={{ paddingRight: "16px" }}
dangerouslySetInnerHTML={{ __html: content }}
/>
),
}}
>
@@ -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<AdminTourStep, StepType> {
const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions;
export function createAdminStepsConfig({
t,
actions,
}: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
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 <strong>external database hookups</strong> 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 <strong>Connections</strong> 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 () => {
@@ -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<SlideId, SlideDefinition> = {
"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<SlideId, SlideDefinition> = {
},
"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<SlideId, SlideDefinition> = {
},
"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<SlideId, SlideDefinition> = {
},
"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<SlideId, SlideDefinition> = {
},
"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
},
@@ -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",
@@ -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<string, unknown>;
// 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();
}
}
@@ -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<OnboardingRuntimeState>): 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<OnboardingRuntimeState>): 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<OnboardingRuntimeState>(() => getInitialRuntimeState(defaultState));
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
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<OnboardingConditionContext>(
() => ({
...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<OnboardingRuntimeState>) => {
const updateRuntimeState = useCallback(
(updates: Partial<OnboardingRuntimeState>) => {
persistRuntimeState(updates);
setRuntimeState((prev) => ({ ...prev, ...updates }));
}, []);
},
[],
);
const refreshFlow = useCallback(() => {
initialIndexSet.current = false;
@@ -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: (
<div className={styles.bodyCopyInner}>
<Trans
@@ -33,13 +38,22 @@ export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoice
<Button
variant="default"
size="sm"
onClick={() => window.open("https://docs.stirlingpdf.com/analytics-telemetry/", "_blank")}
onClick={() =>
window.open(
"https://docs.stirlingpdf.com/analytics-telemetry/",
"_blank",
)
}
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{i18n.t("analytics.learnMore", "Learn more about our analytics")}
</Button>
</div>
{analyticsError && <div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>{analyticsError}</div>}
{analyticsError && (
<div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>
{analyticsError}
</div>
)}
</div>
),
background: {
@@ -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
);
}
}
@@ -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 <div key={`circle-${index}-${position}`} className={styles.circle} style={circleStyle} />;
return (
<div
key={`circle-${index}-${position}`}
className={styles.circle}
style={circleStyle}
/>
);
})}
</div>
);
@@ -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 };
@@ -43,7 +43,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
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<DesktopInstallTitleProps> = ({
}
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", width: "100%" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
width: "100%",
}}
>
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
<Menu position="bottom" offset={5} zIndex={10000}>
<Menu.Target>
@@ -80,7 +90,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
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}
@@ -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 =
<div className={styles.securityCard}>
<Stack gap="md">
<div className={styles.securityAlertRow}>
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: "#3B82F6", flexShrink: 0 }} />
<LocalIcon
icon="info-rounded"
width={20}
height={20}
style={{ color: "#3B82F6", flexShrink: 0 }}
/>
<span>
{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.",
)}
</span>
</div>
<Text size="sm" fw={500}>
{t("firstLogin.loggedInAs", "Logged in as")}: <strong>{username}</strong>
{t("firstLogin.loggedInAs", "Logged in as")}:{" "}
<strong>{username}</strong>
</Text>
{error && (
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
<Alert
icon={
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
}
color="red"
variant="light"
>
{error}
</Alert>
)}
@@ -105,7 +152,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
{!usingDefaultCredentials && (
<PasswordInput
label={t("firstLogin.currentPassword", "Current Password")}
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")}
placeholder={t(
"firstLogin.enterCurrentPassword",
"Enter your current password",
)}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required
@@ -117,7 +167,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
<PasswordInput
label={t("firstLogin.newPassword", "New Password")}
placeholder={t("firstLogin.enterNewPassword", "Enter new password (min 8 characters)")}
placeholder={t(
"firstLogin.enterNewPassword",
"Enter new password (min 8 characters)",
)}
value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8}
@@ -129,7 +182,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
<PasswordInput
label={t("firstLogin.confirmPassword", "Confirm New Password")}
placeholder={t("firstLogin.reEnterNewPassword", "Re-enter new password")}
placeholder={t(
"firstLogin.reEnterNewPassword",
"Re-enter new password",
)}
value={confirmPassword}
onChange={(e) => 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"
>
@@ -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<MfaSetupResponse | null>(null);
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
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) {
<div className={styles.mfaCard}>
<Stack gap="md">
<Text size="sm" c="dimmed">
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.
</Text>
{mfaError && (
<Alert icon={<LocalIcon icon="error" width={16} height={16} />} color="red" variant="light">
<Alert
icon={<LocalIcon icon="error" width={16} height={16} />}
color="red"
variant="light"
>
{mfaError}
</Alert>
)}
@@ -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
</Button>
<Button type="submit" loading={submitting} disabled={!isReady || setupComplete || mfaSetupCode.length < 6}>
<Button
type="submit"
loading={submitting}
disabled={
!isReady || setupComplete || mfaSetupCode.length < 6
}
>
Enable MFA
</Button>
<Button type="button" variant="light" onClick={onLogout}>
@@ -200,7 +237,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
);
}
export default function MFASetupSlide({ onMfaSetupComplete }: MFASetupSlideProps = {}): SlideConfig {
export default function MFASetupSlide({
onMfaSetupComplete,
}: MFASetupSlideProps = {}): SlideConfig {
return {
key: "mfa-setup-slide",
title: "Multi-Factor Authentication Setup",
@@ -22,7 +22,10 @@ const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
);
};
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ freeTierLimit, loginEnabled }) => {
const AdminOverviewBody: React.FC<{
freeTierLimit: number;
loginEnabled: boolean;
}> = ({ freeTierLimit, loginEnabled }) => {
const adminBodyKey = loginEnabled
? "onboarding.planOverview.adminBodyLoginEnabled"
: "onboarding.planOverview.adminBodyLoginDisabled";
@@ -32,7 +35,12 @@ const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean
: "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.";
return (
<Trans i18nKey={adminBodyKey} values={{ freeTierLimit }} components={{ strong: <strong /> }} defaults={defaultValue} />
<Trans
i18nKey={adminBodyKey}
values={{ freeTierLimit }}
components={{ strong: <strong /> }}
defaults={defaultValue}
/>
);
};
@@ -48,11 +56,19 @@ const UserOverviewBody: React.FC = () => {
);
};
const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; loginEnabled: boolean }> = ({
isAdmin,
freeTierLimit,
loginEnabled,
}) => (isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />);
const PlanOverviewBody: React.FC<{
isAdmin: boolean;
freeTierLimit: number;
loginEnabled: boolean;
}> = ({ isAdmin, freeTierLimit, loginEnabled }) =>
isAdmin ? (
<AdminOverviewBody
freeTierLimit={freeTierLimit}
loginEnabled={loginEnabled}
/>
) : (
<UserOverviewBody />
);
export default function PlanOverviewSlide({
isAdmin,
@@ -64,7 +80,13 @@ export default function PlanOverviewSlide({
return {
key: isAdmin ? "admin-overview" : "plan-overview",
title: <PlanOverviewTitle isAdmin={isAdmin} />,
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
body: (
<PlanOverviewBody
isAdmin={isAdmin}
freeTierLimit={freeTierLimit}
loginEnabled={loginEnabled}
/>
),
background: {
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
circles: UNIFIED_CIRCLE_CONFIG,
@@ -11,7 +11,10 @@ interface SecurityCheckSlideProps {
onRoleSelect: (role: "admin" | "user" | null) => void;
}
export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: SecurityCheckSlideProps): SlideConfig {
export default function SecurityCheckSlide({
selectedRole,
onRoleSelect,
}: SecurityCheckSlideProps): SlideConfig {
return {
key: "security-check",
title: "Security Check",
@@ -19,7 +22,12 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
<div className={styles.securitySlideContent}>
<div className={styles.securityCard}>
<div className={styles.securityAlertRow}>
<LocalIcon icon="error" width={20} height={20} style={{ color: "#F04438", flexShrink: 0 }} />
<LocalIcon
icon="error"
width={20}
height={20}
style={{ color: "#F04438", flexShrink: 0 }}
/>
<span>
{i18n.t(
"onboarding.securityCheck.message",
@@ -35,7 +43,9 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
{ value: "admin", label: "Admin" },
{ value: "user", label: "User" },
]}
onChange={(value) => onRoleSelect((value as "admin" | "user") ?? null)}
onChange={(value) =>
onRoleSelect((value as "admin" | "user") ?? null)
}
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
styles={{
input: {
@@ -10,11 +10,14 @@ interface ServerLicenseSlideProps {
const DEFAULT_FREE_TIER_LIMIT = 5;
export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlideProps = {}): SlideConfig {
export default function ServerLicenseSlide({
licenseNotice,
}: ServerLicenseSlideProps = {}): SlideConfig {
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
const totalUsers = licenseNotice?.totalUsers ?? null;
const isOverLimit = licenseNotice?.isOverLimit ?? false;
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
const formattedTotalUsers =
totalUsers != null ? totalUsers.toLocaleString() : null;
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
const title = isOverLimit
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
@@ -50,7 +53,9 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
title,
body,
background: {
gradientStops: isOverLimit ? ["#F472B6", "#8B5CF6"] : ["#F97316", "#F59E0B"],
gradientStops: isOverLimit
? ["#F472B6", "#8B5CF6"]
: ["#F97316", "#F59E0B"],
circles: UNIFIED_CIRCLE_CONFIG,
},
};
@@ -12,6 +12,10 @@ export const addGlowToElements = (selectors: string[]) => {
};
export const removeAllGlows = () => {
document.querySelectorAll(".tour-content-glow").forEach((el) => el.classList.remove("tour-content-glow"));
document.querySelectorAll(".tour-nav-glow").forEach((el) => el.classList.remove("tour-nav-glow"));
document
.querySelectorAll(".tour-content-glow")
.forEach((el) => el.classList.remove("tour-content-glow"));
document
.querySelectorAll(".tour-nav-glow")
.forEach((el) => el.classList.remove("tour-nav-glow"));
};
@@ -38,7 +38,9 @@ function setStoredBypass(enabled: boolean): void {
*/
export function useBypassOnboarding(): boolean {
const location = useLocation();
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() =>
readStoredBypass(),
);
const shouldBypassFromSearch = useMemo(() => {
try {
@@ -36,7 +36,10 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
case "windows":
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
case "mac-apple":
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
return {
label: "Mac (Apple Silicon)",
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
};
case "mac-intel":
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
case "linux-x64":
@@ -51,8 +54,16 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
() =>
[
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
{ label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: "mac-apple" },
{ label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL, value: "mac-intel" },
{
label: "Mac (Apple Silicon)",
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
value: "mac-apple",
},
{
label: "Mac (Intel)",
url: DOWNLOAD_URLS.MAC_INTEL,
value: "mac-intel",
},
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
].filter((opt) => opt.url),
[],
@@ -14,7 +14,9 @@ export function useServerLicenseRequest(): {
closeLicenseSlide: () => void;
} {
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState["licenseNotice"] | null>(null);
const [licenseNotice, setLicenseNotice] = useState<
OnboardingRuntimeState["licenseNotice"] | null
>(null);
useEffect(() => {
if (typeof window === "undefined") return;
@@ -35,7 +37,11 @@ export function useServerLicenseRequest(): {
};
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
return () => window.removeEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
return () =>
window.removeEventListener(
SERVER_LICENSE_REQUEST_EVENT,
handleLicenseRequest,
);
}, []);
const closeLicenseSlide = useCallback(() => {
@@ -51,7 +57,8 @@ export function useTourRequest(): {
clearTourRequest: () => void;
} {
const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>("whatsnew");
const [requestedTourType, setRequestedTourType] =
useState<TourType>("whatsnew");
useEffect(() => {
if (typeof window === "undefined") return;
@@ -63,7 +70,8 @@ export function useTourRequest(): {
};
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
return () =>
window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
}, []);
const clearTourRequest = useCallback(() => {
@@ -36,7 +36,10 @@ interface CreateUserStepsConfigArgs {
actions: UserStepActions;
}
export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
export function createUserStepsConfig({
t,
actions,
}: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
const {
saveWorkbenchState,
closeFilesModal,
@@ -1,7 +1,11 @@
import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next";
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
async function waitForElement(
selector: string,
timeoutMs = 7000,
intervalMs = 100,
): Promise<void> {
if (typeof document === "undefined") return;
const start = Date.now();
// Immediate hit
@@ -19,7 +23,11 @@ async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 1
});
}
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
async function waitForHighlightable(
selector: string,
timeoutMs = 7000,
intervalMs = 500,
): Promise<void> {
if (typeof document === "undefined") return;
const start = Date.now();
@@ -68,7 +76,10 @@ interface CreateWhatsNewStepsConfigArgs {
actions: WhatsNewStepActions;
}
export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
export function createWhatsNewStepsConfig({
t,
actions,
}: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
const {
saveWorkbenchState,
closeFilesModal,
@@ -128,7 +139,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
},
[WhatsNewTourStep.RIGHT_RAIL]: {
selector: '[data-tour="right-rail-controls"]',
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
highlightedSelectors: [
'[data-tour="right-rail-controls"]',
'[data-tour="right-rail-settings"]',
],
content: t(
"onboarding.whatsNew.rightRail",
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
@@ -157,7 +171,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
},
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
selector: '[data-tour="view-switcher"]',
content: t("onboarding.whatsNew.pageEditorView", "Switch to the Page Editor to reorder, rotate, or delete pages."),
content: t(
"onboarding.whatsNew.pageEditorView",
"Switch to the Page Editor to reorder, rotate, or delete pages.",
),
position: "bottom",
padding: 8,
action: async () => {
@@ -39,9 +39,17 @@ const BulkSelectionPanel = ({
onToggleAdvanced={setAdvancedOpened}
/>
<PageSelectionSyntaxHint input={csvInput} maxPages={maxPages} variant="panel" />
<PageSelectionSyntaxHint
input={csvInput}
maxPages={maxPages}
variant="panel"
/>
<SelectedPagesDisplay selectedPageIds={selectedPageIds} displayDocument={displayDocument} syntaxError={null} />
<SelectedPagesDisplay
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
syntaxError={null}
/>
<AdvancedSelectionPanel
csvInput={csvInput}
@@ -1,9 +1,19 @@
import React, { useRef, useEffect, useState, useCallback, useMemo } from "react";
import React, {
useRef,
useEffect,
useState,
useCallback,
useMemo,
} from "react";
import { Box } from "@mantine/core";
import { useVirtualizer } from "@tanstack/react-virtual";
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
import styles from "@app/components/pageEditor/DragDropGrid.module.css";
import { Z_INDEX_SELECTION_BOX, Z_INDEX_DROP_INDICATOR, Z_INDEX_DRAG_BADGE } from "@app/styles/zIndex";
import {
Z_INDEX_SELECTION_BOX,
Z_INDEX_DROP_INDICATOR,
Z_INDEX_DRAG_BADGE,
} from "@app/styles/zIndex";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import {
DndContext,
@@ -28,7 +38,11 @@ interface DragDropItem {
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
onReorderPages: (
sourcePageNumber: number,
targetIndex: number,
selectedPageIds?: string[],
) => void;
renderItem: (
item: T,
index: number,
@@ -40,7 +54,9 @@ interface DragDropGridProps<T extends DragDropItem> {
dragHandleProps?: any,
zoomLevel?: number,
) => React.ReactNode;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
getThumbnailData?: (
itemId: string,
) => { src: string; rotation: number } | null;
zoomLevel?: number;
selectedFileIds?: string[];
selectedPageIds?: string[];
@@ -67,7 +83,10 @@ function resolveDropHint(
}
const items: ItemRect[] = Array.from(itemRefs.current.entries())
.filter((entry): entry is [string, HTMLDivElement] => !!entry[1] && entry[0] !== activeId)
.filter(
(entry): entry is [string, HTMLDivElement] =>
!!entry[1] && entry[0] !== activeId,
)
.map(([itemId, element]) => ({
id: itemId,
rect: element.getBoundingClientRect(),
@@ -90,7 +109,8 @@ function resolveDropHint(
return;
}
const isSameRow = Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance;
const isSameRow =
Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance;
if (isSameRow) {
currentRow.push(item);
} else {
@@ -174,7 +194,9 @@ function resolveTargetIndex<T extends DragDropItem>(
};
if (hoveredId) {
const filteredIndex = filteredItems.findIndex((item) => item.id === hoveredId);
const filteredIndex = filteredItems.findIndex(
(item) => item.id === hoveredId,
);
if (filteredIndex !== -1) {
const adjustedIndex = filteredIndex + (dropSide === "right" ? 1 : 0);
return convertFilteredIndexToOriginal(adjustedIndex);
@@ -198,7 +220,9 @@ interface DraggableItemProps<T extends DragDropItem> {
clearBoxSelection: () => void;
activeDragIds: string[];
justMoved: boolean;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
getThumbnailData?: (
itemId: string,
) => { src: string; rotation: number } | null;
onUpdateDropTarget: (itemId: string | null) => void;
renderItem: (
item: T,
@@ -247,11 +271,15 @@ const DraggableItemInner = <T extends DragDropItem>({
}
const element = itemRefs.current.get(item.id);
const imgElement = element?.querySelector("img.ph-no-capture") as HTMLImageElement;
const imgElement = element?.querySelector(
"img.ph-no-capture",
) as HTMLImageElement;
if (imgElement?.src) {
return {
src: imgElement.src,
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0,
rotation: imgElement.dataset.originalRotation
? parseInt(imgElement.dataset.originalRotation)
: 0,
};
}
return null;
@@ -312,8 +340,12 @@ const DraggableItem = React.memo(DraggableItemInner, (prevProps, nextProps) => {
}
// Check if page selection changed (for checkbox selection, not box selection)
const prevSelectedSet = prevProps.selectedPageIds ? new Set(prevProps.selectedPageIds) : null;
const nextSelectedSet = nextProps.selectedPageIds ? new Set(nextProps.selectedPageIds) : null;
const prevSelectedSet = prevProps.selectedPageIds
? new Set(prevProps.selectedPageIds)
: null;
const nextSelectedSet = nextProps.selectedPageIds
? new Set(nextProps.selectedPageIds)
: null;
if (prevSelectedSet && nextSelectedSet) {
const prevSelected = prevSelectedSet.has(prevProps.item.id);
@@ -348,17 +380,29 @@ const DragDropGrid = <T extends DragDropItem>({
const containerRef = useRef<HTMLDivElement>(null);
const getScrollElement = useCallback(() => {
return containerRef.current?.closest("[data-scrolling-container]") as HTMLElement | null;
return containerRef.current?.closest(
"[data-scrolling-container]",
) as HTMLElement | null;
}, []);
// Create stable signature for items to ensure useMemo detects changes
const itemsSignature = useMemo(() => items.map((item) => item.id).join(","), [items]);
const selectedFileIdsSignature = useMemo(() => selectedFileIds?.join(",") || "", [selectedFileIds]);
const itemsSignature = useMemo(
() => items.map((item) => item.id).join(","),
[items],
);
const selectedFileIdsSignature = useMemo(
() => selectedFileIds?.join(",") || "",
[selectedFileIds],
);
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
const { filteredItems: visibleItems, filteredToOriginalIndex } =
useMemo(() => {
const filtered: T[] = [];
const indexMap: number[] = [];
const selectedIds = selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null;
const selectedIds =
selectedFileIds && selectedFileIds.length > 0
? new Set(selectedFileIds)
: null;
items.forEach((item, index) => {
const isPlaceholder = Boolean(item.isPlaceholder);
@@ -366,7 +410,10 @@ const DragDropGrid = <T extends DragDropItem>({
return;
}
const belongsToVisibleFile = !selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId);
const belongsToVisibleFile =
!selectedIds ||
!item.originalFileId ||
selectedIds.has(item.originalFileId);
if (!belongsToVisibleFile) {
return;
@@ -389,8 +436,14 @@ const DragDropGrid = <T extends DragDropItem>({
}, [visibleItems]);
// Box selection state
const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null);
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null);
const [boxSelectStart, setBoxSelectStart] = useState<{
x: number;
y: number;
} | null>(null);
const [boxSelectEnd, setBoxSelectEnd] = useState<{
x: number;
y: number;
} | null>(null);
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
const [justMovedIds, setJustMovedIds] = useState<string[]>([]);
@@ -398,7 +451,10 @@ const DragDropGrid = <T extends DragDropItem>({
// Drag state
const [activeId, setActiveId] = useState<string | null>(null);
const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null);
const [dragPreview, setDragPreview] = useState<{
src: string;
rotation: number;
} | null>(null);
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
const [dropSide, setDropSide] = useState<DropSide>(null);
@@ -438,7 +494,9 @@ const DragDropGrid = <T extends DragDropItem>({
}
};
window.addEventListener("pointermove", handlePointerMove, { passive: true });
window.addEventListener("pointermove", handlePointerMove, {
passive: true,
});
return () => {
window.removeEventListener("pointermove", handlePointerMove);
if (rafId !== null) {
@@ -449,7 +507,10 @@ const DragDropGrid = <T extends DragDropItem>({
// Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4);
const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
const OVERSCAN =
visibleItems.length > 1000
? GRID_CONSTANTS.OVERSCAN_LARGE
: GRID_CONSTANTS.OVERSCAN_SMALL;
// Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => {
@@ -459,8 +520,11 @@ const DragDropGrid = <T extends DragDropItem>({
if (containerWidth === 0) return 4; // Container not measured yet
// Convert rem to pixels for calculation
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const remToPx = parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
const ITEM_WIDTH =
parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
@@ -501,7 +565,9 @@ const DragDropGrid = <T extends DragDropItem>({
count: Math.ceil(visibleItems.length / itemsPerRow),
getScrollElement,
estimateSize: () => {
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const remToPx = parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
},
overscan: OVERSCAN,
@@ -524,10 +590,19 @@ const DragDropGrid = <T extends DragDropItem>({
// Re-measure virtualizer when zoom or items per row changes
// Also remeasure when items change (not just length) to handle item additions/removals
const visibleItemsSignature = useMemo(() => visibleItems.map((item) => item.id).join(","), [visibleItems]);
const visibleItemsSignature = useMemo(
() => visibleItems.map((item) => item.id).join(","),
[visibleItems],
);
useEffect(() => {
rowVirtualizer.measure();
}, [zoomLevel, itemsPerRow, visibleItems.length, visibleItemsSignature, rowVirtualizer]);
}, [
zoomLevel,
itemsPerRow,
visibleItems.length,
visibleItemsSignature,
rowVirtualizer,
]);
// Cleanup highlight timeout on unmount
useEffect(() => {
@@ -559,7 +634,10 @@ const DragDropGrid = <T extends DragDropItem>({
if (clickedPageId) {
// Clicking directly on a page shouldn't initiate box selection
// but clear previous box selection if clicking outside current group
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
if (
boxSelectedPageIds.length > 0 &&
!boxSelectedPageIds.includes(clickedPageId)
) {
setBoxSelectedPageIds([]);
}
return;
@@ -600,7 +678,12 @@ const DragDropGrid = <T extends DragDropItem>({
const pageBottom = pageRect.bottom - rect.top;
// Check if page intersects with selection box
const intersects = !(pageRight < boxLeft || pageLeft > boxRight || pageBottom < boxTop || pageTop > boxBottom);
const intersects = !(
pageRight < boxLeft ||
pageLeft > boxRight ||
pageBottom < boxTop ||
pageTop > boxBottom
);
if (intersects) {
selectedIds.push(pageId);
@@ -637,7 +720,10 @@ const DragDropGrid = <T extends DragDropItem>({
if (getThumbnail) {
const thumbnailData = getThumbnail();
if (thumbnailData?.src) {
setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation });
setDragPreview({
src: thumbnailData.src,
rotation: thumbnailData.rotation,
});
return;
}
}
@@ -692,7 +778,10 @@ const DragDropGrid = <T extends DragDropItem>({
// Check if this page is box-selected
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
const pagesToDrag =
isBoxSelected && boxSelectedPageIds.length > 0
? boxSelectedPageIds
: undefined;
// Call reorder with page number and target index
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
@@ -726,7 +815,9 @@ const DragDropGrid = <T extends DragDropItem>({
);
// Calculate optimal width for centering
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const remToPx = parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
@@ -814,9 +905,13 @@ const DragDropGrid = <T extends DragDropItem>({
onMouseUp={handleMouseUp}
onWheel={handleWheelWhileDragging}
>
{selectionBoxStyle && <div className={styles.selectionBox} style={selectionBoxStyle} />}
{selectionBoxStyle && (
<div className={styles.selectionBox} style={selectionBoxStyle} />
)}
{dropIndicatorStyle && <div className={styles.dropIndicator} style={dropIndicatorStyle} />}
{dropIndicatorStyle && (
<div className={styles.dropIndicator} style={dropIndicatorStyle} />
)}
<div
className={styles.virtualRows}
@@ -828,7 +923,10 @@ const DragDropGrid = <T extends DragDropItem>({
>
{virtualRows.map((virtualRow) => {
const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length);
const endIndex = Math.min(
startIndex + itemsPerRow,
visibleItems.length,
);
const rowItems = visibleItems.slice(startIndex, endIndex);
return (
@@ -877,8 +975,12 @@ const DragDropGrid = <T extends DragDropItem>({
<DragOverlay>
{activeId && (
<div className={styles.dragOverlay}>
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
<div className={styles.dragOverlayBadge} style={{ zIndex: Z_INDEX_DRAG_BADGE }}>
{boxSelectedPageIds.includes(activeId) &&
boxSelectedPageIds.length > 1 && (
<div
className={styles.dragOverlayBadge}
style={{ zIndex: Z_INDEX_DRAG_BADGE }}
>
{boxSelectedPageIds.length}
</div>
)}
@@ -1,4 +1,10 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from "react";
import React, {
useState,
useCallback,
useRef,
useMemo,
useEffect,
} from "react";
import { ActionIcon, CheckboxIndicator } from "@mantine/core";
import { useTranslation } from "react-i18next";
import MoreVertIcon from "@mui/icons-material/MoreVert";
@@ -6,7 +12,10 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import styles from "@app/components/pageEditor/PageEditor.module.css";
import { useFileContext } from "@app/contexts/FileContext";
@@ -35,7 +44,11 @@ interface FileThumbnailProps {
onDeleteFile: (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;
toolMode?: boolean;
isSupported?: boolean;
@@ -61,7 +74,9 @@ const FileThumbnail = ({
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
const [actionsWidth, setActionsWidth] = useState<number | undefined>(
undefined,
);
const [showActions, setShowActions] = useState(false);
// Resolve the actual File object for pin/unpin operations
@@ -80,7 +95,10 @@ const FileThumbnail = ({
// Fallback: attempt to download using the File object if provided
const maybeFile = (file as unknown as { file?: File }).file;
if (maybeFile instanceof File) {
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || "download" });
void downloadFile({
data: maybeFile,
filename: maybeFile.name || file.name || "download",
});
return;
}
@@ -146,7 +164,8 @@ const FileThumbnail = ({
// Update dropdown width on resize
useEffect(() => {
const update = () => {
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
if (dragElementRef.current)
setActionsWidth(dragElementRef.current.offsetWidth);
};
update();
window.addEventListener("resize", update);
@@ -177,7 +196,9 @@ const FileThumbnail = ({
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("touchstart", handleTouchStart, { passive: true });
document.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("touchstart", handleTouchStart);
@@ -208,7 +229,9 @@ const FileThumbnail = ({
onClick={handleCardClick}
>
{/* Header bar */}
<div className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}>
<div
className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{isSupported ? (
@@ -225,7 +248,10 @@ const FileThumbnail = ({
</div>
{/* Centered index */}
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
<div
className={styles.headerIndex}
aria-label={`Position ${index + 1}`}
>
{index + 1}
</div>
@@ -245,7 +271,11 @@ const FileThumbnail = ({
{/* Actions overlay */}
{showActions && (
<div className={styles.actionsOverlay} style={{ width: actionsWidth }} onClick={(e) => e.stopPropagation()}>
<div
className={styles.actionsOverlay}
style={{ width: actionsWidth }}
onClick={(e) => e.stopPropagation()}
>
<button
className={styles.actionRow}
onClick={() => {
@@ -261,7 +291,11 @@ const FileThumbnail = ({
setShowActions(false);
}}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
{isPinned ? (
<PushPinIcon fontSize="small" />
) : (
<PushPinOutlinedIcon fontSize="small" />
)}
<span>{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}</span>
</button>
@@ -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<FileId, any>());
const gridItemRefsRef = useRef<React.MutableRefObject<Map<string, HTMLDivElement>> | null>(null);
const gridItemRefsRef = useRef<React.MutableRefObject<
Map<string, HTMLDivElement>
> | null>(null);
const pageEditorFiles = useMemo(() => {
const cache = fileObjectsRef.current;
@@ -163,7 +174,8 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const initialDocument = useInitialPageDocument();
const { document: mergedPdfDocument } = usePageDocument();
const { setEditedDocument, displayDocument, getEditedDocument } = useEditedDocumentState({
const { setEditedDocument, displayDocument, getEditedDocument } =
useEditedDocumentState({
initialDocument,
mergedPdfDocument,
reorderedPages,
@@ -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<HTMLDivElement>(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 (
<PageThumbnail
@@ -631,7 +677,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
>
<LoadingOverlay visible={globalProcessing && !initialDocument} />
{!initialDocument && !globalProcessing && selectedFileIds.length === 0 && (
{!initialDocument &&
!globalProcessing &&
selectedFileIds.length === 0 && (
<Center h="100%">
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">
@@ -653,7 +701,13 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
)}
{displayDocument && (
<Box ref={gridContainerRef} p={0} pt="2rem" pb="4rem" style={{ position: "relative" }}>
<Box
ref={gridContainerRef}
p={0}
pt="2rem"
pb="4rem"
style={{ position: "relative" }}
>
{/* Split Lines Overlay */}
<div
style={{
@@ -674,7 +728,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
}
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 {
@@ -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 = ({
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: "var(--mantine-color-gray-3)", margin: "0 8px" }} />
<div
style={{
width: 1,
height: 28,
backgroundColor: "var(--mantine-color-gray-3)",
margin: "0 8px",
}}
/>
{/* Page Operations */}
<Tooltip label="Rotate Selected Left">
@@ -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"
>
@@ -25,7 +25,13 @@ export default function PageSelectByNumberButton({
updatePagesFromCSV,
}: PageSelectByNumberButtonProps) {
return (
<Tooltip content={label} position="left" offset={12} arrow portalTarget={document.body}>
<Tooltip
content={label}
position="left"
offset={12}
arrow
portalTarget={document.body}
>
<div className={`right-rail-fade enter`}>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
@@ -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<Map<string, HTMLDivElement>>;
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<string>;
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<PageThumbnailProps> = ({
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<number>(0);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(
page.thumbnail,
);
const elementRef = useRef<HTMLDivElement | null>(null);
const { openFilesModal } = useFilesModalContext();
@@ -95,7 +124,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
// 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<PageThumbnailProps> = ({
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<PageThumbnailProps> = ({
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<PageThumbnailProps> = ({
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<PageThumbnailProps> = ({
// 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<PageThumbnailProps> = ({
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<PageThumbnailProps> = ({
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<PageThumbnailProps> = ({
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",
@@ -98,7 +98,10 @@ const AdvancedSelectionPanel = ({
<Flex direction="row" mb="xs" wrap="wrap">
<SelectPages
title={t("bulkSelection.firstNPages.title", "First N Pages")}
placeholder={t("bulkSelection.firstNPages.placeholder", "Number of pages")}
placeholder={t(
"bulkSelection.firstNPages.placeholder",
"Number of pages",
)}
onApply={handleFirstNApply}
maxPages={maxPages}
validationFn={validatePositiveNumber}
@@ -113,12 +116,18 @@ const AdvancedSelectionPanel = ({
isRange={true}
rangeEndValue={rangeEnd}
onRangeEndChange={handleRangeEndChange}
rangeEndPlaceholder={t("bulkSelection.range.toPlaceholder", "To")}
rangeEndPlaceholder={t(
"bulkSelection.range.toPlaceholder",
"To",
)}
/>
<SelectPages
title={t("bulkSelection.lastNPages.title", "Last N Pages")}
placeholder={t("bulkSelection.lastNPages.placeholder", "Number of pages")}
placeholder={t(
"bulkSelection.lastNPages.placeholder",
"Number of pages",
)}
onApply={handleLastNApply}
maxPages={maxPages}
validationFn={validatePositiveNumber}
@@ -126,14 +135,20 @@ const AdvancedSelectionPanel = ({
<SelectPages
title={t("bulkSelection.everyNthPage.title", "Every Nth Page")}
placeholder={t("bulkSelection.everyNthPage.placeholder", "Step size")}
placeholder={t(
"bulkSelection.everyNthPage.placeholder",
"Step size",
)}
onApply={handleEveryNthApply}
maxPages={maxPages}
/>
</Flex>
{/* Operators row at bottom */}
<OperatorsSection csvInput={csvInput} onInsertOperator={insertOperator} />
<OperatorsSection
csvInput={csvInput}
onInsertOperator={insertOperator}
/>
</div>
</div>
)}
@@ -8,7 +8,10 @@ interface OperatorsSectionProps {
onInsertOperator: (op: LogicalOperator) => void;
}
const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => {
const OperatorsSection = ({
csvInput,
onInsertOperator,
}: OperatorsSectionProps) => {
const { t } = useTranslation();
return (
@@ -93,7 +93,12 @@ const SelectPages = ({
error={Boolean(error)}
/>
)}
<Button size="sm" className={classes.applyButton} onClick={handleApply} disabled={isDisabled}>
<Button
size="sm"
className={classes.applyButton}
onClick={handleApply}
disabled={isDisabled}
>
Apply
</Button>
</Group>
@@ -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;
}
@@ -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<string, number>();
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<FileId, File>) => void,
private updateFileContext?: (
updatedDocument: PDFDocument,
insertedFiles?: Map<FileId, File>,
) => 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<void> {
private async generateThumbnailsForInsertedPages(
updatedDocument: PDFDocument,
): Promise<void> {
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<FileId, PDFPage[]>();
@@ -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, {
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<PDFPage[]> {
private async extractPagesFromFile(
file: File,
baseTimestamp: number,
): Promise<PDFPage[]> {
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}`;
@@ -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})`);
@@ -20,7 +20,9 @@ export const useEditedDocumentState = ({
fileOrder,
updateCurrentPages,
}: UseEditedDocumentStateParams) => {
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(
null,
);
const editedDocumentRef = useRef<PDFDocument | null>(null);
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
const pageNeighborCacheRef = useRef<Map<string, string | null>>(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<string, number>();
sourcePages.forEach((page, index) => mergedIndexMap.set(page.id, index));
sourcePages.forEach((page, index) =>
mergedIndexMap.set(page.id, index),
);
const additions = newPages
.map((page) => ({
@@ -163,14 +171,18 @@ export const useEditedDocumentState = ({
return a.mergedIndex - b.mergedIndex;
});
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => {
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;
const placeholderIndex =
originalFileId !== undefined
? nextInsertIndexByFile.get(originalFileId)
: undefined;
if (originalFileId && placeholderIndex !== undefined) {
insertIndex = Math.min(placeholderIndex, pages.length);
@@ -178,21 +190,26 @@ export const useEditedDocumentState = ({
} else if (neighborId === null) {
insertIndex = 0;
} else if (neighborId) {
const neighborIndex = pages.findIndex((p) => p.id === neighborId);
const neighborIndex = pages.findIndex(
(p) => p.id === neighborId,
);
if (neighborIndex !== -1) {
insertIndex = neighborIndex + 1;
} 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;
const fallbackIndex =
cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
const clonedPage = { ...page };
pages.splice(insertIndex, 0, clonedPage);
});
},
);
}
}
@@ -244,4 +261,6 @@ export const useEditedDocumentState = ({
};
};
export type UseEditedDocumentStateReturn = ReturnType<typeof useEditedDocumentState>;
export type UseEditedDocumentStateReturn = ReturnType<
typeof useEditedDocumentState
>;
@@ -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<typeof usePageEditorCommands>;
export type UsePageEditorCommandsReturn = ReturnType<
typeof usePageEditorCommands
>;
@@ -8,7 +8,9 @@ import { PDFDocument } from "@app/types/pageEditor";
*/
export function useInitialPageDocument(): PDFDocument | null {
const { document: liveDocument } = usePageDocument();
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(null);
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(
null,
);
const lastDocumentIdRef = useRef<string | null>(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]);
@@ -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<PDFDocument | null>(null);
const [placeholderVersion, setPlaceholderVersion] = useState(0);
@@ -89,7 +97,9 @@ 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) => ({
const pages: PDFPage[] = Array.from(
{ length: totalPages },
(_, index) => ({
id: `placeholder-${primaryFileId}-page-${index + 1}`,
pageNumber: index + 1,
thumbnail: null,
@@ -97,12 +107,14 @@ export function usePageDocument(): PageDocumentHook {
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:", {
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<string, FileId[]>(); // 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,7 +301,9 @@ export function usePageDocument(): PageDocumentHook {
if (processedFile?.totalPages) {
// Fallback: create pages without thumbnails but with correct count
return Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({
return Array.from(
{ length: processedFile.totalPages },
(_, pageIndex) => ({
id: `${fileId}-${pageIndex + 1}`,
pageNumber: startPageNumber + pageIndex,
originalPageNumber: pageIndex + 1,
@@ -272,7 +313,8 @@ export function usePageDocument(): PageDocumentHook {
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,
@@ -20,7 +20,8 @@ export interface PageEditorDropdownState {
fileColorMap: Map<FileId, number>;
}
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<PageEditorDropdownState>(
() => ({
@@ -55,6 +61,12 @@ export function usePageEditorDropdownState(): PageEditorDropdownState {
onReorder: reorderFiles,
fileColorMap,
}),
[pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap],
[
pageEditorFiles,
selectedCount,
toggleFileSelection,
reorderFiles,
fileColorMap,
],
);
}
@@ -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(
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, {
? await pdfExportService.exportPDFMultiFile(
documentWithDOMState,
sourceFiles,
validSelectedPageIds,
{
selectedOnly: true,
filename: exportFilename,
})
: await pdfExportService.exportPDF(documentWithDOMState, validSelectedPageIds, {
},
)
: 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(
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(
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) => {
@@ -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(() => {
@@ -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<typeof usePageSelectionManager>;
export type UsePageSelectionManagerReturn = ReturnType<
typeof usePageSelectionManager
>;
@@ -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);
@@ -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: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="crop-square-outline"
width="1.5rem"
height="1.5rem"
/>
),
tooltip: deselectAllLabel,
ariaLabel: deselectAllLabel,
section: "top" as const,
@@ -99,7 +119,13 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
},
{
id: "page-delete-selected",
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
icon: (
<LocalIcon
icon="delete-outline-rounded"
width="1.5rem"
height="1.5rem"
/>
),
tooltip: deleteSelectedLabel,
ariaLabel: deleteSelectedLabel,
section: "top" as const,
@@ -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<string, number> {
export function buildPageIdIndexMap(
document: PDFDocument | null,
): Map<string, number> {
const map = new Map<string, number>();
if (!document) return map;
document.pages.forEach((page, index) => {
@@ -16,7 +18,10 @@ export function buildPageIdIndexMap(document: PDFDocument | null): Map<string, n
* Convert a set of split page IDs (the page preceding each split) into
* the current index positions inside the document.
*/
export function convertSplitPageIdsToIndexes(document: PDFDocument | null, splitPageIds: Set<string>): Set<number> {
export function convertSplitPageIdsToIndexes(
document: PDFDocument | null,
splitPageIds: Set<string>,
): Set<number> {
const indexes = new Set<number>();
if (!document || !splitPageIds || splitPageIds.size === 0) {
return indexes;
@@ -7,6 +7,8 @@ interface QuickAccessBarFooterExtensionsProps {
className?: string;
}
export function QuickAccessBarFooterExtensions(_props: QuickAccessBarFooterExtensionsProps) {
export function QuickAccessBarFooterExtensions(
_props: QuickAccessBarFooterExtensionsProps,
) {
return null;
}
@@ -2,6 +2,8 @@ interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
export function RightRailFooterExtensions(
_props: RightRailFooterExtensionsProps,
) {
return null;
}
@@ -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<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
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<AllToolsNavButtonProps> = ({ 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();
@@ -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<AppConfigModalProps> = ({ opened, onClose }) => {
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
opened,
onClose,
}) => {
const [active, setActive] = useState<NavKey>("general");
const isMobile = useIsMobile();
const navigate = useNavigate();
@@ -42,7 +57,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ 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<AppConfigModalProps> = ({ 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<AppConfigModalProps> = ({ 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<AppConfigModalProps> = ({ opened, onClose })
{configNavSections.map((section) => (
<div key={section.title} className="modal-nav-section">
{!isMobile && (
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: "uppercase", letterSpacing: 0.4 }}>
<Text
size="xs"
fw={600}
c={colors.sectionTitle}
style={{ textTransform: "uppercase", letterSpacing: 0.4 }}
>
{section.title}
</Text>
)}
@@ -159,10 +191,14 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ 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 = (
<div
@@ -174,20 +210,32 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ 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`}
>
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
<LocalIcon
icon={item.icon}
width={iconSize}
height={iconSize}
style={{ color }}
/>
{!isMobile && (
<Group gap={4} align="center" wrap="nowrap">
<Text size="sm" fw={500} style={{ color }}>
{item.label}
</Text>
{item.badge && (
<Badge size="xs" variant="light" color={item.badgeColor ?? "orange"} style={{ flexShrink: 0 }}>
<Badge
size="xs"
variant="light"
color={item.badgeColor ?? "orange"}
style={{ flexShrink: 0 }}
>
{item.badge}
</Badge>
)}
@@ -196,7 +244,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
icon="warning-rounded"
width={14}
height={14}
style={{ color: "var(--mantine-color-orange-7)" }}
style={{
color: "var(--mantine-color-orange-7)",
}}
/>
)}
</Group>
@@ -215,7 +265,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{navItemContent}
</Tooltip>
) : (
<React.Fragment key={item.key}>{navItemContent}</React.Fragment>
<React.Fragment key={item.key}>
{navItemContent}
</React.Fragment>
);
})}
</div>
@@ -239,8 +291,18 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{activeLabel}
</Text>
<Group gap="xs" wrap="nowrap">
<SettingsSearchBar configNavSections={configNavSections} onNavigate={handleNavigation} isMobile={isMobile} />
<ActionIcon ref={closeButtonRef} variant="subtle" onClick={handleClose} aria-label="Close" data-autofocus>
<SettingsSearchBar
configNavSections={configNavSections}
onNavigate={handleNavigation}
isMobile={isMobile}
/>
<ActionIcon
ref={closeButtonRef}
variant="subtle"
onClick={handleClose}
aria-label="Close"
data-autofocus
>
<LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon>
</Group>

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