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/pdfjs*/
public/js/thirdParty/ public/js/thirdParty/
public/css/cookieconsent.css public/css/cookieconsent.css
src-tauri/target/
*.min.* *.min.*
*.md *.md
*.wxs *.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 = [ const baseRestrictedImportPatterns = [
{ regex: "^\\.", message: "Use @app/* imports instead of relative imports." }, { 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( export default defineConfig(
@@ -64,7 +67,8 @@ export default defineConfig(
...baseRestrictedImportPatterns, ...baseRestrictedImportPatterns,
{ {
regex: "^@tauri-apps/", 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" /> <link rel="icon" href="modern-logo/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#000000" /> <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="apple-touch-icon" href="modern-logo/logo192.png" />
<link rel="manifest" href="manifest.json" /> <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 tauriDir = resolve(frontendDir, "src-tauri");
const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml"); 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)) { if (!existsSync(provisionerExe)) {
throw new Error(`Provisioner binary not found at ${provisionerExe}`); throw new Error(`Provisioner binary not found at ${provisionerExe}`);
} }
@@ -26,9 +36,19 @@ copyFileSync(provisionerExe, destExe);
// --- Thumbnail handler DLL --- // --- Thumbnail handler DLL ---
const thumbManifest = join(tauriDir, "thumbnail-handler", "Cargo.toml"); 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)) { if (!existsSync(thumbDll)) {
throw new Error(`Thumbnail handler DLL not found at ${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"); const path = require("path");
// Check for verbose flag // 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 // Logging functions
const info = (message) => console.log(message); const info = (message) => console.log(message);
@@ -41,62 +42,82 @@ function scanForUsedIcons() {
const content = fs.readFileSync(filePath, "utf8"); const content = fs.readFileSync(filePath, "utf8");
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...> // 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) { if (localIconMatches) {
localIconMatches.forEach((match) => { localIconMatches.forEach((match) => {
const iconMatch = match.match(/icon="([^"]+)"/); const iconMatch = match.match(/icon="([^"]+)"/);
if (iconMatch) { if (iconMatch) {
usedIcons.add(iconMatch[1]); 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' ...> // 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) { if (localIconSingleQuoteMatches) {
localIconSingleQuoteMatches.forEach((match) => { localIconSingleQuoteMatches.forEach((match) => {
const iconMatch = match.match(/icon='([^']+)'/); const iconMatch = match.match(/icon='([^']+)'/);
if (iconMatch) { if (iconMatch) {
usedIcons.add(iconMatch[1]); 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> // 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) { if (spanMatches) {
spanMatches.forEach((match) => { spanMatches.forEach((match) => {
const iconMatch = match.match(/>([^<]+)<\/span>/); const iconMatch = match.match(/>([^<]+)<\/span>/);
if (iconMatch && iconMatch[1].trim()) { if (iconMatch && iconMatch[1].trim()) {
const iconName = iconMatch[1].trim(); const iconName = iconMatch[1].trim();
usedIcons.add(iconName); 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" ...> // 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) { if (iconMatches) {
iconMatches.forEach((match) => { iconMatches.forEach((match) => {
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/); const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
if (iconMatch) { if (iconMatch) {
usedIcons.add(iconMatch[1]); 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" // 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) { if (iconPropertyMatches) {
iconPropertyMatches.forEach((match) => { iconPropertyMatches.forEach((match) => {
const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/); const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/);
if (iconMatch) { if (iconMatch) {
usedIcons.add(iconMatch[2]); 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(); const usedIcons = scanForUsedIcons();
// Check if we need to regenerate (compare with existing) // 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; let needsRegeneration = true;
if (fs.existsSync(outputPath)) { if (fs.existsSync(outputPath)) {
@@ -159,11 +186,17 @@ async function main() {
// Check for missing icons // Check for missing icons
const extractedIconNames = Object.keys(extractedIcons.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) { if (missingIcons.length > 0) {
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`); info(
info("💡 These icons don't exist in Material Symbols. Please use available alternatives."); `⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`,
);
info(
"💡 These icons don't exist in Material Symbols. Please use available alternatives.",
);
} }
// Create output directory // Create output directory
@@ -175,8 +208,12 @@ async function main() {
// Write the extracted icon set to a file (outputPath already defined above) // Write the extracted icon set to a file (outputPath already defined above)
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2)); fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`); info(
info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`); `✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`,
);
info(
`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`,
);
info(`💾 Saved to: ${outputPath}`); info(`💾 Saved to: ${outputPath}`);
// Generate TypeScript types // Generate TypeScript types
+97 -28
View File
@@ -1,7 +1,12 @@
#!/usr/bin/env node #!/usr/bin/env node
const { execSync } = require("node:child_process"); 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 path = require("node:path");
const { argv } = require("node:process"); 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 * 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"); const PACKAGE_JSON = path.join(__dirname, "..", "package.json");
// Ensure the output directory exists // Ensure the output directory exists
@@ -30,7 +41,9 @@ console.log("🔍 Generating frontend license report...");
try { try {
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK) // Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) { 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); process.exit(2);
} }
@@ -90,13 +103,21 @@ try {
licenseType = "Unknown"; licenseType = "Unknown";
} }
if ("posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) { if (
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE"; "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 { return {
name: dep.name, name: dep.name,
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || "unknown", version:
dep.installedVersion ||
dep.definedVersion ||
dep.remoteVersion ||
"unknown",
licenseType: licenseType, licenseType: licenseType,
repository: dep.link, repository: dep.link,
url: dep.link, url: dep.link,
@@ -107,12 +128,17 @@ try {
// Transform to match Java backend format // Transform to match Java backend format
const transformedData = { const transformedData = {
dependencies: licenseArray.map((dep) => { 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); const licenseUrl = dep.link || getLicenseUrl(licenseType);
return { return {
moduleName: dep.name, 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, moduleVersion: dep.version,
moduleLicense: licenseType, moduleLicense: licenseType,
moduleLicenseUrl: licenseUrl, moduleLicenseUrl: licenseUrl,
@@ -122,7 +148,9 @@ try {
// Log summary of license types found // Log summary of license types found
const licenseSummary = licenseArray.reduce((acc, dep) => { 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; acc[license] = (acc[license] || 0) + 1;
return acc; return acc;
}, {}); }, {});
@@ -150,7 +178,10 @@ try {
} }
// Check for potentially problematic licenses // Check for potentially problematic licenses
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray); const problematicLicenses = checkLicenseCompatibility(
licenseSummary,
licenseArray,
);
if (problematicLicenses.length > 0) { if (problematicLicenses.length > 0) {
console.log("\n⚠️ License compatibility warnings:"); console.log("\n⚠️ License compatibility warnings:");
problematicLicenses.forEach((warning) => { problematicLicenses.forEach((warning) => {
@@ -158,7 +189,13 @@ try {
}); });
// Write license warnings to a separate file for CI/CD // 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( writeFileSync(
warningsFile, warningsFile,
JSON.stringify( JSON.stringify(
@@ -257,15 +294,21 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
// Copyleft licenses // Copyleft licenses
"GPL-2.0": "Strong copyleft license - requires derivative works to be GPL", "GPL-2.0": "Strong copyleft license - requires derivative works to be GPL",
"GPL-3.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-2.1":
"LGPL-3.0": "Weak copyleft license - may require source disclosure for modifications", "Weak copyleft license - may require source disclosure for modifications",
"AGPL-3.0": "Network copyleft license - requires source disclosure for network use", "LGPL-3.0":
"AGPL-1.0": "Network copyleft license - requires source disclosure for network use", "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 // Other potentially problematic licenses
WTFPL: "Potentially problematic license - legal uncertainty", WTFPL: "Potentially problematic license - legal uncertainty",
"CC-BY-SA-4.0": "ShareAlike license - requires derivative works to use same license", "CC-BY-SA-4.0":
"CC-BY-SA-3.0": "ShareAlike license - requires derivative works to use same license", "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-4.0": "Non-commercial license - prohibits commercial use",
"CC-BY-NC-3.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", "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 // Check if this license only affects our own packages
const affectedPackages = licenseArray.filter((dep) => { 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; return depLicense === license;
}); });
@@ -335,7 +380,10 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
dep.name.toLowerCase().includes("stirlingpdf"), 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 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 // 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) { if (hasProblematicComponent) {
const affectedPackages = licenseArray const affectedPackages = licenseArray
.filter((dep) => { .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; return depLicense === license;
}) })
.map((dep) => ({ .map((dep) => ({
name: dep.name, name: dep.name,
version: dep.version, 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"; const licenseType = license.includes("AND") ? "AND" : "OR";
@@ -397,21 +452,30 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
if (problematicLicenses[license]) { if (problematicLicenses[license]) {
const affectedPackages = licenseArray const affectedPackages = licenseArray
.filter((dep) => { .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; return depLicense === license;
}) })
.map((dep) => ({ .map((dep) => ({
name: dep.name, name: dep.name,
version: dep.version, 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 = const packageList =
affectedPackages affectedPackages
.map((pkg) => pkg.name) .map((pkg) => pkg.name)
.slice(0, 5) .slice(0, 5)
.join(", ") + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : ""); .join(", ") +
const licenseUrl = getLicenseUrl(license) || "https://opensource.org/licenses"; (affectedPackages.length > 5
? `, and ${affectedPackages.length - 5} more`
: "");
const licenseUrl =
getLicenseUrl(license) || "https://opensource.org/licenses";
warnings.push({ warnings.push({
message: `⚠️ This PR contains ${count} package${count > 1 ? "s" : ""} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`, 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 // Unknown license type - flag for manual review
const affectedPackages = licenseArray const affectedPackages = licenseArray
.filter((dep) => { .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; return depLicense === license;
}) })
.map((dep) => ({ .map((dep) => ({
name: dep.name, name: dep.name,
version: dep.version, 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({ warnings.push({
+2 -2
View File
@@ -21,8 +21,8 @@
/* Font Stack */ /* Font Stack */
--font-family: --font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
"Helvetica Neue", sans-serif; "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
} }
* { * {
+104 -23
View File
@@ -10,15 +10,39 @@
<!-- Page 1: Hero / Cover Page --> <!-- Page 1: Hero / Cover Page -->
<div class="page page-1"> <div class="page page-1">
<div class="decorative-shapes"> <div class="decorative-shapes">
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="" /> <img
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="" /> src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="" /> class="shape shape-1"
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="" /> alt=""
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" 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>
<div class="hero-content"> <div class="hero-content">
<div class="logo-container"> <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> </div>
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1> <h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
<div class="hero-stats"> <div class="hero-stats">
@@ -40,21 +64,31 @@
<div class="content-wrapper"> <div class="content-wrapper">
<h2 class="page-title">What is Stirling PDF?</h2> <h2 class="page-title">What is Stirling PDF?</h2>
<p class="intro-text"> <p class="intro-text">
Stirling PDF is a robust, web-based PDF manipulation tool. It enables you to carry out various operations on PDF Stirling PDF is a robust, web-based PDF manipulation tool. It enables
files, including splitting, merging, converting, rearranging, adding images, rotating, compressing, and more. you to carry out various operations on PDF files, including splitting,
merging, converting, rearranging, adding images, rotating,
compressing, and more.
</p> </p>
<div class="value-props"> <div class="value-props">
<div class="value-prop"> <div class="value-prop">
<div class="value-icon"> <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 <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" 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> </svg>
</div> </div>
<h3>50+ PDF Operations</h3> <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>
<div class="value-prop"> <div class="value-prop">
@@ -66,24 +100,42 @@
</svg> </svg>
</div> </div>
<h3>Workflow Automation</h3> <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>
<div class="value-prop"> <div class="value-prop">
<div class="value-icon"> <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" /> <circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" /> <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> </svg>
</div> </div>
<h3>Multi-Language Support</h3> <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>
<div class="value-prop"> <div class="value-prop">
<div class="value-icon"> <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" /> <rect x="5" y="11" width="14" height="10" rx="2" />
<circle cx="12" cy="16" r="1" /> <circle cx="12" cy="16" r="1" />
<path d="M8 11V7a4 4 0 0 1 8 0v4" /> <path d="M8 11V7a4 4 0 0 1 8 0v4" />
@@ -91,30 +143,47 @@
</div> </div>
<h3>Privacy First</h3> <h3>Privacy First</h3>
<p> <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> </p>
</div> </div>
<div class="value-prop"> <div class="value-prop">
<div class="value-icon"> <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" /> <circle cx="12" cy="12" r="10" />
<path d="m9 12 2 2 4-4" /> <path d="m9 12 2 2 4-4" />
</svg> </svg>
</div> </div>
<h3>Open Source</h3> <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>
<div class="value-prop"> <div class="value-prop">
<div class="value-icon"> <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="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" /> <polyline points="8 6 2 12 8 18" />
</svg> </svg>
</div> </div>
<h3>API Access</h3> <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> </div>
</div> </div>
@@ -129,8 +198,15 @@
<div class="feature-card" data-category="general"> <div class="feature-card" data-category="general">
<div class="feature-header"> <div class="feature-header">
<div class="feature-icon-large"> <div class="feature-icon-large">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /> 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" /> <polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" /> <line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" /> <line x1="16" y1="17" x2="8" y2="17" />
@@ -151,7 +227,12 @@
<div class="feature-card" data-category="security"> <div class="feature-card" data-category="security">
<div class="feature-header"> <div class="feature-header">
<div class="feature-icon-large"> <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" /> <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" /> <path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg> </svg>
+11 -4
View File
@@ -20,7 +20,9 @@ const args = process.argv.slice(2);
const isDesktop = args.includes("--desktop"); const isDesktop = args.includes("--desktop");
const isSaas = args.includes("--saas"); 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[] { function getExampleKeys(exampleFile: string): string[] {
const examplePath = join(root, exampleFile); const examplePath = join(root, exampleFile);
@@ -44,7 +46,9 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean {
config({ path: envPath }); 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) { if (missing.length > 0) {
console.error( console.error(
@@ -62,7 +66,8 @@ let failed = false;
failed = ensureEnvFile(".env", "config/.env.example") || failed; failed = ensureEnvFile(".env", "config/.env.example") || failed;
if (isDesktop) { if (isDesktop) {
failed = ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed; failed =
ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed;
} }
if (isSaas) { if (isSaas) {
@@ -75,7 +80,9 @@ const allExampleKeys = new Set([
...getExampleKeys("config/.env.desktop.example"), ...getExampleKeys("config/.env.desktop.example"),
...getExampleKeys("config/.env.saas.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) { if (unknownViteVars.length > 0) {
console.warn( console.warn(
"setup-env: the following VITE_ vars are set but not listed in any example file:\n" + "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; height: 100% !important;
} }
`}</style> `}</style>
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}> <div
style={{ height: "100vh", display: "flex", flexDirection: "column" }}
>
{banner} {banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div> <div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
</div> </div>
+26 -7
View File
@@ -7,7 +7,10 @@ import { FilesModalProvider } from "@app/contexts/FilesModalContext";
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext"; import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
import { HotkeyProvider } from "@app/contexts/HotkeyContext"; import { HotkeyProvider } from "@app/contexts/HotkeyContext";
import { SidebarProvider } from "@app/contexts/SidebarContext"; import { SidebarProvider } from "@app/contexts/SidebarContext";
import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext"; import {
PreferencesProvider,
usePreferences,
} from "@app/contexts/PreferencesContext";
import { import {
AppConfigProvider, AppConfigProvider,
AppConfigProviderProps, AppConfigProviderProps,
@@ -67,7 +70,10 @@ function BrandingAssetManager() {
} }
// Avoid requirement to have props which are required in app providers anyway // 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 { export interface AppProvidersProps {
children: ReactNode; children: ReactNode;
@@ -84,7 +90,8 @@ function ServerDefaultsSync() {
if (config) { if (config) {
const serverDefaults = { const serverDefaults = {
hideUnavailableTools: config.defaultHideUnavailableTools ?? false, hideUnavailableTools: config.defaultHideUnavailableTools ?? false,
hideUnavailableConversions: config.defaultHideUnavailableConversions ?? false, hideUnavailableConversions:
config.defaultHideUnavailableConversions ?? false,
}; };
updateServerDefaults(serverDefaults); updateServerDefaults(serverDefaults);
} }
@@ -97,17 +104,27 @@ function ServerDefaultsSync() {
* Core application providers * Core application providers
* Contains all providers needed for the core * Contains all providers needed for the core
*/ */
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) { export function AppProviders({
children,
appConfigRetryOptions,
appConfigProviderProps,
}: AppProvidersProps) {
return ( return (
<PreferencesProvider> <PreferencesProvider>
<RainbowThemeProvider> <RainbowThemeProvider>
<ErrorBoundary> <ErrorBoundary>
<BannerProvider> <BannerProvider>
<AppConfigProvider retryOptions={appConfigRetryOptions} {...appConfigProviderProps}> <AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer /> <ScarfTrackingInitializer />
<AppConfigLoader /> <AppConfigLoader />
<ServerDefaultsSync /> <ServerDefaultsSync />
<FileContextProvider enableUrlSync={true} enablePersistence={true}> <FileContextProvider
enableUrlSync={true}
enablePersistence={true}
>
<AppInitializer /> <AppInitializer />
<BrandingAssetManager /> <BrandingAssetManager />
<ToolRegistryProvider> <ToolRegistryProvider>
@@ -124,7 +141,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<AnnotationProvider> <AnnotationProvider>
<RightRailProvider> <RightRailProvider>
<TourOrchestrationProvider> <TourOrchestrationProvider>
<AdminTourOrchestrationProvider>{children}</AdminTourOrchestrationProvider> <AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider> </TourOrchestrationProvider>
</RightRailProvider> </RightRailProvider>
</AnnotationProvider> </AnnotationProvider>
+19 -4
View File
@@ -11,7 +11,10 @@ import DesktopLayout from "@app/components/fileManager/DesktopLayout";
import DragOverlay from "@app/components/fileManager/DragOverlay"; import DragOverlay from "@app/components/fileManager/DragOverlay";
import { FileManagerProvider } from "@app/contexts/FileManagerContext"; import { FileManagerProvider } from "@app/contexts/FileManagerContext";
import { Z_INDEX_FILE_MANAGER_MODAL } from "@app/styles/zIndex"; 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 { loadScript } from "@app/utils/scriptLoader";
import { useAllFiles } from "@app/contexts/FileContext"; import { useAllFiles } from "@app/contexts/FileContext";
@@ -20,7 +23,12 @@ interface FileManagerProps {
} }
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => { const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext(); const {
isFilesModalOpen,
closeFilesModal,
onFileUpload,
onRecentFileSelect,
} = useFilesModalContext();
const { config } = useAppConfig(); const { config } = useAppConfig();
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]); const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -101,7 +109,9 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
return () => { return () => {
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed // StoredFileMetadata doesn't have blob URLs, so no cleanup needed
// Blob URLs are managed by FileContext and tool operations // 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 // Use useMemo to only track Google Drive config changes, not all config updates
const googleDriveBackendConfig = useMemo( const googleDriveBackendConfig = useMemo(
() => extractGoogleDriveBackendConfig(config), () => extractGoogleDriveBackendConfig(config),
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId], [
config?.googleDriveEnabled,
config?.googleDriveClientId,
config?.googleDriveApiKey,
config?.googleDriveAppId,
],
); );
useEffect(() => { useEffect(() => {
@@ -14,7 +14,12 @@ interface StorageStatsCardProps {
onReloadFiles: () => void; onReloadFiles: () => void;
} }
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => { const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
storageStats,
filesCount,
onClearAll,
onReloadFiles,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!storageStats) return null; if (!storageStats) return null;
@@ -27,19 +32,27 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, files
<StorageIcon /> <StorageIcon />
<div style={{ flex: 1 }}> <div style={{ flex: 1 }}>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{t("fileManager.storage", "Storage")}: {formatFileSize(storageStats.used)} {t("fileManager.storage", "Storage")}:{" "}
{formatFileSize(storageStats.used)}
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`} {storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`}
</Text> </Text>
{storageStats.quota && ( {storageStats.quota && (
<Progress <Progress
value={storageUsagePercent} value={storageUsagePercent}
color={storageUsagePercent > 80 ? "red" : storageUsagePercent > 60 ? "yellow" : "blue"} color={
storageUsagePercent > 80
? "red"
: storageUsagePercent > 60
? "yellow"
: "blue"
}
size="sm" size="sm"
mt={4} mt={4}
/> />
)} )}
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{storageStats.fileCount} {t("fileManager.filesStored", "files stored")} {storageStats.fileCount}{" "}
{t("fileManager.filesStored", "files stored")}
</Text> </Text>
</div> </div>
<Group gap="xs"> <Group gap="xs">
@@ -54,7 +67,12 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, files
{t("fileManager.clearAll", "Clear All")} {t("fileManager.clearAll", "Clear All")}
</Button> </Button>
)} )}
<Button variant="light" color="blue" size="xs" onClick={onReloadFiles}> <Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
Reload Files Reload Files
</Button> </Button>
</Group> </Group>
@@ -26,7 +26,9 @@ interface PDFAnnotationContextValue {
setSignatureConfig: (config: any | null) => void; setSignatureConfig: (config: any | null) => void;
} }
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined); const PDFAnnotationContext = createContext<
PDFAnnotationContextValue | undefined
>(undefined);
interface PDFAnnotationProviderProps { interface PDFAnnotationProviderProps {
children: ReactNode; children: ReactNode;
@@ -75,13 +77,19 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
setSignatureConfig, setSignatureConfig,
}; };
return <PDFAnnotationContext.Provider value={contextValue}>{children}</PDFAnnotationContext.Provider>; return (
<PDFAnnotationContext.Provider value={contextValue}>
{children}
</PDFAnnotationContext.Provider>
);
}; };
export const usePDFAnnotation = (): PDFAnnotationContextValue => { export const usePDFAnnotation = (): PDFAnnotationContextValue => {
const context = useContext(PDFAnnotationContext); const context = useContext(PDFAnnotationContext);
if (context === undefined) { 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; return context;
}; };
@@ -34,7 +34,10 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
const [selectedColor, setSelectedColor] = useState("#000000"); const [selectedColor, setSelectedColor] = useState("#000000");
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(null); 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; const historyApiInstance = historyApiRef.current;
useEffect(() => { useEffect(() => {
@@ -77,7 +80,9 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
onRedo={redo} onRedo={redo}
canUndo={historyAvailability.canUndo} canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo} canRedo={historyAvailability.canRedo}
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined} onPlaceSignature={
config.showPlaceButton ? handlePlaceSignature : undefined
}
hasSignatureData={!!signatureData} hasSignatureData={!!signatureData}
disabled={disabled} disabled={disabled}
showPlaceButton={config.showPlaceButton} showPlaceButton={config.showPlaceButton}
@@ -94,8 +99,13 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
})} })}
{/* Instructions for placing signature */} {/* Instructions for placing signature */}
<Alert color="blue" title={t("sign.instructions.title", "How to add signature")}> <Alert
<Text size="sm">Click anywhere on the PDF to place your annotation.</Text> 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> </Alert>
{/* Color Picker Modal */} {/* 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 { useState, useCallback, useEffect } from "react";
import ColorizeIcon from "@mui/icons-material/Colorize"; import ColorizeIcon from "@mui/icons-material/Colorize";
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do. // 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. // 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 { interface EyeDropper {
open(): Promise<{ sRGBHex: string }>; open(): Promise<{ sRGBHex: string }>;
@@ -18,7 +27,12 @@ interface ColorControlProps {
disabled?: boolean; 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); const [opened, setOpened] = useState(false);
// Buffer the colour locally so the picker stays responsive during drag. // Buffer the colour locally so the picker stays responsive during drag.
// Only propagate to the parent (which triggers expensive annotation updates) // Only propagate to the parent (which triggers expensive annotation updates)
@@ -40,7 +54,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
}, [onChange]); }, [onChange]);
return ( return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow withinPortal> <Popover
opened={opened}
onChange={setOpened}
position="bottom"
withArrow
withinPortal
>
<Popover.Target> <Popover.Target>
<Tooltip label={label}> <Tooltip label={label}>
<ActionIcon <ActionIcon
@@ -1,5 +1,14 @@
import React from "react"; 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"; import { useTranslation } from "react-i18next";
interface ColorPickerProps { interface ColorPickerProps {
@@ -27,16 +36,30 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const resolvedTitle = title ?? t("colorPicker.title", "Choose colour"); const resolvedTitle = title ?? t("colorPicker.title", "Choose colour");
const resolvedOpacityLabel = opacityLabel ?? t("annotation.opacity", "Opacity"); const resolvedOpacityLabel =
opacityLabel ?? t("annotation.opacity", "Opacity");
return ( return (
<Modal opened={isOpen} onClose={onClose} title={resolvedTitle} size="sm" centered> <Modal
opened={isOpen}
onClose={onClose}
title={resolvedTitle}
size="sm"
centered
>
<Stack gap="md"> <Stack gap="md">
<MantineColorPicker <MantineColorPicker
format="hex" format="hex"
value={selectedColor} value={selectedColor}
onChange={onColorChange} onChange={onColorChange}
swatches={["#000000", "#0066cc", "#cc0000", "#cc6600", "#009900", "#6600cc"]} swatches={[
"#000000",
"#0066cc",
"#cc0000",
"#cc6600",
"#009900",
"#6600cc",
]}
swatchesPerRow={6} swatchesPerRow={6}
size="lg" size="lg"
fullWidth fullWidth
@@ -74,6 +97,18 @@ interface ColorSwatchButtonProps {
size?: number; size?: number;
} }
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({ color, onClick, size = 24 }) => { export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
return <ColorSwatch color={color} size={size} radius={0} style={{ cursor: "pointer" }} onClick={onClick} />; 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 modalCanvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null); const padRef = useRef<SignaturePad | null>(null);
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(null); const [savedSignatureData, setSavedSignatureData] = useState<string | null>(
null,
);
const initPad = (canvas: HTMLCanvasElement) => { const initPad = (canvas: HTMLCanvasElement) => {
if (!padRef.current) { if (!padRef.current) {
@@ -125,7 +127,17 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
trimmedCanvas.height = trimHeight; trimmedCanvas.height = trimHeight;
const trimmedCtx = trimmedCanvas.getContext("2d"); const trimmedCtx = trimmedCanvas.getContext("2d");
if (trimmedCtx) { 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"); return trimmedCanvas.toDataURL("image/png");
@@ -140,7 +152,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height); 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 scaledWidth = img.width * scale;
const scaledHeight = img.height * scale; const scaledHeight = img.height * scale;
const x = (canvas.width - scaledWidth) / 2; const x = (canvas.width - scaledWidth) / 2;
@@ -181,7 +196,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
if (previewCanvasRef.current) { if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext("2d"); const ctx = previewCanvasRef.current.getContext("2d");
if (ctx) { 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 setSavedSignatureData(null); // Clear saved signature
@@ -230,7 +250,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<Paper withBorder p="md"> <Paper withBorder p="md">
<Stack gap="sm"> <Stack gap="sm">
<PrivateContent> <PrivateContent>
<Text fw={500}>{t("sign.canvas.heading", "Draw your signature")}</Text> <Text fw={500}>
{t("sign.canvas.heading", "Draw your signature")}
</Text>
<canvas <canvas
ref={previewCanvasRef} ref={previewCanvasRef}
width={width} width={width}
@@ -264,7 +286,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{t("sign.canvas.colorLabel", "Colour")} {t("sign.canvas.colorLabel", "Colour")}
</Text> </Text>
<ColorSwatchButton color={selectedColor} onClick={onColorSwatchClick} /> <ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</Stack> </Stack>
<Stack gap={4} style={{ minWidth: 120 }}> <Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
@@ -44,7 +44,12 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
disabled={undoDisabled} disabled={undoDisabled}
color={undoDisabled ? "gray" : "blue"} 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> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
@@ -58,7 +63,12 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
disabled={redoDisabled} disabled={redoDisabled}
color={redoDisabled ? "gray" : "blue"} 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> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
@@ -67,7 +77,13 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
{/* Place Signature Button */} {/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && ( {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} {placeButtonText}
</Button> </Button>
)} )}
@@ -27,22 +27,33 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
const { t } = useTranslation(); const { t } = useTranslation();
const [removeBackground, setRemoveBackground] = useState(false); const [removeBackground, setRemoveBackground] = useState(false);
const [currentFile, setCurrentFile] = useState<File | null>(null); 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 [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) { if (shouldRemoveBackground && allowBackgroundRemoval) {
setIsProcessing(true); setIsProcessing(true);
try { try {
const transparentImageDataUrl = await removeWhiteBackground(imageSource, { const transparentImageDataUrl = await removeWhiteBackground(
autoDetectCorner: true, imageSource,
tolerance: 15, {
}); autoDetectCorner: true,
tolerance: 15,
},
);
onProcessedImageData?.(transparentImageDataUrl); onProcessedImageData?.(transparentImageDataUrl);
} catch (error) { } catch (error) {
console.error("Error removing background:", error); console.error("Error removing background:", error);
alert({ alert({
title: t("sign.image.backgroundRemovalFailedTitle", "Background removal failed"), title: t(
"sign.image.backgroundRemovalFailedTitle",
"Background removal failed",
),
body: t( body: t(
"sign.image.backgroundRemovalFailedMessage", "sign.image.backgroundRemovalFailedMessage",
"Could not remove the background from the image. Using original image instead.", "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) { if (file && !disabled) {
try { try {
// Validate that it's actually an image file or SVG // 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"); console.error("Selected file is not an image or SVG");
return; return;
} }
@@ -83,7 +97,9 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
let dataUrlToProcess: string; let dataUrlToProcess: string;
// Check if file is SVG // 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) { if (isSvg) {
// For SVG, convert to PNG so it can be embedded in PDF // 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 width = 800; // Default width
let height = 600; // Default height 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"); width = parseFloat(svgElement.getAttribute("width") || "800");
height = parseFloat(svgElement.getAttribute("height") || "600"); height = parseFloat(svgElement.getAttribute("height") || "600");
} else if (svgElement.hasAttribute("viewBox")) { } else if (svgElement.hasAttribute("viewBox")) {
@@ -141,7 +160,12 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
} }
// Ensure reasonable dimensions // Ensure reasonable dimensions
if (width === 0 || height === 0 || !isFinite(width) || !isFinite(height)) { if (
width === 0 ||
height === 0 ||
!isFinite(width) ||
!isFinite(height)
) {
width = 800; width = 800;
height = 600; height = 600;
} }
@@ -158,7 +182,9 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
// Create an image element to render SVG // Create an image element to render SVG
const img = new Image(); 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); const url = URL.createObjectURL(blob);
img.onload = () => { img.onload = () => {
@@ -239,7 +265,9 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
<PrivateContent> <PrivateContent>
<FileInput <FileInput
label={label} label={label}
placeholder={placeholder || t("sign.image.placeholder", "Select image file")} placeholder={
placeholder || t("sign.image.placeholder", "Select image file")
}
accept="image/*,.svg" accept="image/*,.svg"
onChange={handleImageChange} onChange={handleImageChange}
disabled={disabled || isProcessing} disabled={disabled || isProcessing}
@@ -247,9 +275,14 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
</PrivateContent> </PrivateContent>
{allowBackgroundRemoval && ( {allowBackgroundRemoval && (
<Checkbox <Checkbox
label={t("sign.image.removeBackground", "Remove white background (make transparent)")} label={t(
"sign.image.removeBackground",
"Remove white background (make transparent)",
)}
checked={removeBackground} checked={removeBackground}
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)} onChange={(event) =>
handleBackgroundRemovalChange(event.currentTarget.checked)
}
disabled={disabled || !currentFile || isProcessing} 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 { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
import OpacityIcon from "@mui/icons-material/Opacity"; import OpacityIcon from "@mui/icons-material/Opacity";
@@ -9,7 +16,11 @@ interface OpacityControlProps {
disabled?: boolean; disabled?: boolean;
} }
export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) { export function OpacityControl({
value,
onChange,
disabled = false,
}: OpacityControlProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
@@ -46,7 +57,13 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
<Text size="xs" fw={500}> <Text size="xs" fw={500}>
{t("annotation.opacity", "Opacity")} {t("annotation.opacity", "Opacity")}
</Text> </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> </Stack>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </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 { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation"; import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
@@ -18,7 +27,12 @@ interface PropertiesPopoverProps {
disabled?: boolean; disabled?: boolean;
} }
export function PropertiesPopover({ annotationType, annotation, onUpdate, disabled = false }: PropertiesPopoverProps) { export function PropertiesPopover({
annotationType,
annotation,
onUpdate,
disabled = false,
}: PropertiesPopoverProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
@@ -30,7 +44,9 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
strokeWidth?: number; strokeWidth?: number;
} }
const obj = annotation?.object as (PdfAnnotationObject & AnnotationObjectProps) | undefined; const obj = annotation?.object as
| (PdfAnnotationObject & AnnotationObjectProps)
| undefined;
// Get current values // Get current values
const fontSize = obj?.fontSize ?? 14; 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> </Button>
</Group> </Group>
</div> </div>
@@ -207,7 +225,8 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
</Tooltip> </Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
{(annotationType === "text" || annotationType === "note") && renderTextNoteControls()} {(annotationType === "text" || annotationType === "note") &&
renderTextNoteControls()}
{annotationType === "shape" && renderShapeControls()} {annotationType === "shape" && renderShapeControls()}
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -1,5 +1,14 @@
import React, { useState, useEffect } from "react"; 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 { useTranslation } from "react-i18next";
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker"; 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 { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
import LineWeightIcon from "@mui/icons-material/LineWeight"; import LineWeightIcon from "@mui/icons-material/LineWeight";
@@ -11,7 +18,13 @@ interface WidthControlProps {
disabled?: boolean; 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 { t } = useTranslation();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
@@ -48,7 +61,13 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
<Text size="xs" fw={500}> <Text size="xs" fw={500}>
{t("annotation.width", "Width")} {t("annotation.width", "Width")}
</Text> </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> </Stack>
</Popover.Dropdown> </Popover.Dropdown>
</Popover> </Popover>
@@ -8,7 +8,10 @@ interface DrawingToolProps {
disabled?: boolean; 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 [selectedColor] = useState("#000000");
const [penSize, setPenSize] = useState(2); const [penSize, setPenSize] = useState(2);
const [penSizeInput, setPenSizeInput] = useState("2"); const [penSizeInput, setPenSizeInput] = useState("2");
@@ -20,7 +23,11 @@ export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disab
}; };
return ( return (
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onDrawingChange} disabled={disabled}> <BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onDrawingChange}
disabled={disabled}
>
<Stack gap="sm"> <Stack gap="sm">
<DrawingCanvas <DrawingCanvas
selectedColor={selectedColor} selectedColor={selectedColor}
@@ -8,7 +8,10 @@ interface ImageToolProps {
disabled?: boolean; 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 [, setImageData] = useState<string | null>(null);
const handleImageUpload = async (file: File | null) => { const handleImageUpload = async (file: File | null) => {
@@ -45,7 +48,11 @@ export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled =
}; };
return ( return (
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onImageChange} disabled={disabled}> <BaseAnnotationTool
config={toolConfig}
onSignatureDataChange={onImageChange}
disabled={disabled}
>
<Stack gap="sm"> <Stack gap="sm">
<ImageUploader <ImageUploader
onImageChange={handleImageUpload} onImageChange={handleImageUpload}
@@ -16,7 +16,11 @@ interface AddFileCardProps {
multiple?: boolean; multiple?: boolean;
} }
const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps) => { const AddFileCard = ({
onFileSelect,
accept,
multiple = true,
}: AddFileCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { openFilesModal } = useFilesModalContext(); const { openFilesModal } = useFilesModalContext();
@@ -84,7 +88,9 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
<div className={styles.logoMark}> <div className={styles.logoMark}>
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} /> <AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
</div> </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 className={styles.kebab} />
</div> </div>
@@ -131,8 +137,15 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
onClick={handleOpenFilesModal} onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)} onMouseEnter={() => setIsUploadHover(false)}
> >
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" /> <LocalIcon
{!isUploadHover && <span>{t("landing.addFiles", "Add Files")}</span>} icon="add"
width="1.5rem"
height="1.5rem"
className="text-[var(--accent-interactive)]"
/>
{!isUploadHover && (
<span>{t("landing.addFiles", "Add Files")}</span>
)}
</Button> </Button>
<Button <Button
aria-label="Upload" aria-label="Upload"
@@ -160,14 +173,22 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
height="1.25rem" height="1.25rem"
style={{ color: "var(--accent-interactive)" }} style={{ color: "var(--accent-interactive)" }}
/> />
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>} {isUploadHover && (
<span style={{ marginLeft: ".5rem" }}>
{terminology.uploadFromComputer}
</span>
)}
</Button> </Button>
</div> </div>
{/* Instruction Text */} {/* Instruction Text */}
<span <span
className="text-[var(--accent-interactive)]" className="text-[var(--accent-interactive)]"
style={{ fontSize: ".8rem", textAlign: "center", marginTop: "0.5rem" }} style={{
fontSize: ".8rem",
textAlign: "center",
marginTop: "0.5rem",
}}
> >
{terminology.dropFilesHere} {terminology.dropFilesHere}
</span> </span>
@@ -1,7 +1,13 @@
import { useState, useCallback, useRef, useMemo, useEffect } from "react"; import { useState, useCallback, useRef, useMemo, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { Dropzone } from "@mantine/dropzone"; 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 { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext"; import { useViewer } from "@app/contexts/ViewerContext";
import { zipFileService } from "@app/services/zipFileService"; import { zipFileService } from "@app/services/zipFileService";
@@ -22,7 +28,10 @@ interface FileEditorProps {
supportedExtensions?: string[]; 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 // Utility function to check if a file extension is supported
const isFileSupported = useCallback( const isFileSupported = useCallback(
(fileName: string): boolean => { (fileName: string): boolean => {
@@ -40,7 +49,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
const { clearAllFileErrors } = fileContextActions; const { clearAllFileErrors } = fileContextActions;
// Extract needed values from state (memoized to prevent infinite loops) // 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 selectedFileIds = state.ui.selectedFileIds;
const totalItems = state.files.ids.length; const totalItems = state.files.ids.length;
const selectedCount = selectedFileIds.length; const selectedCount = selectedFileIds.length;
@@ -58,11 +70,27 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
const [_error, _setError] = useState<string | null>(null); const [_error, _setError] = useState<string | null>(null);
// Toast helpers // Toast helpers
const showStatus = useCallback((message: string, type: "neutral" | "success" | "warning" | "error" = "neutral") => { const showStatus = useCallback(
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 }); (
}, []); message: string,
type: "neutral" | "success" | "warning" | "error" = "neutral",
) => {
alert({
alertType: type,
title: message,
expandable: false,
durationMs: 4000,
});
},
[],
);
const showError = useCallback((message: string) => { 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); const [selectionMode, setSelectionMode] = useState(toolMode);
@@ -83,7 +111,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
}, [toolMode]); }, [toolMode]);
const [showFilePickerModal, setShowFilePickerModal] = useState(false); const [showFilePickerModal, setShowFilePickerModal] = useState(false);
// Get selected file IDs from context (defensive programming) // 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 // Create refs for frequently changing values to stabilize callbacks
const contextSelectedIdsRef = useRef<FileId[]>([]); const contextSelectedIdsRef = useRef<FileId[]>([]);
@@ -95,7 +125,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
const handleSelectAllFiles = useCallback(() => { const handleSelectAllFiles = useCallback(() => {
// Respect maxAllowed: if limited, select the last N files // Respect maxAllowed: if limited, select the last N files
const allIds = state.files.ids; 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); setSelectedFiles(idsToSelect);
try { try {
clearAllFileErrors(); clearAllFileErrors();
@@ -147,7 +179,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
await addFiles(uploadedFiles, { selectFiles: true }); await addFiles(uploadedFiles, { selectFiles: true });
// After auto-selection, enforce maxAllowed if needed // After auto-selection, enforce maxAllowed if needed
if (Number.isFinite(maxAllowed)) { if (Number.isFinite(maxAllowed)) {
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map((r) => r.id); const nowSelectedIds = selectors
.getSelectedStirlingFileStubs()
.map((r) => r.id);
if (nowSelectedIds.length > maxAllowed) { if (nowSelectedIds.length > maxAllowed) {
setSelectedFiles(nowSelectedIds.slice(-maxAllowed)); setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
} }
@@ -155,7 +189,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
showStatus(`Added ${uploadedFiles.length} file(s)`, "success"); showStatus(`Added ${uploadedFiles.length} file(s)`, "success");
} }
} catch (err) { } 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); showError(errorMessage);
console.error("File processing error:", err); console.error("File processing error:", err);
} }
@@ -182,14 +217,18 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
// Add file to selection // Add file to selection
// Determine max files allowed from the active tool (negative or undefined means unlimited) // Determine max files allowed from the active tool (negative or undefined means unlimited)
const rawMax = selectedTool?.maxFiles; 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) { if (maxAllowed === 1) {
// Only one file allowed -> replace selection with the new file // Only one file allowed -> replace selection with the new file
newSelection = [contextFileId]; newSelection = [contextFileId];
} else { } else {
// If at capacity, drop the oldest selected and append the new one // 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]; newSelection = [...currentSelectedIds.slice(1), contextFileId];
} else { } else {
newSelection = [...currentSelectedIds, contextFileId]; 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) // Update context (this automatically updates tool selection since they use the same action)
setSelectedFiles(newSelection); 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 // 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 // Handle multi-file selection reordering
const filesToMove = 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 // Create new order
const newOrder = [...currentIds]; const newOrder = [...currentIds];
// Remove files to move from their current positions (in reverse order to maintain indices) // 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) => { sourceIndices.forEach((index) => {
newOrder.splice(index, 1); newOrder.splice(index, 1);
@@ -278,11 +327,19 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
removeFiles([contextFileId], false); removeFiles([contextFileId], false);
// Remove from context selections // Remove from context selections
const currentSelected = selectedFileIds.filter((id) => id !== contextFileId); const currentSelected = selectedFileIds.filter(
(id) => id !== contextFileId,
);
setSelectedFiles(currentSelected); setSelectedFiles(currentSelected);
} }
}, },
[activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds], [
activeStirlingFileStubs,
selectors,
removeFiles,
setSelectedFiles,
selectedFileIds,
],
); );
const handleDownloadFile = useCallback( const handleDownloadFile = useCallback(
@@ -315,7 +372,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
isDirty: false, isDirty: false,
}); });
} else { } 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) { if (record && file) {
try { try {
// Extract and store files using shared service method // 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) { if (result.success && result.extractedStubs.length > 0) {
// Add extracted file stubs to FileContext // Add extracted file stubs to FileContext
@@ -376,7 +439,12 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
navActions.setWorkbench("viewer"); navActions.setWorkbench("viewer");
} }
}, },
[activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench], [
activeStirlingFileStubs,
setActiveFileId,
setActiveFileIndex,
navActions.setWorkbench,
],
); );
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => { const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
@@ -417,7 +485,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
</Text> </Text>
<Text c="dimmed">No files loaded</Text> <Text c="dimmed">No files loaded</Text>
<Text size="sm" c="dimmed"> <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> </Text>
</Stack> </Stack>
</Center> </Center>
@@ -432,7 +501,12 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
}} }}
> >
{/* Add File Card - only show when files exist */} {/* 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) => { {activeStirlingFileStubs.map((record, index) => {
return ( return (
@@ -6,6 +6,8 @@ interface FileEditorFileNameProps {
file: StirlingFileStub; file: StirlingFileStub;
} }
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => <PrivateContent>{file.name}</PrivateContent>; const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
<PrivateContent>{file.name}</PrivateContent>
);
export default FileEditorFileName; export default FileEditorFileName;
@@ -1,5 +1,15 @@
import React, { useState, useCallback, useRef, useMemo } from "react"; 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 { useIsMobile } from "@app/hooks/useIsMobile";
import { alert } from "@app/components/toast"; import { alert } from "@app/components/toast";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -14,7 +24,10 @@ import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined"; import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import LockOpenIcon from "@mui/icons-material/LockOpen"; import LockOpenIcon from "@mui/icons-material/LockOpen";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; 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 { StirlingFileStub } from "@app/types/fileContext";
import { zipFileService } from "@app/services/zipFileService"; import { zipFileService } from "@app/services/zipFileService";
@@ -24,7 +37,9 @@ import { useFileState } from "@app/contexts/file/fileHooks";
import { FileId } from "@app/types/file"; import { FileId } from "@app/types/file";
import { formatFileSize } from "@app/utils/fileUtils"; import { formatFileSize } from "@app/utils/fileUtils";
import ToolChain from "@app/components/shared/ToolChain"; 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 { downloadFile } from "@app/services/downloadService";
import { PrivateContent } from "@app/components/shared/PrivateContent"; import { PrivateContent } from "@app/components/shared/PrivateContent";
import UploadToServerModal from "@app/components/shared/UploadToServerModal"; import UploadToServerModal from "@app/components/shared/UploadToServerModal";
@@ -42,7 +57,11 @@ interface FileEditorThumbnailProps {
onCloseFile: (fileId: FileId) => void; onCloseFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void; onViewFile: (fileId: FileId) => void;
_onSetStatus: (status: string) => 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; onDownloadFile: (fileId: FileId) => void;
onUnzipFile?: (fileId: FileId) => void; onUnzipFile?: (fileId: FileId) => void;
toolMode?: boolean; toolMode?: boolean;
@@ -67,7 +86,14 @@ const FileEditorThumbnail = ({
const terminology = useFileActionTerminology(); const terminology = useFileActionTerminology();
const icons = useFileActionIcons(); const icons = useFileActionIcons();
const DownloadOutlinedIcon = icons.download; 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 { state, selectors } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id); const hasError = state.ui.errorFileIds.includes(file.id);
@@ -117,18 +143,29 @@ const FileEditorThumbnail = ({
const isCBZ = extLower === "cbz"; const isCBZ = extLower === "cbz";
const isCBR = extLower === "cbr"; const isCBR = extLower === "cbr";
const uploadEnabled = config?.storageEnabled === true; const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; const sharingEnabled =
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled =
sharingEnabled && config?.storageShareLinksEnabled === true;
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false; 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 localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0; const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(file.remoteStorageId); const isUploaded = Boolean(file.remoteStorageId);
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; 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 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 dateLabel = useMemo(() => {
const d = new Date(file.lastModified); const d = new Date(file.lastModified);
@@ -198,7 +235,12 @@ const FileEditorThumbnail = ({
const handleConfirmClose = useCallback(() => { const handleConfirmClose = useCallback(() => {
onCloseFile(file.id); 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); setShowCloseModal(false);
}, [file.id, file.name, onCloseFile]); }, [file.id, file.name, onCloseFile]);
@@ -222,16 +264,33 @@ const FileEditorThumbnail = ({
} }
} catch (error) { } catch (error) {
console.error(`Failed to save ${file.name}:`, 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); setShowCloseModal(false);
return; return;
} }
} }
// Then close // Then close
onCloseFile(file.id); 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); setShowCloseModal(false);
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]); }, [
file.id,
file.name,
file.localFilePath,
onCloseFile,
selectors,
fileActions,
]);
const handleCancelClose = useCallback(() => { const handleCancelClose = useCallback(() => {
setShowCloseModal(false); setShowCloseModal(false);
@@ -298,7 +357,12 @@ const FileEditorThumbnail = ({
e.stopPropagation(); e.stopPropagation();
if (onUnzipFile) { if (onUnzipFile) {
onUnzipFile(file.id); 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, hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
@@ -381,7 +445,10 @@ const FileEditorThumbnail = ({
onDoubleClick={handleCardDoubleClick} onDoubleClick={handleCardDoubleClick}
> >
{/* Header bar */} {/* Header bar */}
<div className={`${styles.header} ${getHeaderClassName()}`} data-has-error={hasError}> <div
className={`${styles.header} ${getHeaderClassName()}`}
data-has-error={hasError}
>
{/* Logo/checkbox area */} {/* Logo/checkbox area */}
<div className={styles.logoMark}> <div className={styles.logoMark}>
{hasError ? ( {hasError ? (
@@ -402,16 +469,27 @@ const FileEditorThumbnail = ({
</div> </div>
{/* Centered index */} {/* Centered index */}
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}> <div
className={styles.headerIndex}
aria-label={`Position ${index + 1}`}
>
{index + 1} {index + 1}
</div> </div>
{/* Action buttons group */} {/* Action buttons group */}
<div className={styles.headerActions}> <div className={styles.headerActions}>
{isEncrypted && ( {isEncrypted && (
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}> <Tooltip
label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
>
<ActionIcon <ActionIcon
aria-label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")} aria-label={t(
"encryptedPdfUnlock.unlockPrompt",
"Unlock PDF to continue",
)}
variant="subtle" variant="subtle"
className={styles.headerIconButton} className={styles.headerIconButton}
onClick={(e) => { onClick={(e) => {
@@ -426,7 +504,9 @@ const FileEditorThumbnail = ({
{/* Pin/Unpin icon */} {/* Pin/Unpin icon */}
<Tooltip <Tooltip
label={ 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 <ActionIcon
@@ -443,15 +523,29 @@ const FileEditorThumbnail = ({
if (actualFile) { if (actualFile) {
if (isPinned) { if (isPinned) {
unpinFile(actualFile); 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 { } else {
pinFile(actualFile); 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> </ActionIcon>
</Tooltip> </Tooltip>
</div> </div>
@@ -470,7 +564,13 @@ const FileEditorThumbnail = ({
<Text size="lg" fw={700} className={styles.title} title={file.name}> <Text size="lg" fw={700} className={styles.title} title={file.name}>
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent> <PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
</Text> </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 */} {/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
{`v${file.versionNumber} - `} {`v${file.versionNumber} - `}
{dateLabel} {dateLabel}
@@ -482,7 +582,11 @@ const FileEditorThumbnail = ({
{/* Preview area */} {/* Preview area */}
<div <div
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`} 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}> <div className={styles.previewPaper}>
{file.thumbnailUrl ? ( {file.thumbnailUrl ? (
@@ -513,7 +617,12 @@ const FileEditorThumbnail = ({
/> />
</PrivateContent> </PrivateContent>
) : file.type?.startsWith("application/pdf") ? ( ) : 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" /> <Loader size="sm" />
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
Loading thumbnail... Loading thumbnail...
@@ -554,7 +663,11 @@ const FileEditorThumbnail = ({
</div> </div>
{/* Hover Menu */} {/* Hover Menu */}
<HoverActionMenu show={showHoverMenu || isMobile} actions={hoverActions} position="outside" /> <HoverActionMenu
show={showHoverMenu || isMobile}
actions={hoverActions}
position="outside"
/>
{/* Close Confirmation Modal */} {/* Close Confirmation Modal */}
<Modal <Modal
@@ -567,7 +680,9 @@ const FileEditorThumbnail = ({
<Stack gap="md"> <Stack gap="md">
{file.isDirty && file.localFilePath ? ( {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}> <Text size="sm" c="dimmed" fw={500}>
{file.name} {file.name}
</Text> </Text>
@@ -575,7 +690,11 @@ const FileEditorThumbnail = ({
<Button variant="light" onClick={handleCancelClose}> <Button variant="light" onClick={handleCancelClose}>
{t("confirmCloseCancel", "Cancel")} {t("confirmCloseCancel", "Cancel")}
</Button> </Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}> <Button
variant="filled"
color="red"
onClick={handleConfirmClose}
>
{t("confirmCloseDiscard", "Discard changes and close")} {t("confirmCloseDiscard", "Discard changes and close")}
</Button> </Button>
<Button variant="filled" onClick={handleSaveAndClose}> <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}> <Text size="sm" c="dimmed" fw={500}>
{file.name} {file.name}
</Text> </Text>
@@ -593,7 +717,11 @@ const FileEditorThumbnail = ({
<Button variant="light" onClick={handleCancelClose}> <Button variant="light" onClick={handleCancelClose}>
{t("confirmCloseCancel", "Cancel")} {t("confirmCloseCancel", "Cancel")}
</Button> </Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}> <Button
variant="filled"
color="red"
onClick={handleConfirmClose}
>
{t("confirmCloseConfirm", "Close File")} {t("confirmCloseConfirm", "Close File")}
</Button> </Button>
</Group> </Group>
@@ -623,8 +751,20 @@ const FileEditorThumbnail = ({
</Stack> </Stack>
</Modal> </Modal>
{canUpload && <UploadToServerModal opened={showUploadModal} onClose={() => setShowUploadModal(false)} file={file} />} {canUpload && (
{canShare && <ShareFileModal opened={showShareModal} onClose={() => setShowShareModal(false)} file={file} />} <UploadToServerModal
opened={showUploadModal}
onClose={() => setShowUploadModal(false)}
file={file}
/>
)}
{canShare && (
<ShareFileModal
opened={showShareModal}
onClose={() => setShowShareModal(false)}
file={file}
/>
)}
</div> </div>
); );
}; };
@@ -1,6 +1,9 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 LocalIcon from "@app/components/shared/LocalIcon";
interface FileEditorRightRailButtonsParams { interface FileEditorRightRailButtonsParams {
@@ -26,7 +29,10 @@ export function useFileEditorRightRailButtons({
id: "file-select-all", id: "file-select-all",
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.selectAll", "Select All"), 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, section: "top" as const,
order: 10, order: 10,
disabled: totalItems === 0 || selectedCount === totalItems, disabled: totalItems === 0 || selectedCount === totalItems,
@@ -35,9 +41,18 @@ export function useFileEditorRightRailButtons({
}, },
{ {
id: "file-deselect-all", 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"), 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, section: "top" as const,
order: 20, order: 20,
disabled: selectedCount === 0, disabled: selectedCount === 0,
@@ -48,7 +63,10 @@ export function useFileEditorRightRailButtons({
id: "file-close-selected", id: "file-close-selected",
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />, icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: t("rightRail.closeSelected", "Close Selected Files"), 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, section: "top" as const,
order: 30, order: 30,
disabled: selectedCount === 0, disabled: selectedCount === 0,
@@ -56,7 +74,15 @@ export function useFileEditorRightRailButtons({
onClick: onCloseSelected, onClick: onCloseSelected,
}, },
], ],
[t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected], [
t,
i18n.language,
totalItems,
selectedCount,
onSelectAll,
onDeselectAll,
onCloseSelected,
],
); );
useRightRailButtons(buttons); useRightRailButtons(buttons);
@@ -35,9 +35,14 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
const hasSelection = selectedFiles.length > 0; const hasSelection = selectedFiles.length > 0;
const hasMultipleFiles = numberOfFiles > 1; const hasMultipleFiles = numberOfFiles > 1;
const showOwner = Boolean( 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 ( return (
<Stack gap="xs" style={{ height: "100%" }}> <Stack gap="xs" style={{ height: "100%" }}>
@@ -78,7 +83,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
borderRadius: 4, borderRadius: 4,
}} }}
> >
<PictureAsPdfIcon style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }} /> <PictureAsPdfIcon
style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }}
/>
</Center> </Center>
) : null} ) : null}
</Box> </Box>
@@ -86,7 +93,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* File info */} {/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate> <Text size="sm" fw={500} truncate>
<PrivateContent>{currentFile ? currentFile.name : "No file selected"}</PrivateContent> <PrivateContent>
{currentFile ? currentFile.name : "No file selected"}
</PrivateContent>
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ""} {currentFile ? getFileSize(currentFile) : ""}
@@ -101,7 +110,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* Compact tool chain for mobile */} {/* Compact tool chain for mobile */}
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && ( {currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
<Text size="xs" c="dimmed"> <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> </Text>
)} )}
{currentFile && showOwner && ( {currentFile && showOwner && (
@@ -114,10 +125,20 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
{/* Navigation arrows for multiple files */} {/* Navigation arrows for multiple files */}
{hasMultipleFiles && ( {hasMultipleFiles && (
<Box style={{ display: "flex", gap: "0.25rem" }}> <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 }} /> <ChevronLeftIcon style={{ fontSize: 16 }} />
</ActionIcon> </ActionIcon>
<ActionIcon variant="subtle" size="sm" onClick={onNext} disabled={isAnimating}> <ActionIcon
variant="subtle"
size="sm"
onClick={onNext}
disabled={isAnimating}
>
<ChevronRightIcon style={{ fontSize: 16 }} /> <ChevronRightIcon style={{ fontSize: 16 }} />
</ActionIcon> </ActionIcon>
</Box> </Box>
@@ -131,7 +152,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
disabled={!hasSelection} disabled={!hasSelection}
fullWidth fullWidth
style={{ 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", color: "white",
}} }}
> >
@@ -12,7 +12,12 @@ const DesktopLayout: React.FC = () => {
const { activeSource, recentFiles, modalHeight } = useFileManagerContext(); const { activeSource, recentFiles, modalHeight } = useFileManagerContext();
return ( 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 */} {/* Column 1: File Sources */}
<Grid.Col <Grid.Col
span="content" span="content"
@@ -74,9 +79,16 @@ const DesktopLayout: React.FC = () => {
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}> <div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<FileListArea <FileListArea
scrollAreaHeight={activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%"} scrollAreaHeight={
activeSource === "recent" && recentFiles.length > 0
? `calc(${modalHeight} - 7rem)`
: "100%"
}
scrollAreaStyle={{ scrollAreaStyle={{
height: activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%", height:
activeSource === "recent" && recentFiles.length > 0
? `calc(${modalHeight} - 7rem)`
: "100%",
backgroundColor: "transparent", backgroundColor: "transparent",
border: "none", border: "none",
borderRadius: 0, borderRadius: 0,
@@ -32,7 +32,9 @@ const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
}} }}
> >
<Stack align="center" gap="md"> <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"> <Text size="xl" fw={500} c="blue.6">
{t("fileManager.dropFilesHere", "Drop files here to upload")} {t("fileManager.dropFilesHere", "Drop files here to upload")}
</Text> </Text>
@@ -1,5 +1,11 @@
import React, { useState } from "react"; 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 HistoryIcon from "@mui/icons-material/History";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useFileManagerContext } from "@app/contexts/FileManagerContext"; import { useFileManagerContext } from "@app/contexts/FileManagerContext";
@@ -49,7 +55,9 @@ const EmptyFilesState: React.FC = () => {
> >
{/* No Recent Files Message */} {/* No Recent Files Message */}
<Stack align="center" gap="sm"> <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"> <Text c="dimmed" ta="center" size="lg">
{t("fileManager.noRecentFiles", "No recent files")} {t("fileManager.noRecentFiles", "No recent files")}
</Text> </Text>
@@ -91,7 +99,8 @@ const EmptyFilesState: React.FC = () => {
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "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} onClick={handleUploadClick}
onMouseEnter={() => setIsUploadHover(true)} onMouseEnter={() => setIsUploadHover(true)}
@@ -102,12 +111,19 @@ const EmptyFilesState: React.FC = () => {
height="1.25rem" height="1.25rem"
style={{ color: "var(--accent-interactive)" }} style={{ color: "var(--accent-interactive)" }}
/> />
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>} {isUploadHover && (
<span style={{ marginLeft: ".5rem" }}>
{terminology.uploadFromComputer}
</span>
)}
</Button> </Button>
</div> </div>
{/* Instruction Text */} {/* 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} {terminology.dropFilesHere}
</span> </span>
</div> </div>
@@ -1,5 +1,11 @@
import React, { useEffect } from "react"; 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 SelectAllIcon from "@mui/icons-material/SelectAll";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
@@ -33,33 +39,51 @@ const FileActions: React.FC = () => {
onStorageFilterChange, onStorageFilterChange,
} = useFileManagerContext(); } = useFileManagerContext();
const uploadEnabled = config?.storageEnabled === true; const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; const sharingEnabled =
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled =
sharingEnabled && config?.storageShareLinksEnabled === true;
const showStorageFilter = uploadEnabled; const showStorageFilter = uploadEnabled;
const storageFilterOptions = sharingEnabled const storageFilterOptions = sharingEnabled
? [ ? [
{ value: "all", label: t("fileManager.filterAll", "All") }, { value: "all", label: t("fileManager.filterAll", "All") },
{ value: "local", label: t("fileManager.filterLocal", "Local") }, { 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: "all", label: t("fileManager.filterAll", "All") },
{ value: "local", label: t("fileManager.filterLocal", "Local") }, { value: "local", label: t("fileManager.filterLocal", "Local") },
]; ];
useEffect(() => { useEffect(() => {
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) { if (
!sharingEnabled &&
(storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")
) {
onStorageFilterChange("all"); onStorageFilterChange("all");
} }
}, [sharingEnabled, storageFilter, onStorageFilterChange]); }, [sharingEnabled, storageFilter, onStorageFilterChange]);
const hasSelection = selectedFileIds.length > 0; 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 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"; return role === "editor" || role === "commenter" || role === "viewer";
}); });
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection; const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection; const canBulkShare =
shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
const handleSelectAll = () => { const handleSelectAll = () => {
onSelectAll(); onSelectAll();
@@ -82,7 +106,8 @@ const FileActions: React.FC = () => {
return null; return null;
} }
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length; const allFilesSelected =
filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
return ( return (
<div <div
@@ -99,7 +124,11 @@ const FileActions: React.FC = () => {
{/* Left: Select All + Filter */} {/* Left: Select All + Filter */}
<Group gap="xs"> <Group gap="xs">
<Tooltip <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 <ActionIcon
variant="light" variant="light"
@@ -116,7 +145,11 @@ const FileActions: React.FC = () => {
<SegmentedControl <SegmentedControl
size="xs" size="xs"
value={storageFilter} value={storageFilter}
onChange={(value) => onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")} onChange={(value) =>
onStorageFilterChange(
value as "all" | "local" | "sharedWithMe" | "sharedByMe",
)
}
data={storageFilterOptions} data={storageFilterOptions}
/> />
)} )}
@@ -132,7 +165,9 @@ const FileActions: React.FC = () => {
> >
{hasSelection && ( {hasSelection && (
<Text size="sm" c="dimmed" fw={500}> <Text size="sm" c="dimmed" fw={500}>
{t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })} {t("fileManager.selectedCount", "{{count}} selected", {
count: selectedFileIds.length,
})}
</Text> </Text>
)} )}
</div> </div>
@@ -18,7 +18,8 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
// Get the currently displayed file // 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; const hasSelection = selectedFiles.length > 0;
// Use IndexedDB hook for the current file // Use IndexedDB hook for the current file
@@ -33,7 +34,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
if (isAnimating) return; if (isAnimating) return;
setIsAnimating(true); setIsAnimating(true);
setTimeout(() => { setTimeout(() => {
setCurrentFileIndex((prev) => (prev > 0 ? prev - 1 : selectedFiles.length - 1)); setCurrentFileIndex((prev) =>
prev > 0 ? prev - 1 : selectedFiles.length - 1,
);
setIsAnimating(false); setIsAnimating(false);
}, 150); }, 150);
}; };
@@ -42,7 +45,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
if (isAnimating) return; if (isAnimating) return;
setIsAnimating(true); setIsAnimating(true);
setTimeout(() => { setTimeout(() => {
setCurrentFileIndex((prev) => (prev < selectedFiles.length - 1 ? prev + 1 : 0)); setCurrentFileIndex((prev) =>
prev < selectedFiles.length - 1 ? prev + 1 : 0,
);
setIsAnimating(false); setIsAnimating(false);
}, 150); }, 150);
}; };
@@ -73,7 +78,14 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
return ( return (
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start"> <Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
{/* Section 1: Thumbnail Preview */} {/* 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 <FilePreview
file={currentFile} file={currentFile}
thumbnail={getCurrentThumbnail()} thumbnail={getCurrentThumbnail()}
@@ -95,7 +107,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
disabled={!hasSelection} disabled={!hasSelection}
fullWidth fullWidth
style={{ 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", color: "white",
}} }}
> >
@@ -39,7 +39,8 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
<Box ml="md" mt="xs" mb="sm"> <Box ml="md" mt="xs" mb="sm">
<Group align="center" mb="sm"> <Group align="center" mb="sm">
<Text size="xs" fw={600} c="dimmed"> <Text size="xs" fw={600} c="dimmed">
{t("fileManager.fileHistory", "File History")} ({sortedHistory.length}) {t("fileManager.fileHistory", "File History")} (
{sortedHistory.length})
</Text> </Text>
</Group> </Group>
@@ -1,5 +1,15 @@
import React, { useMemo, useState } from "react"; 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 { useTranslation } from "react-i18next";
import { detectFileExtension, getFileSize } from "@app/utils/fileUtils"; import { detectFileExtension, getFileSize } from "@app/utils/fileUtils";
import { StirlingFileStub } from "@app/types/fileContext"; import { StirlingFileStub } from "@app/types/fileContext";
@@ -14,31 +24,43 @@ interface FileInfoCardProps {
modalHeight: string; modalHeight: string;
} }
const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight }) => { const FileInfoCard: React.FC<FileInfoCardProps> = ({
currentFile,
modalHeight,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { config } = useAppConfig(); const { config } = useAppConfig();
const { onMakeCopy } = useFileManagerContext(); const { onMakeCopy } = useFileManagerContext();
const [showShareManageModal, setShowShareManageModal] = useState(false); const [showShareManageModal, setShowShareManageModal] = useState(false);
const isSharedWithYou = useMemo(() => { const isSharedWithYou = useMemo(() => {
if (!currentFile) return false; if (!currentFile) return false;
return currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink; return (
currentFile.remoteOwnedByCurrentUser === false ||
currentFile.remoteSharedViaLink
);
}, [currentFile]); }, [currentFile]);
const isOwnedRemote = useMemo(() => { const isOwnedRemote = useMemo(() => {
if (!currentFile) return false; if (!currentFile) return false;
return Boolean(currentFile.remoteStorageId) && currentFile.remoteOwnedByCurrentUser !== false; return (
Boolean(currentFile.remoteStorageId) &&
currentFile.remoteOwnedByCurrentUser !== false
);
}, [currentFile]); }, [currentFile]);
const localUpdatedAt = currentFile?.createdAt ?? currentFile?.lastModified ?? 0; const localUpdatedAt =
currentFile?.createdAt ?? currentFile?.lastModified ?? 0;
const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0; const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(currentFile?.remoteStorageId); const isUploaded = Boolean(currentFile?.remoteStorageId);
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote; const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote;
const isLocalOnly = !currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink; const isLocalOnly =
!currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink;
const isSharedByYou = useMemo(() => { const isSharedByYou = useMemo(() => {
if (!currentFile) return false; if (!currentFile) return false;
return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks); return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks);
}, [currentFile, isOwnedRemote]); }, [currentFile, isOwnedRemote]);
const uploadEnabled = config?.storageEnabled === true; const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; const sharingEnabled =
uploadEnabled && config?.storageSharingEnabled === true;
const ownerLabel = useMemo(() => { const ownerLabel = useMemo(() => {
if (!currentFile) return ""; if (!currentFile) return "";
if (currentFile.remoteOwnerUsername) { if (currentFile.remoteOwnerUsername) {
@@ -56,7 +78,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
withBorder withBorder
p={0} p={0}
mah={`calc(${modalHeight} * 0.45)`} mah={`calc(${modalHeight} * 0.45)`}
style={{ overflow: "hidden", flexShrink: 1, display: "flex", flexDirection: "column" }} style={{
overflow: "hidden",
flexShrink: 1,
display: "flex",
flexDirection: "column",
}}
> >
<Box <Box
bg="gray.4" bg="gray.4"
@@ -75,9 +102,16 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between" py="xs"> <Group justify="space-between" py="xs">
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
<PrivateContent>{t("fileManager.fileName", "Name")}</PrivateContent> <PrivateContent>
{t("fileManager.fileName", "Name")}
</PrivateContent>
</Text> </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 : ""} {currentFile ? currentFile.name : ""}
</Text> </Text>
</Group> </Group>
@@ -112,7 +146,9 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
{t("fileManager.lastModified", "Last modified")} {t("fileManager.lastModified", "Last modified")}
</Text> </Text>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ""} {currentFile
? new Date(currentFile.lastModified).toLocaleDateString()
: ""}
</Text> </Text>
</Group> </Group>
<Divider /> <Divider />
@@ -122,7 +158,11 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
{t("fileManager.fileVersion", "Version")} {t("fileManager.fileVersion", "Version")}
</Text> </Text>
{currentFile && ( {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 : ""} v{currentFile ? currentFile.versionNumber || 1 : ""}
</Badge> </Badge>
)} )}
@@ -154,7 +194,11 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
<Text size="xs" c="dimmed" mb="xs"> <Text size="xs" c="dimmed" mb="xs">
{t("fileManager.toolChain", "Tools Applied")} {t("fileManager.toolChain", "Tools Applied")}
</Text> </Text>
<ToolChain toolChain={currentFile.toolHistory} displayStyle="badges" size="xs" /> <ToolChain
toolChain={currentFile.toolHistory}
displayStyle="badges"
size="xs"
/>
</Box> </Box>
</> </>
)} )}
@@ -162,7 +206,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
{currentFile && isSharedWithYou && ( {currentFile && isSharedWithYou && (
<> <>
<Divider /> <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")} {t("fileManager.makeCopy", "Make a copy")}
</Button> </Button>
</> </>
@@ -177,7 +226,10 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
</Text> </Text>
{uploadEnabled && isOutOfSync ? ( {uploadEnabled && isOutOfSync ? (
<Badge size="xs" variant="light" color="yellow"> <Badge size="xs" variant="light" color="yellow">
{t("fileManager.changesNotUploaded", "Changes not uploaded")} {t(
"fileManager.changesNotUploaded",
"Changes not uploaded",
)}
</Badge> </Badge>
) : uploadEnabled ? ( ) : uploadEnabled ? (
<Badge size="xs" variant="light" color="teal"> <Badge size="xs" variant="light" color="teal">
@@ -206,7 +258,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
{t("fileManager.sharedByYou", "Shared by you")} {t("fileManager.sharedByYou", "Shared by you")}
</Badge> </Badge>
</Group> </Group>
<Button size="sm" variant="light" onClick={() => setShowShareManageModal(true)} fullWidth> <Button
size="sm"
variant="light"
onClick={() => setShowShareManageModal(true)}
fullWidth
>
{t("storageShare.manage", "Manage sharing")} {t("storageShare.manage", "Manage sharing")}
</Button> </Button>
</> </>
@@ -12,7 +12,10 @@ interface FileListAreaProps {
scrollAreaStyle?: React.CSSProperties; scrollAreaStyle?: React.CSSProperties;
} }
const FileListArea: React.FC<FileListAreaProps> = ({ scrollAreaHeight, scrollAreaStyle = {} }) => { const FileListArea: React.FC<FileListAreaProps> = ({
scrollAreaHeight,
scrollAreaStyle = {},
}) => {
const { const {
activeSource, activeSource,
recentFiles, recentFiles,
@@ -94,9 +97,14 @@ const FileListArea: React.FC<FileListAreaProps> = ({ scrollAreaHeight, scrollAre
return ( return (
<Center style={{ height: "12.5rem" }}> <Center style={{ height: "12.5rem" }}>
<Stack align="center" gap="sm"> <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"> <Text c="dimmed" ta="center">
{t("fileManager.googleDriveNotAvailable", "Google Drive integration coming soon")} {t(
"fileManager.googleDriveNotAvailable",
"Google Drive integration coming soon",
)}
</Text> </Text>
</Stack> </Stack>
</Center> </Center>
@@ -1,5 +1,14 @@
import React, { useCallback, useMemo, useState } from "react"; 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 MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
@@ -60,14 +69,21 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [showShareManageModal, setShowShareManageModal] = useState(false); const [showShareManageModal, setShowShareManageModal] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const { config } = useAppConfig(); const { config } = useAppConfig();
const { expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext(); const {
expandedFileIds,
onToggleExpansion,
onUnzipFile,
refreshRecentFiles,
} = useFileManagerContext();
const { removeFiles } = useFileManagement(); const { removeFiles } = useFileManagement();
// Check if this is a ZIP file // Check if this is a ZIP file
const isZipFile = zipFileService.isZipFileStub(file); const isZipFile = zipFileService.isZipFileStub(file);
// Check file extension // 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 isCBZ = extLower === "cbz";
const isCBR = extLower === "cbr"; const isCBR = extLower === "cbr";
@@ -75,33 +91,55 @@ const FileListItem: React.FC<FileListItemProps> = ({
const shouldShowHovered = isHovered || isMenuOpen; const shouldShowHovered = isHovered || isMenuOpen;
// Get version information for this file // 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 hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
const currentVersion = file.versionNumber || 1; // Display original files as v1 const currentVersion = file.versionNumber || 1; // Display original files as v1
const isExpanded = expandedFileIds.has(leafFileId); const isExpanded = expandedFileIds.has(leafFileId);
const uploadEnabled = config?.storageEnabled === true; const uploadEnabled = config?.storageEnabled === true;
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true; const sharingEnabled =
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true; uploadEnabled && config?.storageSharingEnabled === true;
const shareLinksEnabled =
sharingEnabled && config?.storageShareLinksEnabled === true;
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false; 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 localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0; const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
const isUploaded = Boolean(file.remoteStorageId); const isUploaded = Boolean(file.remoteStorageId);
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt; const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal; const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink; const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
const accessRole = (isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase(); const accessRole = (
const hasReadAccess = isOwnedOrLocal || accessRole === "editor" || accessRole === "commenter" || accessRole === "viewer"; isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")
const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate); ).toLowerCase();
const hasReadAccess =
isOwnedOrLocal ||
accessRole === "editor" ||
accessRole === "commenter" ||
accessRole === "viewer";
const canUpload =
uploadEnabled &&
isOwnedOrLocal &&
isLatestVersion &&
(!isUploaded || !isUpToDate);
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion; const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId); const canManageShare =
const canCopyShareLink = shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId); sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
const canCopyShareLink =
shareLinksEnabled &&
Boolean(file.remoteHasShareLinks) &&
Boolean(file.remoteStorageId);
const canDownloadFile = Boolean(onDownload) && hasReadAccess; const canDownloadFile = Boolean(onDownload) && hasReadAccess;
const shareBaseUrl = useMemo(() => { const shareBaseUrl = useMemo(() => {
const frontendUrl = (config?.frontendUrl || "").trim(); const frontendUrl = (config?.frontendUrl || "").trim();
if (frontendUrl) { if (frontendUrl) {
const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl; const normalized = frontendUrl.endsWith("/")
? frontendUrl.slice(0, -1)
: frontendUrl;
return `${normalized}/share/`; return `${normalized}/share/`;
} }
return absoluteWithBasePath("/share/"); return absoluteWithBasePath("/share/");
@@ -110,10 +148,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
const handleCopyShareLink = useCallback(async () => { const handleCopyShareLink = useCallback(async () => {
if (!file.remoteStorageId) return; if (!file.remoteStorageId) return;
try { try {
const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>( const response = await apiClient.get<{
`/api/v1/storage/files/${file.remoteStorageId}`, shareLinks?: Array<{ token?: string }>;
{ suppressErrorToast: true } as any, }>(`/api/v1/storage/files/${file.remoteStorageId}`, {
); suppressErrorToast: true,
} as any);
const links = response.data?.shareLinks ?? []; const links = response.data?.shareLinks ?? [];
const token = links[links.length - 1]?.token; const token = links[links.length - 1]?.token;
if (!token) { if (!token) {
@@ -163,9 +202,13 @@ const FileListItem: React.FC<FileListItemProps> = ({
MozUserSelect: "none", MozUserSelect: "none",
msUserSelect: "none", msUserSelect: "none",
paddingLeft: isHistoryFile ? "2rem" : "0.75rem", // Indent history files 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} onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
@@ -217,7 +260,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
{t("fileManager.sharedWithYou", "Shared with you")} {t("fileManager.sharedWithYou", "Shared with you")}
</Badge> </Badge>
) : null} ) : null}
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== "editor" ? ( {sharingEnabled &&
isSharedWithYou &&
accessRole &&
accessRole !== "editor" ? (
<Badge size="xs" variant="light" color="gray"> <Badge size="xs" variant="light" color="gray">
{accessRole === "commenter" {accessRole === "commenter"
? t("storageShare.roleCommenter", "Commenter") ? t("storageShare.roleCommenter", "Commenter")
@@ -228,19 +274,31 @@ const FileListItem: React.FC<FileListItemProps> = ({
{t("fileManager.localOnly", "Local only")} {t("fileManager.localOnly", "Local only")}
</Badge> </Badge>
) : uploadEnabled && isOutOfSync ? ( ) : 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")} {t("fileManager.changesNotUploaded", "Changes not uploaded")}
</Badge> </Badge>
) : uploadEnabled && isUploaded ? ( ) : 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")} {t("fileManager.synced", "Synced")}
</Badge> </Badge>
) : null} ) : null}
{sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && ( {sharingEnabled &&
<Badge size="xs" variant="light" color="blue"> file.remoteOwnedByCurrentUser !== false &&
{t("fileManager.sharedByYou", "Shared by you")} file.remoteHasShareLinks && (
</Badge> <Badge size="xs" variant="light" color="blue">
)} {t("fileManager.sharedByYou", "Shared by you")}
</Badge>
)}
</Group> </Group>
<Group gap="xs" align="center"> <Group gap="xs" align="center">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
@@ -249,7 +307,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
{/* Tool chain for processed files */} {/* Tool chain for processed files */}
{file.toolHistory && file.toolHistory.length > 0 && ( {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> </Group>
</Box> </Box>
@@ -368,7 +431,9 @@ const FileListItem: React.FC<FileListItemProps> = ({
onToggleExpansion(leafFileId); 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.Item>
<Menu.Divider /> <Menu.Divider />
</> </>
@@ -436,7 +501,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
/> />
)} )}
{canManageShare && ( {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 FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext(); horizontal = false,
}) => {
const {
activeSource,
onSourceChange,
onLocalFileClick,
onGoogleDriveSelect,
onNewFilesSelect,
} = useFileManagerContext();
const { t } = useTranslation(); const { t } = useTranslation();
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker(); const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } =
useGoogleDrivePicker();
const terminology = useFileActionTerminology(); const terminology = useFileActionTerminology();
const icons = useFileActionIcons(); const icons = useFileActionIcons();
const UploadIcon = icons.upload; const UploadIcon = icons.upload;
@@ -66,21 +75,29 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
}; };
// Determine visibility of Google Drive button // Determine visibility of Google Drive button
const shouldHideGoogleDrive = !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive; const shouldHideGoogleDrive =
!isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
// Determine visibility of Mobile QR Scanner button // Determine visibility of Mobile QR Scanner button
const shouldHideMobileQR = !isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner; const shouldHideMobileQR =
!isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
const buttonProps = { const buttonProps = {
variant: (source: string) => (activeSource === source ? "filled" : "subtle"), variant: (source: string) =>
getColor: (source: string) => (activeSource === source ? "var(--mantine-color-gray-2)" : undefined), activeSource === source ? "filled" : "subtle",
getColor: (source: string) =>
activeSource === source ? "var(--mantine-color-gray-2)" : undefined,
getStyles: (source: string) => ({ getStyles: (source: string) => ({
root: { root: {
backgroundColor: activeSource === source ? undefined : "transparent", 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", border: "none",
"&:hover": { "&: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")} color={buttonProps.getColor("recent")}
styles={buttonProps.getStyles("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>
<Button <Button
@@ -136,17 +155,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
backgroundColor: "transparent", backgroundColor: "transparent",
border: "none", border: "none",
"&:hover": { "&:hover": {
backgroundColor: isGoogleDriveEnabled ? "var(--mantine-color-gray-0)" : "transparent", backgroundColor: isGoogleDriveEnabled
? "var(--mantine-color-gray-0)"
: "transparent",
}, },
}, },
}} }}
title={ title={
!isGoogleDriveEnabled !isGoogleDriveEnabled
? t("fileManager.googleDriveNotAvailable", "Google Drive integration not available") ? t(
"fileManager.googleDriveNotAvailable",
"Google Drive integration not available",
)
: undefined : undefined
} }
> >
{horizontal ? t("fileManager.googleDriveShort", "Drive") : t("fileManager.googleDrive", "Google Drive")} {horizontal
? t("fileManager.googleDriveShort", "Drive")
: t("fileManager.googleDrive", "Google Drive")}
</Button> </Button>
)} )}
@@ -165,13 +191,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
backgroundColor: "transparent", backgroundColor: "transparent",
border: "none", border: "none",
"&:hover": { "&: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> </Button>
)} )}
</> </>
@@ -195,7 +232,14 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
return ( return (
<> <>
<Stack gap="xs" style={{ height: "100%" }}> <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")} {t("fileManager.myFiles", "My Files")}
</Text> </Text>
{buttons} {buttons}
@@ -27,7 +27,11 @@ const MobileLayout: React.FC = () => {
}; };
return ( 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 */} {/* Section 1: File Sources - Fixed at top */}
<Box style={{ flexShrink: 0 }}> <Box style={{ flexShrink: 0 }}>
<FileSourceButtons horizontal={true} /> <FileSourceButtons horizontal={true} />
@@ -23,7 +23,11 @@ const baseKeyStyle: React.CSSProperties = {
color: "var(--mantine-color-text)", 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 { getDisplayParts } = useHotkeys();
const parts = getDisplayParts(binding); const parts = getDisplayParts(binding);
@@ -31,7 +35,10 @@ export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = "s
return null; 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 ( return (
<span <span
@@ -4,7 +4,11 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileHandler } from "@app/hooks/useFileHandler"; import { useFileHandler } from "@app/hooks/useFileHandler";
import { useFileState, useFileActions } from "@app/contexts/FileContext"; 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 { isBaseWorkbench } from "@app/types/workbench";
import { useViewer } from "@app/contexts/ViewerContext"; import { useViewer } from "@app/contexts/ViewerContext";
import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useAppConfig } from "@app/contexts/AppConfigContext";
@@ -102,7 +106,9 @@ export default function Workbench() {
// Check if we're showing a custom workbench first // Check if we're showing a custom workbench first
// Custom workbenches may not require files in FileContext (e.g., sign request workbench) // Custom workbenches may not require files in FileContext (e.g., sign request workbench)
if (!isBaseWorkbench(currentView)) { 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) { if (customView) {
const CustomComponent = customView.component; const CustomComponent = customView.component;
return <CustomComponent data={customView.data} />; return <CustomComponent data={customView.data} />;
@@ -148,7 +154,15 @@ export default function Workbench() {
<div style={{ position: "relative", flex: "1 1 0", height: 0 }}> <div style={{ position: "relative", flex: "1 1 0", height: 0 }}>
<PageEditor onFunctionsReady={setPageEditorFunctions} /> <PageEditor onFunctionsReady={setPageEditorFunctions} />
{pageEditorFunctions && ( {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 <PageEditorControls
onClosePdf={pageEditorFunctions.closePdf} onClosePdf={pageEditorFunctions.closePdf}
onUndo={pageEditorFunctions.handleUndo} onUndo={pageEditorFunctions.handleUndo}
@@ -190,20 +204,26 @@ export default function Workbench() {
} }
> >
{/* Top Controls */} {/* Top Controls */}
{activeFiles.length > 0 && !customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && ( {activeFiles.length > 0 &&
<TopControls !customWorkbenchViews.find((v) => v.workbenchId === currentView)
currentView={currentView} ?.hideTopControls && (
setCurrentView={setCurrentView} <TopControls
customViews={customWorkbenchViews} currentView={currentView}
activeFiles={activeFiles.map((f) => { setCurrentView={setCurrentView}
const stub = selectors.getStirlingFileStub(f.fileId); customViews={customWorkbenchViews}
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber }; activeFiles={activeFiles.map((f) => {
})} const stub = selectors.getStirlingFileStub(f.fileId);
currentFileIndex={activeFileIndex} return {
onFileSelect={handleFileSelect} fileId: f.fileId,
onFileRemove={handleFileRemove} name: f.name,
/> versionNumber: stub?.versionNumber,
)} };
})}
currentFileIndex={activeFileIndex}
onFileSelect={handleFileSelect}
onFileRemove={handleFileRemove}
/>
)}
{/* Dismiss All Errors Button */} {/* Dismiss All Errors Button */}
<DismissAllErrorsButton /> <DismissAllErrorsButton />
@@ -2,7 +2,10 @@ import React from "react";
import { Button, Group, ActionIcon } from "@mantine/core"; import { Button, Group, ActionIcon } from "@mantine/core";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import { useTranslation } from "react-i18next"; 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 { LicenseNotice } from "@app/types/types";
import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig"; import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
@@ -16,10 +19,19 @@ interface SlideButtonsProps {
onAction: (action: ButtonAction) => void; onAction: (action: ButtonAction) => void;
} }
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) { export function SlideButtons({
slideDefinition,
licenseNotice,
flowState,
onAction,
}: SlideButtonsProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left"); const leftButtons = slideDefinition.buttons.filter(
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right"); (btn) => btn.group === "left",
);
const rightButtons = slideDefinition.buttons.filter(
(btn) => btn.group === "right",
);
const buttonStyles = (variant: ButtonDefinition["variant"]) => const buttonStyles = (variant: ButtonDefinition["variant"]) =>
variant === "primary" 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> </ActionIcon>
); );
} }
@@ -85,7 +99,12 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
const label = resolveButtonLabel(button); const label = resolveButtonLabel(button);
return ( 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} {label}
</Button> </Button>
); );
@@ -6,11 +6,21 @@ import { isAuthRoute } from "@app/constants/routes";
import { dispatchTourState } from "@app/constants/events"; import { dispatchTourState } from "@app/constants/events";
import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator"; import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator";
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding"; 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 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 { 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 ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext"; import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext";
import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext"; import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext";
@@ -36,9 +46,18 @@ export default function Onboarding() {
const onAuthRoute = isAuthRoute(location.pathname); const onAuthRoute = isAuthRoute(location.pathname);
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state; const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload(); const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } =
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest(); useOnboardingDownload();
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest(); const {
showLicenseSlide,
licenseNotice: externalLicenseNotice,
closeLicenseSlide,
} = useServerLicenseRequest();
const {
tourRequested: externalTourRequested,
requestedTourType,
clearTourRequest,
} = useTourRequest();
const { config, refetch: refetchConfig } = useAppConfig(); const { config, refetch: refetchConfig } = useAppConfig();
const [analyticsError, setAnalyticsError] = useState<string | null>(null); const [analyticsError, setAnalyticsError] = useState<string | null>(null);
const [analyticsLoading, setAnalyticsLoading] = useState(false); const [analyticsLoading, setAnalyticsLoading] = useState(false);
@@ -75,10 +94,20 @@ export default function Onboarding() {
// Check if we should show analytics modal before onboarding // Check if we should show analytics modal before onboarding
useEffect(() => { useEffect(() => {
if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) { if (
!isLoading &&
!analyticsModalDismissed &&
serverExperience.effectiveIsAdmin &&
config?.enableAnalytics == null
) {
setShowAnalyticsModal(true); setShowAnalyticsModal(true);
} }
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]); }, [
isLoading,
analyticsModalDismissed,
serverExperience.effectiveIsAdmin,
config?.enableAnalytics,
]);
const handleAnalyticsChoice = useCallback( const handleAnalyticsChoice = useCallback(
async (enableAnalytics: boolean) => { async (enableAnalytics: boolean) => {
@@ -90,12 +119,17 @@ export default function Onboarding() {
formData.append("enabled", enableAnalytics.toString()); formData.append("enabled", enableAnalytics.toString());
try { try {
await apiClient.post("/api/v1/settings/update-enable-analytics", formData); await apiClient.post(
"/api/v1/settings/update-enable-analytics",
formData,
);
await refetchConfig(); await refetchConfig();
setShowAnalyticsModal(false); setShowAnalyticsModal(false);
setAnalyticsModalDismissed(true); setAnalyticsModalDismissed(true);
} catch (error) { } catch (error) {
setAnalyticsError(error instanceof Error ? error.message : "Unknown error"); setAnalyticsError(
error instanceof Error ? error.message : "Unknown error",
);
} finally { } finally {
setAnalyticsLoading(false); setAnalyticsLoading(false);
} }
@@ -137,7 +171,11 @@ export default function Onboarding() {
setIsTourOpen(true); setIsTourOpen(true);
break; break;
case "launch-auto": { case "launch-auto": {
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === "admin" ? "admin" : "whatsnew"; const tourType =
serverExperience.effectiveIsAdmin ||
runtimeState.selectedRole === "admin"
? "admin"
: "whatsnew";
actions.updateRuntimeState({ tourType }); actions.updateRuntimeState({ tourType });
setIsTourOpen(true); setIsTourOpen(true);
break; 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); const [isTourOpen, setIsTourOpen] = useState(false);
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]); useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
@@ -241,7 +282,12 @@ export default function Onboarding() {
default: default:
return Object.values(userStepsConfig); return Object.values(userStepsConfig);
} }
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]); }, [
adminStepsConfig,
runtimeState.tourType,
userStepsConfig,
whatsNewStepsConfig,
]);
useEffect(() => { useEffect(() => {
if (externalTourRequested) { if (externalTourRequested) {
@@ -290,7 +336,12 @@ export default function Onboarding() {
const handleAdvanceTour = useCallback( const handleAdvanceTour = useCallback(
(args: AdvanceArgs) => { (args: AdvanceArgs) => {
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args; const {
setCurrentStep,
currentStep: tourCurrentStep,
steps,
setIsOpen,
} = args;
if (steps && tourCurrentStep === steps.length - 1) { if (steps && tourCurrentStep === steps.length - 1) {
setIsOpen(false); setIsOpen(false);
finishTour(); finishTour();
@@ -310,7 +361,11 @@ export default function Onboarding() {
); );
const currentSlideDefinition = useMemo(() => { const currentSlideDefinition = useMemo(() => {
if (!currentStep || currentStep.type !== "modal-slide" || !currentStep.slideId) { if (
!currentStep ||
currentStep.type !== "modal-slide" ||
!currentStep.slideId
) {
return null; return null;
} }
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId]; return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
@@ -356,7 +411,9 @@ export default function Onboarding() {
const currentModalSlideIndex = useMemo(() => { const currentModalSlideIndex = useMemo(() => {
if (!currentStep || currentStep.type !== "modal-slide") return 0; 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); return modalSlides.findIndex((step) => step.id === currentStep.id);
}, [activeFlow, currentStep]); }, [activeFlow, currentStep]);
@@ -464,9 +521,12 @@ export default function Onboarding() {
// Remove back button for external license notice // Remove back button for external license notice
const slideDefinition = { const slideDefinition = {
...baseSlideDefinition, ...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({ const slideContent = slideDefinition.createSlide({
osLabel: "", osLabel: "",
osUrl: "", osUrl: "",
@@ -482,7 +542,10 @@ export default function Onboarding() {
<OnboardingModalSlide <OnboardingModalSlide
slideDefinition={slideDefinition} slideDefinition={slideDefinition}
slideContent={slideContent} slideContent={slideContent}
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }} runtimeState={{
...runtimeState,
licenseNotice: effectiveLicenseNotice,
}}
modalSlideCount={1} modalSlideCount={1}
currentModalSlideIndex={0} currentModalSlideIndex={0}
onSkip={closeLicenseSlide} onSkip={closeLicenseSlide}
@@ -524,7 +587,9 @@ export default function Onboarding() {
// Render the current onboarding step // Render the current onboarding step
switch (currentStep.type) { switch (currentStep.type) {
case "tool-prompt": case "tool-prompt":
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />; return (
<ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />
);
case "modal-slide": case "modal-slide":
if (!currentSlideDefinition || !currentSlideContent) return null; if (!currentSlideDefinition || !currentSlideContent) return null;
@@ -10,7 +10,10 @@ import { Modal, Stack, ActionIcon } from "@mantine/core";
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined"; import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
import CloseIcon from "@mui/icons-material/Close"; 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 { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
import type { SlideConfig } from "@app/types/types"; import type { SlideConfig } from "@app/types/types";
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground"; import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
@@ -47,7 +50,11 @@ export default function OnboardingModalSlide({
return ( return (
<div className={styles.heroIconsContainer}> <div className={styles.heroIconsContainer}>
<div className={styles.iconWrapper}> <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>
</div> </div>
); );
@@ -56,20 +63,45 @@ export default function OnboardingModalSlide({
return ( return (
<div className={styles.heroLogoCircle}> <div className={styles.heroLogoCircle}>
{slideDefinition.hero.type === "rocket" && ( {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" && ( {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" && ( {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" && ( {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" && ( {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> </div>
); );
@@ -88,7 +120,12 @@ export default function OnboardingModalSlide({
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE} zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{ styles={{
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" }, 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}> <Stack gap={0} className={styles.modalContent}>
@@ -129,20 +166,34 @@ export default function OnboardingModalSlide({
</div> </div>
</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}> <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} {slideContent.title}
</div> </div>
<div className={styles.bodyText}> <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} {slideContent.body}
</div> </div>
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style> <style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
</div> </div>
{modalSlideCount > 1 && <OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />} {modalSlideCount > 1 && (
<OnboardingStepper
totalSteps={modalSlideCount}
activeStep={currentModalSlideIndex}
/>
)}
<div className={styles.buttonContainer}> <div className={styles.buttonContainer}>
<SlideButtons <SlideButtons
@@ -10,7 +10,11 @@ interface OnboardingStepperProps {
* Renders a progress indicator where the active step is a pill and others are dots. * Renders a progress indicator where the active step is a pill and others are dots.
* Colors come from theme.css variables. * 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); const items = Array.from({ length: totalSteps }, (_, index) => index);
return ( return (
@@ -26,7 +30,9 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
{items.map((index) => { {items.map((index) => {
const isActive = index === activeStep; const isActive = index === activeStep;
const baseStyles: React.CSSProperties = { 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 ( return (
@@ -57,7 +57,15 @@ interface OnboardingTourProps {
onClose: (args: CloseArgs) => void; 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; if (!isOpen) return null;
return ( return (
@@ -111,15 +119,31 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
disableInteraction={true} disableInteraction={true}
disableDotsNavigation={false} disableDotsNavigation={false}
prevButton={() => null} prevButton={() => null}
nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => { nextButton={({
currentStep: tourCurrentStep,
stepsLength,
setCurrentStep,
setIsOpen,
}) => {
const isLast = tourCurrentStep === stepsLength - 1; const isLast = tourCurrentStep === stepsLength - 1;
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon; const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
return ( return (
<ActionIcon <ActionIcon
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })} onClick={() =>
onAdvance({
setCurrentStep,
currentStep: tourCurrentStep,
steps: tourSteps,
setIsOpen,
})
}
variant="subtle" variant="subtle"
size="lg" 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 />} {isLast ? <CheckIcon /> : <ArrowIcon />}
</ActionIcon> </ActionIcon>
@@ -127,10 +151,17 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
}} }}
components={{ components={{
Close: ({ onClick }) => ( 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 }) => ( 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 { StepType } from "@reactour/tour";
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { addGlowToElements, removeAllGlows } from "@app/components/onboarding/tourGlow"; import {
addGlowToElements,
removeAllGlows,
} from "@app/components/onboarding/tourGlow";
export enum AdminTourStep { export enum AdminTourStep {
WELCOME, WELCOME,
@@ -26,8 +29,16 @@ interface CreateAdminStepsConfigArgs {
actions: AdminStepActions; actions: AdminStepActions;
} }
export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> { export function createAdminStepsConfig({
const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions; t,
actions,
}: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
const {
saveAdminState,
openConfigModal,
navigateToSection,
scrollNavToSection,
} = actions;
return { return {
[AdminTourStep.WELCOME]: { [AdminTourStep.WELCOME]: {
@@ -120,7 +131,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
}, },
[AdminTourStep.DATABASE_SECTION]: { [AdminTourStep.DATABASE_SECTION]: {
selector: '[data-tour="admin-adminDatabase-nav"]', 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( content: t(
"adminOnboarding.databaseSection", "adminOnboarding.databaseSection",
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.", "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(); removeAllGlows();
navigateToSection("adminDatabase"); navigateToSection("adminDatabase");
setTimeout(() => { setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']); addGlowToElements([
'[data-tour="admin-adminDatabase-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100); }, 100);
}, },
}, },
[AdminTourStep.CONNECTIONS_SECTION]: { [AdminTourStep.CONNECTIONS_SECTION]: {
selector: '[data-tour="admin-adminConnections-nav"]', 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( content: t(
"adminOnboarding.connectionsSection", "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.", "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(); removeAllGlows();
navigateToSection("adminConnections"); navigateToSection("adminConnections");
setTimeout(() => { setTimeout(() => {
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']); addGlowToElements([
'[data-tour="admin-adminConnections-nav"]',
'[data-tour="settings-content-area"]',
]);
}, 100); }, 100);
}, },
actionAfter: async () => { actionAfter: async () => {
@@ -20,7 +20,14 @@ export type SlideId =
| "analytics-choice" | "analytics-choice"
| "mfa-setup"; | "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 = export type ButtonAction =
| "next" | "next"
@@ -91,7 +98,11 @@ export interface SlideDefinition {
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = { export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
"first-login": { "first-login": {
id: "first-login", id: "first-login",
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) => createSlide: ({
firstLoginUsername,
onPasswordChanged,
usingDefaultCredentials,
}) =>
FirstLoginSlide({ FirstLoginSlide({
username: firstLoginUsername || "", username: firstLoginUsername || "",
onPasswordChanged: onPasswordChanged || (() => {}), onPasswordChanged: onPasswordChanged || (() => {}),
@@ -148,7 +159,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
}, },
"security-check": { "security-check": {
id: "security-check", id: "security-check",
createSlide: ({ selectedRole, onRoleSelect }) => SecurityCheckSlide({ selectedRole, onRoleSelect }), createSlide: ({ selectedRole, onRoleSelect }) =>
SecurityCheckSlide({ selectedRole, onRoleSelect }),
hero: { type: "shield" }, hero: { type: "shield" },
buttons: [ buttons: [
{ {
@@ -172,7 +184,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
}, },
"admin-overview": { "admin-overview": {
id: "admin-overview", id: "admin-overview",
createSlide: ({ licenseNotice, loginEnabled }) => PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }), createSlide: ({ licenseNotice, loginEnabled }) =>
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
hero: { type: "diamond" }, hero: { type: "diamond" },
buttons: [ buttons: [
{ {
@@ -262,7 +275,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
}, },
"analytics-choice": { "analytics-choice": {
id: "analytics-choice", id: "analytics-choice",
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }), createSlide: ({ analyticsError }) =>
AnalyticsChoiceSlide({ analyticsError }),
hero: { type: "analytics" }, hero: { type: "analytics" },
buttons: [ buttons: [
{ {
@@ -285,7 +299,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
}, },
"mfa-setup": { "mfa-setup": {
id: "mfa-setup", id: "mfa-setup",
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }), createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) =>
MFASetupSlide({ onMfaSetupComplete }),
hero: { type: "lock" }, hero: { type: "lock" },
buttons: [], // Form has its own submit button buttons: [], // Form has its own submit button
}, },
@@ -115,13 +115,15 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
id: "tour-overview", id: "tour-overview",
type: "modal-slide", type: "modal-slide",
slideId: "tour-overview", slideId: "tour-overview",
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp, condition: (ctx) =>
!ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
}, },
{ {
id: "server-license", id: "server-license",
type: "modal-slide", type: "modal-slide",
slideId: "server-license", slideId: "server-license",
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense, condition: (ctx) =>
ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
}, },
{ {
id: "mfa-setup", id: "mfa-setup",
@@ -16,7 +16,10 @@ export function markOnboardingCompleted(): void {
try { try {
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true"); localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
} catch (error) { } 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 { try {
localStorage.removeItem(ONBOARDING_COMPLETED_KEY); localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
} catch (error) { } 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 { try {
localStorage.setItem(TOURS_TOOLTIP_KEY, "true"); localStorage.setItem(TOURS_TOOLTIP_KEY, "true");
} catch (error) { } 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>; const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
// If user had completed onboarding in old system, mark new system as complete // 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(); markOnboardingCompleted();
} }
} }
@@ -31,19 +31,27 @@ function hasAuthToken(): boolean {
} }
// Get initial runtime state from session storage (survives remounts) // Get initial runtime state from session storage (survives remounts)
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState { function getInitialRuntimeState(
baseState: OnboardingRuntimeState,
): OnboardingRuntimeState {
if (typeof window === "undefined") { if (typeof window === "undefined") {
return baseState; return baseState;
} }
try { 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 sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
const tourType = const tourType =
sessionTourType === "admin" || sessionTourType === "tools" || sessionTourType === "whatsnew" sessionTourType === "admin" ||
sessionTourType === "tools" ||
sessionTourType === "whatsnew"
? sessionTourType ? sessionTourType
: "whatsnew"; : "whatsnew";
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as "admin" | "user" | null; const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as
| "admin"
| "user"
| null;
return { return {
...baseState, ...baseState,
@@ -61,7 +69,10 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
try { try {
if (state.tourRequested !== undefined) { 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) { if (state.tourType !== undefined) {
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType); sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
@@ -74,7 +85,10 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
} }
} }
} catch (error) { } 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 }; const parsed = JSON.parse(settings) as { mfaRequired?: string };
return parsed.mfaRequired?.toLowerCase() === "true"; return parsed.mfaRequired?.toLowerCase() === "true";
} catch (error) { } catch (error) {
console.warn("[useOnboardingOrchestrator] Failed to parse account settings JSON:", error); console.warn(
"[useOnboardingOrchestrator] Failed to parse account settings JSON:",
error,
);
return false; return false;
} }
} }
@@ -152,14 +169,18 @@ export interface UseOnboardingOrchestratorOptions {
defaultRuntimeState?: OnboardingRuntimeState; defaultRuntimeState?: OnboardingRuntimeState;
} }
export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOptions): UseOnboardingOrchestratorResult { export function useOnboardingOrchestrator(
options?: UseOnboardingOrchestratorOptions,
): UseOnboardingOrchestratorResult {
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE; const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
const serverExperience = useServerExperience(); const serverExperience = useServerExperience();
const { config, loading: configLoading } = useAppConfig(); const { config, loading: configLoading } = useAppConfig();
const location = useLocation(); const location = useLocation();
const bypassOnboarding = useBypassOnboarding(); const bypassOnboarding = useBypassOnboarding();
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() => getInitialRuntimeState(defaultState)); const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
getInitialRuntimeState(defaultState),
);
const [isPaused, setIsPaused] = useState(false); const [isPaused, setIsPaused] = useState(false);
const [isInitialized, setIsInitialized] = useState(false); const [isInitialized, setIsInitialized] = useState(false);
const [currentStepIndex, setCurrentStepIndex] = useState(-1); const [currentStepIndex, setCurrentStepIndex] = useState(-1);
@@ -186,7 +207,8 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
requiresLicense: requiresLicense:
!serverExperience.hasPaidLicense && !serverExperience.hasPaidLicense &&
(serverExperience.overFreeTierLimit === true || (serverExperience.overFreeTierLimit === true ||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)), (serverExperience.effectiveIsAdmin &&
serverExperience.userCountResolved)),
}, },
})); }));
}, [ }, [
@@ -217,7 +239,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
requiresMfaSetup: parseMfaRequired(accountData.settings), requiresMfaSetup: parseMfaRequired(accountData.settings),
})); }));
} catch (error) { } 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 // Account endpoint failed - user not logged in or security disabled
} }
}; };
@@ -227,17 +252,25 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
} }
}, [config?.enableLogin, configLoading]); }, [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 loginEnabled = config?.enableLogin === true;
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken(); const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
const shouldBlockOnboarding = bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled; const shouldBlockOnboarding =
bypassOnboarding ||
isOnAuthRoute ||
configLoading ||
isUnauthenticatedWithLoginEnabled;
const conditionContext = useMemo<OnboardingConditionContext>( const conditionContext = useMemo<OnboardingConditionContext>(
() => ({ () => ({
...serverExperience, ...serverExperience,
...runtimeState, ...runtimeState,
effectiveIsAdmin: effectiveIsAdmin:
serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === "admin"), serverExperience.effectiveIsAdmin ||
(!serverExperience.loginEnabled &&
runtimeState.selectedRole === "admin"),
}), }),
[serverExperience, runtimeState], [serverExperience, runtimeState],
); );
@@ -248,7 +281,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
// Wait for config AND admin status before calculating initial step // Wait for config AND admin status before calculating initial step
const adminStatusResolved = const adminStatusResolved =
!configLoading && (config?.enableLogin === false || config?.enableLogin === undefined || config?.isAdmin !== undefined); !configLoading &&
(config?.enableLogin === false ||
config?.enableLogin === undefined ||
config?.isAdmin !== undefined);
useEffect(() => { useEffect(() => {
if (configLoading || !adminStatusResolved) return; if (configLoading || !adminStatusResolved) return;
@@ -276,9 +312,21 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
const totalSteps = activeFlow.length; const totalSteps = activeFlow.length;
const isComplete = isInitialized && (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted()); const isComplete =
const currentStep = currentStepIndex >= 0 && currentStepIndex < totalSteps ? activeFlow[currentStepIndex] : null; isInitialized &&
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null; (totalSteps === 0 ||
currentStepIndex >= totalSteps ||
isOnboardingCompleted());
const currentStep =
currentStepIndex >= 0 && currentStepIndex < totalSteps
? activeFlow[currentStepIndex]
: null;
const isActive =
!shouldBlockOnboarding &&
!isPaused &&
!isComplete &&
isInitialized &&
currentStep !== null;
const isLoading = const isLoading =
configLoading || configLoading ||
!adminStatusResolved || !adminStatusResolved ||
@@ -322,10 +370,13 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
setCurrentStepIndex(nextIndex); setCurrentStepIndex(nextIndex);
}, [currentStepIndex, totalSteps]); }, [currentStepIndex, totalSteps]);
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => { const updateRuntimeState = useCallback(
persistRuntimeState(updates); (updates: Partial<OnboardingRuntimeState>) => {
setRuntimeState((prev) => ({ ...prev, ...updates })); persistRuntimeState(updates);
}, []); setRuntimeState((prev) => ({ ...prev, ...updates }));
},
[],
);
const refreshFlow = useCallback(() => { const refreshFlow = useCallback(() => {
initialIndexSet.current = false; initialIndexSet.current = false;
@@ -11,10 +11,15 @@ interface AnalyticsChoiceSlideProps {
analyticsError?: string | null; analyticsError?: string | null;
} }
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig { export default function AnalyticsChoiceSlide({
analyticsError,
}: AnalyticsChoiceSlideProps): SlideConfig {
return { return {
key: "analytics-choice", 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: ( body: (
<div className={styles.bodyCopyInner}> <div className={styles.bodyCopyInner}>
<Trans <Trans
@@ -33,13 +38,22 @@ export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoice
<Button <Button
variant="default" variant="default"
size="sm" 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 }} />} rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
> >
{i18n.t("analytics.learnMore", "Learn more about our analytics")} {i18n.t("analytics.learnMore", "Learn more about our analytics")}
</Button> </Button>
</div> </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> </div>
), ),
background: { background: {
@@ -65,6 +65,10 @@
transform: translate3d(0, 0, 0); transform: translate3d(0, 0, 0);
} }
100% { 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; slideKey: string;
} }
export default function AnimatedSlideBackground({ gradientStops, circles, isActive }: AnimatedSlideBackgroundComponentProps) { export default function AnimatedSlideBackground({
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null); gradientStops,
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(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 [isTransitioning, setIsTransitioning] = React.useState(false);
const isFirstMount = React.useRef(true); const isFirstMount = React.useRef(true);
@@ -29,7 +36,10 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
} }
// Only transition if gradient actually changed // 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 // Store previous gradient and start transition
setPrevGradient(currentGradient); setPrevGradient(currentGradient);
setIsTransitioning(true); setIsTransitioning(true);
@@ -69,10 +79,20 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
style={currentGradientStyle} style={currentGradientStyle}
/> />
{circles.map((circle, index) => { {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 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 = { const circleStyle: CircleStyles = {
width: size, width: size,
@@ -98,7 +118,13 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
circleStyle.top = `${defaultOffset + offsetY}px`; 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> </div>
); );
@@ -2,7 +2,10 @@ import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { SlideConfig } from "@app/types/types"; import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; 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 }; export type { OSOption };
@@ -43,7 +43,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
const displayLabel = currentOsOption.label || osLabel; const displayLabel = currentOsOption.label || osLabel;
const title = displayLabel 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"); : t("onboarding.desktopInstall.title", "Download");
// If only one option or no options, don't show dropdown // If only one option or no options, don't show dropdown
@@ -52,7 +54,15 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
} }
return ( 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> <span style={{ whiteSpace: "nowrap" }}>{title}</span>
<Menu position="bottom" offset={5} zIndex={10000}> <Menu position="bottom" offset={5} zIndex={10000}>
<Menu.Target> <Menu.Target>
@@ -80,7 +90,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
backgroundColor: isSelected backgroundColor: isSelected
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))" ? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
: "transparent", : "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} {option.label}
@@ -16,10 +16,16 @@ interface FirstLoginSlideProps {
const DEFAULT_PASSWORD = "stirling"; const DEFAULT_PASSWORD = "stirling";
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) { function FirstLoginForm({
username,
onPasswordChanged,
usingDefaultCredentials = false,
}: FirstLoginSlideProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// If using default credentials, pre-fill with "stirling" - user won't see this field // 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 [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -27,23 +33,39 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
const handleSubmit = async () => { const handleSubmit = async () => {
// Validation // Validation
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) { if (
(!usingDefaultCredentials && !currentPassword) ||
!newPassword ||
!confirmPassword
) {
setError(t("firstLogin.allFieldsRequired", "All fields are required")); setError(t("firstLogin.allFieldsRequired", "All fields are required"));
return; return;
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match")); setError(
t("firstLogin.passwordsDoNotMatch", "New passwords do not match"),
);
return; return;
} }
if (newPassword.length < 8) { 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; return;
} }
if (newPassword === currentPassword) { 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; return;
} }
@@ -51,11 +73,18 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
setLoading(true); setLoading(true);
setError(""); setError("");
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword); await accountService.changePasswordOnLogin(
currentPassword,
newPassword,
confirmPassword,
);
showToast({ showToast({
alertType: "success", 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 // Clear form
@@ -73,7 +102,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
const axiosError = err as { response?: { data?: { message?: string } } }; const axiosError = err as { response?: { data?: { message?: string } } };
setError( setError(
axiosError.response?.data?.message || 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 { } finally {
setLoading(false); setLoading(false);
@@ -85,18 +117,33 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
<div className={styles.securityCard}> <div className={styles.securityCard}>
<Stack gap="md"> <Stack gap="md">
<div className={styles.securityAlertRow}> <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> <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> </span>
</div> </div>
<Text size="sm" fw={500}> <Text size="sm" fw={500}>
{t("firstLogin.loggedInAs", "Logged in as")}: <strong>{username}</strong> {t("firstLogin.loggedInAs", "Logged in as")}:{" "}
<strong>{username}</strong>
</Text> </Text>
{error && ( {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} {error}
</Alert> </Alert>
)} )}
@@ -105,7 +152,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
{!usingDefaultCredentials && ( {!usingDefaultCredentials && (
<PasswordInput <PasswordInput
label={t("firstLogin.currentPassword", "Current Password")} label={t("firstLogin.currentPassword", "Current Password")}
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")} placeholder={t(
"firstLogin.enterCurrentPassword",
"Enter your current password",
)}
value={currentPassword} value={currentPassword}
onChange={(e) => setCurrentPassword(e.currentTarget.value)} onChange={(e) => setCurrentPassword(e.currentTarget.value)}
required required
@@ -117,7 +167,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
<PasswordInput <PasswordInput
label={t("firstLogin.newPassword", "New Password")} 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} value={newPassword}
onChange={(e) => setNewPassword(e.currentTarget.value)} onChange={(e) => setNewPassword(e.currentTarget.value)}
minLength={8} minLength={8}
@@ -129,7 +182,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
<PasswordInput <PasswordInput
label={t("firstLogin.confirmPassword", "Confirm New Password")} 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} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.currentTarget.value)} onChange={(e) => setConfirmPassword(e.currentTarget.value)}
required required
@@ -143,7 +199,12 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
fullWidth fullWidth
onClick={handleSubmit} onClick={handleSubmit}
loading={loading} loading={loading}
disabled={!newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8} disabled={
!newPassword ||
!confirmPassword ||
newPassword.length < 8 ||
confirmPassword.length < 8
}
size="md" size="md"
mt="xs" mt="xs"
> >
@@ -1,5 +1,20 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import {
import { Alert, Box, Button, Group, Loader, Stack, Text, TextInput } from "@mantine/core"; 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 { QRCodeSVG } from "qrcode.react";
import { SlideConfig } from "@app/types/types"; import { SlideConfig } from "@app/types/types";
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig"; import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
@@ -16,7 +31,9 @@ interface MFASetupSlideProps {
} }
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) { function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null); const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
null,
);
const [mfaSetupCode, setMfaSetupCode] = useState(""); const [mfaSetupCode, setMfaSetupCode] = useState("");
const [mfaError, setMfaError] = useState(""); const [mfaError, setMfaError] = useState("");
const [mfaLoading, setMfaLoading] = useState(false); const [mfaLoading, setMfaLoading] = useState(false);
@@ -27,7 +44,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
const accountLogout = useAccountLogout(); const accountLogout = useAccountLogout();
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`; 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 () => { const fetchMfaSetup = useCallback(async () => {
try { try {
@@ -38,7 +58,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
setMfaSetupData(data); setMfaSetupData(data);
} catch (err) { } catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } }; 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 { } finally {
setMfaLoading(false); setMfaLoading(false);
} }
@@ -84,7 +107,8 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
} catch (err) { } catch (err) {
const axiosError = err as { response?: { data?: { error?: string } } }; const axiosError = err as { response?: { data?: { error?: string } } };
setMfaError( 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 { } finally {
setSubmitting(false); setSubmitting(false);
@@ -137,12 +161,17 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
<div className={styles.mfaCard}> <div className={styles.mfaCard}>
<Stack gap="md"> <Stack gap="md">
<Text size="sm" c="dimmed"> <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 Secure your account by linking an authenticator app. Scan the QR
6-digit code to finish. code or enter the setup key, then confirm the 6-digit code to
finish.
</Text> </Text>
{mfaError && ( {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} {mfaError}
</Alert> </Alert>
)} )}
@@ -163,7 +192,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
label="Authentication code" label="Authentication code"
placeholder="123456" placeholder="123456"
value={mfaSetupCode} value={mfaSetupCode}
onChange={(event) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))} onChange={(event) =>
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
}
inputMode="numeric" inputMode="numeric"
maxLength={6} maxLength={6}
minLength={6} minLength={6}
@@ -179,7 +210,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
> >
Regenerate QR code Regenerate QR code
</Button> </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 Enable MFA
</Button> </Button>
<Button type="button" variant="light" onClick={onLogout}> <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 { return {
key: "mfa-setup-slide", key: "mfa-setup-slide",
title: "Multi-Factor Authentication Setup", 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 const adminBodyKey = loginEnabled
? "onboarding.planOverview.adminBodyLoginEnabled" ? "onboarding.planOverview.adminBodyLoginEnabled"
: "onboarding.planOverview.adminBodyLoginDisabled"; : "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."; : "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 ( 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 }> = ({ const PlanOverviewBody: React.FC<{
isAdmin, isAdmin: boolean;
freeTierLimit, freeTierLimit: number;
loginEnabled, loginEnabled: boolean;
}) => (isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />); }> = ({ isAdmin, freeTierLimit, loginEnabled }) =>
isAdmin ? (
<AdminOverviewBody
freeTierLimit={freeTierLimit}
loginEnabled={loginEnabled}
/>
) : (
<UserOverviewBody />
);
export default function PlanOverviewSlide({ export default function PlanOverviewSlide({
isAdmin, isAdmin,
@@ -64,7 +80,13 @@ export default function PlanOverviewSlide({
return { return {
key: isAdmin ? "admin-overview" : "plan-overview", key: isAdmin ? "admin-overview" : "plan-overview",
title: <PlanOverviewTitle isAdmin={isAdmin} />, title: <PlanOverviewTitle isAdmin={isAdmin} />,
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />, body: (
<PlanOverviewBody
isAdmin={isAdmin}
freeTierLimit={freeTierLimit}
loginEnabled={loginEnabled}
/>
),
background: { background: {
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"], gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
circles: UNIFIED_CIRCLE_CONFIG, circles: UNIFIED_CIRCLE_CONFIG,
@@ -11,7 +11,10 @@ interface SecurityCheckSlideProps {
onRoleSelect: (role: "admin" | "user" | null) => void; onRoleSelect: (role: "admin" | "user" | null) => void;
} }
export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: SecurityCheckSlideProps): SlideConfig { export default function SecurityCheckSlide({
selectedRole,
onRoleSelect,
}: SecurityCheckSlideProps): SlideConfig {
return { return {
key: "security-check", key: "security-check",
title: "Security Check", title: "Security Check",
@@ -19,7 +22,12 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
<div className={styles.securitySlideContent}> <div className={styles.securitySlideContent}>
<div className={styles.securityCard}> <div className={styles.securityCard}>
<div className={styles.securityAlertRow}> <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> <span>
{i18n.t( {i18n.t(
"onboarding.securityCheck.message", "onboarding.securityCheck.message",
@@ -35,7 +43,9 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
{ value: "admin", label: "Admin" }, { value: "admin", label: "Admin" },
{ value: "user", label: "User" }, { 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 }} comboboxProps={{ withinPortal: true, zIndex: 5000 }}
styles={{ styles={{
input: { input: {
@@ -10,11 +10,14 @@ interface ServerLicenseSlideProps {
const DEFAULT_FREE_TIER_LIMIT = 5; 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 freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
const totalUsers = licenseNotice?.totalUsers ?? null; const totalUsers = licenseNotice?.totalUsers ?? null;
const isOverLimit = licenseNotice?.isOverLimit ?? false; 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 overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
const title = isOverLimit const title = isOverLimit
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed") ? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
@@ -50,7 +53,9 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
title, title,
body, body,
background: { background: {
gradientStops: isOverLimit ? ["#F472B6", "#8B5CF6"] : ["#F97316", "#F59E0B"], gradientStops: isOverLimit
? ["#F472B6", "#8B5CF6"]
: ["#F97316", "#F59E0B"],
circles: UNIFIED_CIRCLE_CONFIG, circles: UNIFIED_CIRCLE_CONFIG,
}, },
}; };
@@ -12,6 +12,10 @@ export const addGlowToElements = (selectors: string[]) => {
}; };
export const removeAllGlows = () => { export const removeAllGlows = () => {
document.querySelectorAll(".tour-content-glow").forEach((el) => el.classList.remove("tour-content-glow")); document
document.querySelectorAll(".tour-nav-glow").forEach((el) => el.classList.remove("tour-nav-glow")); .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 { export function useBypassOnboarding(): boolean {
const location = useLocation(); const location = useLocation();
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass()); const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() =>
readStoredBypass(),
);
const shouldBypassFromSearch = useMemo(() => { const shouldBypassFromSearch = useMemo(() => {
try { try {
@@ -36,7 +36,10 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
case "windows": case "windows":
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS }; return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
case "mac-apple": 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": case "mac-intel":
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL }; return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
case "linux-x64": case "linux-x64":
@@ -51,8 +54,16 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
() => () =>
[ [
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" }, { 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" }, { label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
].filter((opt) => opt.url), ].filter((opt) => opt.url),
[], [],
@@ -14,7 +14,9 @@ export function useServerLicenseRequest(): {
closeLicenseSlide: () => void; closeLicenseSlide: () => void;
} { } {
const [showLicenseSlide, setShowLicenseSlide] = useState(false); const [showLicenseSlide, setShowLicenseSlide] = useState(false);
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState["licenseNotice"] | null>(null); const [licenseNotice, setLicenseNotice] = useState<
OnboardingRuntimeState["licenseNotice"] | null
>(null);
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -35,7 +37,11 @@ export function useServerLicenseRequest(): {
}; };
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest); 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(() => { const closeLicenseSlide = useCallback(() => {
@@ -51,7 +57,8 @@ export function useTourRequest(): {
clearTourRequest: () => void; clearTourRequest: () => void;
} { } {
const [tourRequested, setTourRequested] = useState(false); const [tourRequested, setTourRequested] = useState(false);
const [requestedTourType, setRequestedTourType] = useState<TourType>("whatsnew"); const [requestedTourType, setRequestedTourType] =
useState<TourType>("whatsnew");
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
@@ -63,7 +70,8 @@ export function useTourRequest(): {
}; };
window.addEventListener(START_TOUR_EVENT, handleTourRequest); window.addEventListener(START_TOUR_EVENT, handleTourRequest);
return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest); return () =>
window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
}, []); }, []);
const clearTourRequest = useCallback(() => { const clearTourRequest = useCallback(() => {
@@ -36,7 +36,10 @@ interface CreateUserStepsConfigArgs {
actions: UserStepActions; actions: UserStepActions;
} }
export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record<TourStep, StepType> { export function createUserStepsConfig({
t,
actions,
}: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
const { const {
saveWorkbenchState, saveWorkbenchState,
closeFilesModal, closeFilesModal,
@@ -1,7 +1,11 @@
import type { StepType } from "@reactour/tour"; import type { StepType } from "@reactour/tour";
import type { TFunction } from "i18next"; 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; if (typeof document === "undefined") return;
const start = Date.now(); const start = Date.now();
// Immediate hit // 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; if (typeof document === "undefined") return;
const start = Date.now(); const start = Date.now();
@@ -68,7 +76,10 @@ interface CreateWhatsNewStepsConfigArgs {
actions: WhatsNewStepActions; actions: WhatsNewStepActions;
} }
export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> { export function createWhatsNewStepsConfig({
t,
actions,
}: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
const { const {
saveWorkbenchState, saveWorkbenchState,
closeFilesModal, closeFilesModal,
@@ -128,7 +139,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
}, },
[WhatsNewTourStep.RIGHT_RAIL]: { [WhatsNewTourStep.RIGHT_RAIL]: {
selector: '[data-tour="right-rail-controls"]', 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( content: t(
"onboarding.whatsNew.rightRail", "onboarding.whatsNew.rightRail",
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.", "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]: { [WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
selector: '[data-tour="view-switcher"]', 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", position: "bottom",
padding: 8, padding: 8,
action: async () => { action: async () => {
@@ -39,9 +39,17 @@ const BulkSelectionPanel = ({
onToggleAdvanced={setAdvancedOpened} 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 <AdvancedSelectionPanel
csvInput={csvInput} 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 { Box } from "@mantine/core";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants"; import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
import styles from "@app/components/pageEditor/DragDropGrid.module.css"; 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 { LocalIcon } from "@app/components/shared/LocalIcon";
import { import {
DndContext, DndContext,
@@ -28,7 +38,11 @@ interface DragDropItem {
interface DragDropGridProps<T extends DragDropItem> { interface DragDropGridProps<T extends DragDropItem> {
items: T[]; items: T[];
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; onReorderPages: (
sourcePageNumber: number,
targetIndex: number,
selectedPageIds?: string[],
) => void;
renderItem: ( renderItem: (
item: T, item: T,
index: number, index: number,
@@ -40,7 +54,9 @@ interface DragDropGridProps<T extends DragDropItem> {
dragHandleProps?: any, dragHandleProps?: any,
zoomLevel?: number, zoomLevel?: number,
) => React.ReactNode; ) => React.ReactNode;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; getThumbnailData?: (
itemId: string,
) => { src: string; rotation: number } | null;
zoomLevel?: number; zoomLevel?: number;
selectedFileIds?: string[]; selectedFileIds?: string[];
selectedPageIds?: string[]; selectedPageIds?: string[];
@@ -67,7 +83,10 @@ function resolveDropHint(
} }
const items: ItemRect[] = Array.from(itemRefs.current.entries()) 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]) => ({ .map(([itemId, element]) => ({
id: itemId, id: itemId,
rect: element.getBoundingClientRect(), rect: element.getBoundingClientRect(),
@@ -90,7 +109,8 @@ function resolveDropHint(
return; 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) { if (isSameRow) {
currentRow.push(item); currentRow.push(item);
} else { } else {
@@ -174,7 +194,9 @@ function resolveTargetIndex<T extends DragDropItem>(
}; };
if (hoveredId) { if (hoveredId) {
const filteredIndex = filteredItems.findIndex((item) => item.id === hoveredId); const filteredIndex = filteredItems.findIndex(
(item) => item.id === hoveredId,
);
if (filteredIndex !== -1) { if (filteredIndex !== -1) {
const adjustedIndex = filteredIndex + (dropSide === "right" ? 1 : 0); const adjustedIndex = filteredIndex + (dropSide === "right" ? 1 : 0);
return convertFilteredIndexToOriginal(adjustedIndex); return convertFilteredIndexToOriginal(adjustedIndex);
@@ -198,7 +220,9 @@ interface DraggableItemProps<T extends DragDropItem> {
clearBoxSelection: () => void; clearBoxSelection: () => void;
activeDragIds: string[]; activeDragIds: string[];
justMoved: boolean; justMoved: boolean;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; getThumbnailData?: (
itemId: string,
) => { src: string; rotation: number } | null;
onUpdateDropTarget: (itemId: string | null) => void; onUpdateDropTarget: (itemId: string | null) => void;
renderItem: ( renderItem: (
item: T, item: T,
@@ -247,11 +271,15 @@ const DraggableItemInner = <T extends DragDropItem>({
} }
const element = itemRefs.current.get(item.id); 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) { if (imgElement?.src) {
return { return {
src: imgElement.src, src: imgElement.src,
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0, rotation: imgElement.dataset.originalRotation
? parseInt(imgElement.dataset.originalRotation)
: 0,
}; };
} }
return null; return null;
@@ -312,8 +340,12 @@ const DraggableItem = React.memo(DraggableItemInner, (prevProps, nextProps) => {
} }
// Check if page selection changed (for checkbox selection, not box selection) // Check if page selection changed (for checkbox selection, not box selection)
const prevSelectedSet = prevProps.selectedPageIds ? new Set(prevProps.selectedPageIds) : null; const prevSelectedSet = prevProps.selectedPageIds
const nextSelectedSet = nextProps.selectedPageIds ? new Set(nextProps.selectedPageIds) : null; ? new Set(prevProps.selectedPageIds)
: null;
const nextSelectedSet = nextProps.selectedPageIds
? new Set(nextProps.selectedPageIds)
: null;
if (prevSelectedSet && nextSelectedSet) { if (prevSelectedSet && nextSelectedSet) {
const prevSelected = prevSelectedSet.has(prevProps.item.id); const prevSelected = prevSelectedSet.has(prevProps.item.id);
@@ -348,36 +380,51 @@ const DragDropGrid = <T extends DragDropItem>({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const getScrollElement = useCallback(() => { 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 // Create stable signature for items to ensure useMemo detects changes
const itemsSignature = useMemo(() => items.map((item) => item.id).join(","), [items]); const itemsSignature = useMemo(
const selectedFileIdsSignature = useMemo(() => selectedFileIds?.join(",") || "", [selectedFileIds]); () => items.map((item) => item.id).join(","),
[items],
);
const selectedFileIdsSignature = useMemo(
() => selectedFileIds?.join(",") || "",
[selectedFileIds],
);
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => { const { filteredItems: visibleItems, filteredToOriginalIndex } =
const filtered: T[] = []; useMemo(() => {
const indexMap: number[] = []; const filtered: T[] = [];
const selectedIds = selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null; const indexMap: number[] = [];
const selectedIds =
selectedFileIds && selectedFileIds.length > 0
? new Set(selectedFileIds)
: null;
items.forEach((item, index) => { items.forEach((item, index) => {
const isPlaceholder = Boolean(item.isPlaceholder); const isPlaceholder = Boolean(item.isPlaceholder);
if (isPlaceholder) { if (isPlaceholder) {
return; return;
} }
const belongsToVisibleFile = !selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId); const belongsToVisibleFile =
!selectedIds ||
!item.originalFileId ||
selectedIds.has(item.originalFileId);
if (!belongsToVisibleFile) { if (!belongsToVisibleFile) {
return; return;
} }
filtered.push(item); filtered.push(item);
indexMap.push(index); indexMap.push(index);
}); });
return { filteredItems: filtered, filteredToOriginalIndex: indexMap }; return { filteredItems: filtered, filteredToOriginalIndex: indexMap };
}, [items, selectedFileIds, itemsSignature, selectedFileIdsSignature]); }, [items, selectedFileIds, itemsSignature, selectedFileIdsSignature]);
useEffect(() => { useEffect(() => {
const visibleIdSet = new Set(visibleItems.map((item) => item.id)); const visibleIdSet = new Set(visibleItems.map((item) => item.id));
@@ -389,8 +436,14 @@ const DragDropGrid = <T extends DragDropItem>({
}, [visibleItems]); }, [visibleItems]);
// Box selection state // Box selection state
const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null); const [boxSelectStart, setBoxSelectStart] = useState<{
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null); x: number;
y: number;
} | null>(null);
const [boxSelectEnd, setBoxSelectEnd] = useState<{
x: number;
y: number;
} | null>(null);
const [isBoxSelecting, setIsBoxSelecting] = useState(false); const [isBoxSelecting, setIsBoxSelecting] = useState(false);
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]); const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
const [justMovedIds, setJustMovedIds] = useState<string[]>([]); const [justMovedIds, setJustMovedIds] = useState<string[]>([]);
@@ -398,7 +451,10 @@ const DragDropGrid = <T extends DragDropItem>({
// Drag state // Drag state
const [activeId, setActiveId] = useState<string | null>(null); 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 [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
const [dropSide, setDropSide] = useState<DropSide>(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 () => { return () => {
window.removeEventListener("pointermove", handlePointerMove); window.removeEventListener("pointermove", handlePointerMove);
if (rafId !== null) { if (rafId !== null) {
@@ -449,7 +507,10 @@ const DragDropGrid = <T extends DragDropItem>({
// Responsive grid configuration // Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4); 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 // Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => { const calculateItemsPerRow = useCallback(() => {
@@ -459,8 +520,11 @@ const DragDropGrid = <T extends DragDropItem>({
if (containerWidth === 0) return 4; // Container not measured yet if (containerWidth === 0) return 4; // Container not measured yet
// Convert rem to pixels for calculation // Convert rem to pixels for calculation
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); const remToPx = parseFloat(
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel; getComputedStyle(document.documentElement).fontSize,
);
const ITEM_WIDTH =
parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel; const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
// Calculate how many items fit: (width - gap) / (itemWidth + gap) // Calculate how many items fit: (width - gap) / (itemWidth + gap)
@@ -501,7 +565,9 @@ const DragDropGrid = <T extends DragDropItem>({
count: Math.ceil(visibleItems.length / itemsPerRow), count: Math.ceil(visibleItems.length / itemsPerRow),
getScrollElement, getScrollElement,
estimateSize: () => { estimateSize: () => {
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize); const remToPx = parseFloat(
getComputedStyle(document.documentElement).fontSize,
);
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel; return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
}, },
overscan: OVERSCAN, overscan: OVERSCAN,
@@ -524,10 +590,19 @@ const DragDropGrid = <T extends DragDropItem>({
// Re-measure virtualizer when zoom or items per row changes // Re-measure virtualizer when zoom or items per row changes
// Also remeasure when items change (not just length) to handle item additions/removals // 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(() => { useEffect(() => {
rowVirtualizer.measure(); rowVirtualizer.measure();
}, [zoomLevel, itemsPerRow, visibleItems.length, visibleItemsSignature, rowVirtualizer]); }, [
zoomLevel,
itemsPerRow,
visibleItems.length,
visibleItemsSignature,
rowVirtualizer,
]);
// Cleanup highlight timeout on unmount // Cleanup highlight timeout on unmount
useEffect(() => { useEffect(() => {
@@ -559,7 +634,10 @@ const DragDropGrid = <T extends DragDropItem>({
if (clickedPageId) { if (clickedPageId) {
// Clicking directly on a page shouldn't initiate box selection // Clicking directly on a page shouldn't initiate box selection
// but clear previous box selection if clicking outside current group // 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([]); setBoxSelectedPageIds([]);
} }
return; return;
@@ -600,7 +678,12 @@ const DragDropGrid = <T extends DragDropItem>({
const pageBottom = pageRect.bottom - rect.top; const pageBottom = pageRect.bottom - rect.top;
// Check if page intersects with selection box // 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) { if (intersects) {
selectedIds.push(pageId); selectedIds.push(pageId);
@@ -637,7 +720,10 @@ const DragDropGrid = <T extends DragDropItem>({
if (getThumbnail) { if (getThumbnail) {
const thumbnailData = getThumbnail(); const thumbnailData = getThumbnail();
if (thumbnailData?.src) { if (thumbnailData?.src) {
setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation }); setDragPreview({
src: thumbnailData.src,
rotation: thumbnailData.rotation,
});
return; return;
} }
} }
@@ -692,7 +778,10 @@ const DragDropGrid = <T extends DragDropItem>({
// Check if this page is box-selected // Check if this page is box-selected
const isBoxSelected = boxSelectedPageIds.includes(active.id as string); 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 // Call reorder with page number and target index
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag); onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
@@ -726,7 +815,9 @@ const DragDropGrid = <T extends DragDropItem>({
); );
// Calculate optimal width for centering // 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 itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel; const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap; const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
@@ -814,9 +905,13 @@ const DragDropGrid = <T extends DragDropItem>({
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onWheel={handleWheelWhileDragging} 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 <div
className={styles.virtualRows} className={styles.virtualRows}
@@ -828,7 +923,10 @@ const DragDropGrid = <T extends DragDropItem>({
> >
{virtualRows.map((virtualRow) => { {virtualRows.map((virtualRow) => {
const startIndex = virtualRow.index * itemsPerRow; 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); const rowItems = visibleItems.slice(startIndex, endIndex);
return ( return (
@@ -877,11 +975,15 @@ const DragDropGrid = <T extends DragDropItem>({
<DragOverlay> <DragOverlay>
{activeId && ( {activeId && (
<div className={styles.dragOverlay}> <div className={styles.dragOverlay}>
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && ( {boxSelectedPageIds.includes(activeId) &&
<div className={styles.dragOverlayBadge} style={{ zIndex: Z_INDEX_DRAG_BADGE }}> boxSelectedPageIds.length > 1 && (
{boxSelectedPageIds.length} <div
</div> className={styles.dragOverlayBadge}
)} style={{ zIndex: Z_INDEX_DRAG_BADGE }}
>
{boxSelectedPageIds.length}
</div>
)}
{dragPreview ? ( {dragPreview ? (
<img <img
src={dragPreview.src} src={dragPreview.src}
@@ -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 { ActionIcon, CheckboxIndicator } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import MoreVertIcon from "@mui/icons-material/MoreVert"; 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 PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined"; import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator"; 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 styles from "@app/components/pageEditor/PageEditor.module.css";
import { useFileContext } from "@app/contexts/FileContext"; import { useFileContext } from "@app/contexts/FileContext";
@@ -35,7 +44,11 @@ interface FileThumbnailProps {
onDeleteFile: (fileId: FileId) => void; onDeleteFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void; onViewFile: (fileId: FileId) => void;
onSetStatus: (status: string) => 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; onDownloadFile?: (fileId: FileId) => void;
toolMode?: boolean; toolMode?: boolean;
isSupported?: boolean; isSupported?: boolean;
@@ -61,7 +74,9 @@ const FileThumbnail = ({
// ---- Drag state ---- // ---- Drag state ----
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null); 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); const [showActions, setShowActions] = useState(false);
// Resolve the actual File object for pin/unpin operations // 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 // Fallback: attempt to download using the File object if provided
const maybeFile = (file as unknown as { file?: File }).file; const maybeFile = (file as unknown as { file?: File }).file;
if (maybeFile instanceof 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; return;
} }
@@ -146,7 +164,8 @@ const FileThumbnail = ({
// Update dropdown width on resize // Update dropdown width on resize
useEffect(() => { useEffect(() => {
const update = () => { const update = () => {
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth); if (dragElementRef.current)
setActionsWidth(dragElementRef.current.offsetWidth);
}; };
update(); update();
window.addEventListener("resize", update); window.addEventListener("resize", update);
@@ -177,7 +196,9 @@ const FileThumbnail = ({
}; };
document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("touchstart", handleTouchStart, { passive: true }); document.addEventListener("touchstart", handleTouchStart, {
passive: true,
});
return () => { return () => {
document.removeEventListener("mousemove", handleMouseMove); document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("touchstart", handleTouchStart); document.removeEventListener("touchstart", handleTouchStart);
@@ -208,7 +229,9 @@ const FileThumbnail = ({
onClick={handleCardClick} onClick={handleCardClick}
> >
{/* Header bar */} {/* Header bar */}
<div className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}> <div
className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}
>
{/* Logo/checkbox area */} {/* Logo/checkbox area */}
<div className={styles.logoMark}> <div className={styles.logoMark}>
{isSupported ? ( {isSupported ? (
@@ -225,7 +248,10 @@ const FileThumbnail = ({
</div> </div>
{/* Centered index */} {/* Centered index */}
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}> <div
className={styles.headerIndex}
aria-label={`Position ${index + 1}`}
>
{index + 1} {index + 1}
</div> </div>
@@ -245,7 +271,11 @@ const FileThumbnail = ({
{/* Actions overlay */} {/* Actions overlay */}
{showActions && ( {showActions && (
<div className={styles.actionsOverlay} style={{ width: actionsWidth }} onClick={(e) => e.stopPropagation()}> <div
className={styles.actionsOverlay}
style={{ width: actionsWidth }}
onClick={(e) => e.stopPropagation()}
>
<button <button
className={styles.actionRow} className={styles.actionRow}
onClick={() => { onClick={() => {
@@ -261,7 +291,11 @@ const FileThumbnail = ({
setShowActions(false); 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> <span>{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}</span>
</button> </button>
@@ -1,7 +1,10 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react"; import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "@app/contexts/FileContext"; 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 { usePageEditor } from "@app/contexts/PageEditorContext";
import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor"; import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
// Thumbnail generation is now handled by individual PageThumbnail components // Thumbnail generation is now handled by individual PageThumbnail components
@@ -35,8 +38,11 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const { actions } = useFileActions(); const { actions } = useFileActions();
// Navigation guard for unsaved changes // Navigation guard for unsaved changes
const { setHasUnsavedChanges, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } = const {
useNavigationGuard(); setHasUnsavedChanges,
registerNavigationWarningHandlers,
unregisterNavigationWarningHandlers,
} = useNavigationGuard();
const navigationState = useNavigationState(); const navigationState = useNavigationState();
// Get PageEditor coordination functions // Get PageEditor coordination functions
@@ -56,7 +62,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const handleVisibleItemsChange = useCallback((items: PDFPage[]) => { const handleVisibleItemsChange = useCallback((items: PDFPage[]) => {
setVisiblePageIds((prev) => { setVisiblePageIds((prev) => {
const ids = items.map((item) => item.id); 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 prev;
} }
return ids; return ids;
@@ -97,7 +106,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const filesSignature = selectors.getFilesSignature(); const filesSignature = selectors.getFilesSignature();
const fileObjectsRef = useRef(new Map<FileId, any>()); 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 pageEditorFiles = useMemo(() => {
const cache = fileObjectsRef.current; const cache = fileObjectsRef.current;
@@ -163,14 +174,15 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const initialDocument = useInitialPageDocument(); const initialDocument = useInitialPageDocument();
const { document: mergedPdfDocument } = usePageDocument(); const { document: mergedPdfDocument } = usePageDocument();
const { setEditedDocument, displayDocument, getEditedDocument } = useEditedDocumentState({ const { setEditedDocument, displayDocument, getEditedDocument } =
initialDocument, useEditedDocumentState({
mergedPdfDocument, initialDocument,
reorderedPages, mergedPdfDocument,
clearReorderedPages, reorderedPages,
fileOrder, clearReorderedPages,
updateCurrentPages, fileOrder,
}); updateCurrentPages,
});
const displayDocumentRef = useRef(displayDocument); const displayDocumentRef = useRef(displayDocument);
useEffect(() => { useEffect(() => {
@@ -184,7 +196,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const loadedCount = doc.pages.filter((p) => p.thumbnail).length; const loadedCount = doc.pages.filter((p) => p.thumbnail).length;
const pending = thumbnailRequestsRef.current.size; 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); const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
if (available === 0) return; if (available === 0) return;
@@ -214,7 +233,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
if (pageIndex === -1) return prev; if (pageIndex === -1) return prev;
const updated = [...prev.pages]; const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache }; updated[pageIndex] = {
...prev.pages[pageIndex],
thumbnail: cache,
};
return { ...prev, pages: updated }; return { ...prev, pages: updated };
}); });
}) })
@@ -230,7 +252,11 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
if (!file) return; if (!file) return;
thumbnailRequestsRef.current.add(pageId); thumbnailRequestsRef.current.add(pageId);
requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber) requestThumbnail(
pageId,
file,
page.originalPageNumber || page.pageNumber,
)
.then((thumbnail) => { .then((thumbnail) => {
if (thumbnail) { if (thumbnail) {
setEditedDocument((prev) => { setEditedDocument((prev) => {
@@ -277,11 +303,17 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
} }
const INITIAL_VISIBLE_PAGE_COUNT = 8; 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); queueThumbnailRequestsForPages(initialIds);
lastInitialDocumentSignatureRef.current = signature; lastInitialDocumentSignatureRef.current = signature;
}, [displayDocumentId, displayDocumentLength, queueThumbnailRequestsForPages]); }, [
displayDocumentId,
displayDocumentLength,
queueThumbnailRequestsForPages,
]);
useEffect(() => { useEffect(() => {
setVisiblePageIds([]); setVisiblePageIds([]);
@@ -340,7 +372,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
// Grid container ref for positioning split indicators // Grid container ref for positioning split indicators
const gridContainerRef = useRef<HTMLDivElement>(null); const gridContainerRef = useRef<HTMLDivElement>(null);
const { canUndo, canRedo, executeCommandWithTracking, handleUndo, handleRedo, clearUndoHistory } = useUndoManagerState({ const {
canUndo,
canRedo,
executeCommandWithTracking,
handleUndo,
handleRedo,
clearUndoHistory,
} = useUndoManagerState({
setHasUnsavedChanges, setHasUnsavedChanges,
}); });
@@ -402,7 +441,12 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
}, },
}); });
return () => unregisterNavigationWarningHandlers(); return () => unregisterNavigationWarningHandlers();
}, [applyChanges, onExportAll, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers]); }, [
applyChanges,
onExportAll,
registerNavigationWarningHandlers,
unregisterNavigationWarningHandlers,
]);
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument) // Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
const selectedPageCount = selectedPageIds.length; const selectedPageCount = selectedPageIds.length;
@@ -556,7 +600,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
zoomLevelParam?: number, zoomLevelParam?: number,
) => { ) => {
gridItemRefsRef.current = refs; 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); const isBoxSelected = boxSelectedIds.includes(page.id);
return ( return (
<PageThumbnail <PageThumbnail
@@ -631,19 +677,21 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
> >
<LoadingOverlay visible={globalProcessing && !initialDocument} /> <LoadingOverlay visible={globalProcessing && !initialDocument} />
{!initialDocument && !globalProcessing && selectedFileIds.length === 0 && ( {!initialDocument &&
<Center h="100%"> !globalProcessing &&
<Stack align="center" gap="md"> selectedFileIds.length === 0 && (
<Text size="lg" c="dimmed"> <Center h="100%">
📄 <Stack align="center" gap="md">
</Text> <Text size="lg" c="dimmed">
<Text c="dimmed">No PDF files loaded</Text> 📄
<Text size="sm" c="dimmed"> </Text>
Add files to start editing pages <Text c="dimmed">No PDF files loaded</Text>
</Text> <Text size="sm" c="dimmed">
</Stack> Add files to start editing pages
</Center> </Text>
)} </Stack>
</Center>
)}
{!initialDocument && globalProcessing && ( {!initialDocument && globalProcessing && (
<Box p={0}> <Box p={0}>
@@ -653,7 +701,13 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
)} )}
{displayDocument && ( {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 */} {/* Split Lines Overlay */}
<div <div
style={{ style={{
@@ -674,7 +728,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
} }
const containerRect = containerEl.getBoundingClientRect(); const containerRect = containerEl.getBoundingClientRect();
const splitIndexes = convertSplitPageIdsToIndexes(displayDocument, splitPositions); const splitIndexes = convertSplitPageIdsToIndexes(
displayDocument,
splitPositions,
);
return Array.from(splitIndexes).map((position) => { return Array.from(splitIndexes).map((position) => {
const currentPage = displayedPages[position]; const currentPage = displayedPages[position];
@@ -695,7 +752,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const nextEl = refsMap.get(nextPage.id); const nextEl = refsMap.get(nextPage.id);
if (nextEl) { if (nextEl) {
const nextRect = nextEl.getBoundingClientRect(); 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) { if (sameRow) {
lineLeft = (currentRect.right + nextRect.left) / 2; lineLeft = (currentRect.right + nextRect.left) / 2;
} else { } else {
@@ -60,22 +60,31 @@ const PageEditorControls = ({
const totalPages = displayDocument.pages.length; const totalPages = displayDocument.pages.length;
const selectedValidPageIds = displayDocument.pages 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); .map((page) => page.id);
if (selectedValidPageIds.length === 0) { if (selectedValidPageIds.length === 0) {
return "Split Selected"; 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 noSplitsCount = selectedValidPageIds.length - existingSplitsCount;
const willRemoveSplits = existingSplitsCount > noSplitsCount; const willRemoveSplits = existingSplitsCount > noSplitsCount;
if (willRemoveSplits) { 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 { } 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} onClick={onUndo}
disabled={!canUndo} disabled={!canUndo}
variant="subtle" 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" radius="md"
size="lg" size="lg"
> >
@@ -140,7 +153,11 @@ const PageEditorControls = ({
onClick={onRedo} onClick={onRedo}
disabled={!canRedo} disabled={!canRedo}
variant="subtle" 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" radius="md"
size="lg" size="lg"
> >
@@ -148,7 +165,14 @@ const PageEditorControls = ({
</ActionIcon> </ActionIcon>
</Tooltip> </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 */} {/* Page Operations */}
<Tooltip label="Rotate Selected Left"> <Tooltip label="Rotate Selected Left">
@@ -156,7 +180,12 @@ const PageEditorControls = ({
onClick={() => onRotate("left")} onClick={() => onRotate("left")}
disabled={selectedPageIds.length === 0} disabled={selectedPageIds.length === 0}
variant="subtle" 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" radius="md"
size="lg" size="lg"
> >
@@ -168,7 +197,12 @@ const PageEditorControls = ({
onClick={() => onRotate("right")} onClick={() => onRotate("right")}
disabled={selectedPageIds.length === 0} disabled={selectedPageIds.length === 0}
variant="subtle" 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" radius="md"
size="lg" size="lg"
> >
@@ -180,7 +214,12 @@ const PageEditorControls = ({
onClick={onDelete} onClick={onDelete}
disabled={selectedPageIds.length === 0} disabled={selectedPageIds.length === 0}
variant="subtle" 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" radius="md"
size="lg" size="lg"
> >
@@ -192,7 +231,12 @@ const PageEditorControls = ({
onClick={onSplit} onClick={onSplit}
disabled={selectedPageIds.length === 0} disabled={selectedPageIds.length === 0}
variant="subtle" 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" radius="md"
size="lg" size="lg"
> >
@@ -204,7 +248,12 @@ const PageEditorControls = ({
onClick={onPageBreak} onClick={onPageBreak}
disabled={selectedPageIds.length === 0} disabled={selectedPageIds.length === 0}
variant="subtle" 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" radius="md"
size="lg" size="lg"
> >
@@ -25,7 +25,13 @@ export default function PageSelectByNumberButton({
updatePagesFromCSV, updatePagesFromCSV,
}: PageSelectByNumberButtonProps) { }: PageSelectByNumberButtonProps) {
return ( 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`}> <div className={`right-rail-fade enter`}>
<Popover position="left" withArrow shadow="md" offset={8}> <Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target> <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 { Text, Checkbox } from "@mantine/core";
import { useIsMobile } from "@app/hooks/useIsMobile"; import { useIsMobile } from "@app/hooks/useIsMobile";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; 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 { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors"; import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors";
import styles from "@app/components/pageEditor/PageEditor.module.css"; 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 { StirlingFileStub } from "@app/types/fileContext";
import { PrivateContent } from "@app/components/shared/PrivateContent"; import { PrivateContent } from "@app/components/shared/PrivateContent";
@@ -31,20 +39,34 @@ interface PageThumbnailProps {
justMoved?: boolean; justMoved?: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>; pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
dragHandleProps?: any; dragHandleProps?: any;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; onReorderPages: (
sourcePageNumber: number,
targetIndex: number,
selectedPageIds?: string[],
) => void;
onTogglePage: (pageId: string) => void; onTogglePage: (pageId: string) => void;
onAnimateReorder: () => void; onAnimateReorder: () => void;
onExecuteCommand: (command: { execute: () => void }) => void; onExecuteCommand: (command: { execute: () => void }) => void;
onSetStatus: (status: string) => void; onSetStatus: (status: string) => void;
onSetMovingPage: (page: number | null) => void; onSetMovingPage: (page: number | null) => void;
onDeletePage: (pageNumber: number) => void; onDeletePage: (pageNumber: number) => void;
createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void }; createRotateCommand: (
pageIds: string[],
rotation: number,
) => { execute: () => void };
createDeleteCommand: (pageIds: string[]) => { execute: () => void }; createDeleteCommand: (pageIds: string[]) => { execute: () => void };
createSplitCommand: (pageId: string, pageNumber: number) => { execute: () => void }; createSplitCommand: (
pageId: string,
pageNumber: number,
) => { execute: () => void };
pdfDocument: PDFDocument; pdfDocument: PDFDocument;
setPdfDocument: (doc: PDFDocument) => void; setPdfDocument: (doc: PDFDocument) => void;
splitPositions: Set<string>; splitPositions: Set<string>;
onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void; onInsertFiles?: (
files: File[] | StirlingFileStub[],
insertAfterPage: number,
isFromStorage?: boolean,
) => void;
zoomLevel?: number; zoomLevel?: number;
} }
@@ -77,15 +99,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
justMoved = false, justMoved = false,
}: PageThumbnailProps) => { }: PageThumbnailProps) => {
const pageIndex = page.pageNumber - 1; 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 [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 [isHovered, setIsHovered] = useState(false);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const lastClickTimeRef = useRef<number>(0); 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 elementRef = useRef<HTMLDivElement | null>(null);
const { openFilesModal } = useFilesModalContext(); const { openFilesModal } = useFilesModalContext();
@@ -95,7 +124,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
// Calculate document aspect ratio from first non-blank page // Calculate document aspect ratio from first non-blank page
const getDocumentAspectRatio = useCallback(() => { const getDocumentAspectRatio = useCallback(() => {
// Find first non-blank page with a thumbnail to get aspect ratio // 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) { if (firstRealPage?.thumbnail) {
// Try to get aspect ratio from an actual thumbnail image // Try to get aspect ratio from an actual thumbnail image
// For now, default to A4 but could be enhanced to measure image dimensions // For now, default to A4 but could be enhanced to measure image dimensions
@@ -139,7 +170,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
onExecuteCommand(command); onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`); onSetStatus(`Rotated page ${page.pageNumber} left`);
}, },
[page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand], [
page.id,
page.pageNumber,
onExecuteCommand,
onSetStatus,
createRotateCommand,
],
); );
const handleRotateRight = useCallback( const handleRotateRight = useCallback(
@@ -150,7 +187,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
onExecuteCommand(command); onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`); onSetStatus(`Rotated page ${page.pageNumber} right`);
}, },
[page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand], [
page.id,
page.pageNumber,
onExecuteCommand,
onSetStatus,
createRotateCommand,
],
); );
const handleDelete = useCallback( const handleDelete = useCallback(
@@ -174,7 +217,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const action = hasSplit ? "removed" : "added"; const action = hasSplit ? "removed" : "added";
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`); onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
}, },
[pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand], [
pageIndex,
splitPositions,
onExecuteCommand,
onSetStatus,
createSplitCommand,
],
); );
const handleInsertFileAfter = useCallback( const handleInsertFileAfter = useCallback(
@@ -185,7 +234,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
// Open file manager modal with custom handler for page insertion // Open file manager modal with custom handler for page insertion
openFilesModal({ openFilesModal({
insertAfterPage: page.pageNumber, insertAfterPage: page.pageNumber,
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => { customHandler: (
files: File[] | StirlingFileStub[],
insertAfterPage?: number,
isFromStorage?: boolean,
) => {
if (insertAfterPage !== undefined) { if (insertAfterPage !== undefined) {
onInsertFiles(files, insertAfterPage, isFromStorage); onInsertFiles(files, insertAfterPage, isFromStorage);
} }
@@ -242,7 +295,15 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
setIsMouseDown(false); setIsMouseDown(false);
setMouseStartPos(null); setMouseStartPos(null);
}, },
[isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage], [
isMouseDown,
mouseStartPos,
isDragging,
page.id,
isBoxSelected,
clearBoxSelection,
onTogglePage,
],
); );
const handleMouseLeave = useCallback(() => { const handleMouseLeave = useCallback(() => {
@@ -251,7 +312,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
setIsHovered(false); 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 // Spread dragHandleProps but use our merged ref
const { ref: _, ...restDragProps } = dragHandleProps || {}; const { ref: _, ...restDragProps } = dragHandleProps || {};
@@ -420,7 +483,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
height: "100%", height: "100%",
backgroundColor: "var(--mantine-color-gray-1)", backgroundColor: "var(--mantine-color-gray-1)",
borderRadius: 6, 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, padding: 4,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
@@ -98,7 +98,10 @@ const AdvancedSelectionPanel = ({
<Flex direction="row" mb="xs" wrap="wrap"> <Flex direction="row" mb="xs" wrap="wrap">
<SelectPages <SelectPages
title={t("bulkSelection.firstNPages.title", "First N Pages")} 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} onApply={handleFirstNApply}
maxPages={maxPages} maxPages={maxPages}
validationFn={validatePositiveNumber} validationFn={validatePositiveNumber}
@@ -113,12 +116,18 @@ const AdvancedSelectionPanel = ({
isRange={true} isRange={true}
rangeEndValue={rangeEnd} rangeEndValue={rangeEnd}
onRangeEndChange={handleRangeEndChange} onRangeEndChange={handleRangeEndChange}
rangeEndPlaceholder={t("bulkSelection.range.toPlaceholder", "To")} rangeEndPlaceholder={t(
"bulkSelection.range.toPlaceholder",
"To",
)}
/> />
<SelectPages <SelectPages
title={t("bulkSelection.lastNPages.title", "Last N Pages")} 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} onApply={handleLastNApply}
maxPages={maxPages} maxPages={maxPages}
validationFn={validatePositiveNumber} validationFn={validatePositiveNumber}
@@ -126,14 +135,20 @@ const AdvancedSelectionPanel = ({
<SelectPages <SelectPages
title={t("bulkSelection.everyNthPage.title", "Every Nth Page")} title={t("bulkSelection.everyNthPage.title", "Every Nth Page")}
placeholder={t("bulkSelection.everyNthPage.placeholder", "Step size")} placeholder={t(
"bulkSelection.everyNthPage.placeholder",
"Step size",
)}
onApply={handleEveryNthApply} onApply={handleEveryNthApply}
maxPages={maxPages} maxPages={maxPages}
/> />
</Flex> </Flex>
{/* Operators row at bottom */} {/* Operators row at bottom */}
<OperatorsSection csvInput={csvInput} onInsertOperator={insertOperator} /> <OperatorsSection
csvInput={csvInput}
onInsertOperator={insertOperator}
/>
</div> </div>
</div> </div>
)} )}
@@ -8,7 +8,10 @@ interface OperatorsSectionProps {
onInsertOperator: (op: LogicalOperator) => void; onInsertOperator: (op: LogicalOperator) => void;
} }
const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => { const OperatorsSection = ({
csvInput,
onInsertOperator,
}: OperatorsSectionProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -93,7 +93,12 @@ const SelectPages = ({
error={Boolean(error)} error={Boolean(error)}
/> />
)} )}
<Button size="sm" className={classes.applyButton} onClick={handleApply} disabled={isDisabled}> <Button
size="sm"
className={classes.applyButton}
onClick={handleApply}
disabled={isDisabled}
>
Apply Apply
</Button> </Button>
</Group> </Group>
@@ -7,7 +7,11 @@ interface SelectedPagesDisplayProps {
syntaxError: string | null; syntaxError: string | null;
} }
const SelectedPagesDisplay = ({ selectedPageIds, displayDocument, syntaxError }: SelectedPagesDisplayProps) => { const SelectedPagesDisplay = ({
selectedPageIds,
displayDocument,
syntaxError,
}: SelectedPagesDisplayProps) => {
if (selectedPageIds.length === 0 && !syntaxError) { if (selectedPageIds.length === 0 && !syntaxError) {
return null; return null;
} }
@@ -35,11 +35,15 @@ export class RotatePageCommand extends DOMCommand {
this.setDocument({ this.setDocument({
...currentDoc, ...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 // 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) { if (pageElement) {
const img = pageElement.querySelector("img"); const img = pageElement.querySelector("img");
if (img) img.style.transform = `rotate(${newRotation}deg)`; if (img) img.style.transform = `rotate(${newRotation}deg)`;
@@ -54,10 +58,14 @@ export class RotatePageCommand extends DOMCommand {
this.setDocument({ this.setDocument({
...currentDoc, ...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) { if (pageElement) {
const img = pageElement.querySelector("img"); const img = pageElement.querySelector("img");
if (img) img.style.transform = `rotate(${this.originalRotation}deg)`; if (img) img.style.transform = `rotate(${this.originalRotation}deg)`;
@@ -118,12 +126,17 @@ export class DeletePagesCommand extends DOMCommand {
const selectedPageNumbersBefore = this.getSelectedPages(); const selectedPageNumbersBefore = this.getSelectedPages();
const selectedIdSet = new Set( const selectedIdSet = new Set(
selectedPageNumbersBefore 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((id): id is string => Boolean(id)),
); );
// Filter out deleted pages by ID (stable across undo/redo) // 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 (remainingPages.length === 0) {
// If all pages would be deleted, clear selection/splits and close PDF // If all pages would be deleted, clear selection/splits and close PDF
@@ -162,7 +175,9 @@ export class DeletePagesCommand extends DOMCommand {
// Apply changes // Apply changes
this.setDocument(updatedDocument); 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.setSelectedPageIds(remainingSelectedPageIds);
this.setSplitPositions(newPositions); this.setSplitPositions(newPositions);
@@ -175,7 +190,12 @@ export class DeletePagesCommand extends DOMCommand {
this.setDocument(this.originalDocument); this.setDocument(this.originalDocument);
this.setSplitPositions(this.originalSplitPositions); this.setSplitPositions(this.originalSplitPositions);
const restoredIds = this.originalSelectedPages 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 !== ""); .filter((id) => id !== "");
this.setSelectedPageIds(restoredIds); this.setSelectedPageIds(restoredIds);
} }
@@ -207,18 +227,28 @@ export class ReorderPagesCommand extends DOMCommand {
this.originalPages = currentDoc.pages.map((page) => ({ ...page })); this.originalPages = currentDoc.pages.map((page) => ({ ...page }));
// Perform the reorder // 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; if (sourceIndex === -1) return;
const newPages = [...currentDoc.pages]; 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 // Multi-page reorder
const selectedPageObjects = this.selectedPages 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[]; .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.splice(this.targetIndex, 0, ...selectedPageObjects);
remainingPages.forEach((page, index) => { remainingPages.forEach((page, index) => {
@@ -231,7 +261,10 @@ export class ReorderPagesCommand extends DOMCommand {
const [movedPage] = newPages.splice(sourceIndex, 1); const [movedPage] = newPages.splice(sourceIndex, 1);
// Adjust target index if moving forward (after removal, indices shift) // 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); newPages.splice(adjustedTargetIndex, 0, movedPage);
@@ -346,7 +379,8 @@ export class BulkRotateCommand extends DOMCommand {
const newRotationById = new Map<string, number>(); const newRotationById = new Map<string, number>();
const updatedPages = currentDoc.pages.map((page) => { const updatedPages = currentDoc.pages.map((page) => {
if (pageIdSet.has(page.id)) { 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); newRotationById.set(page.id, newRotation);
return { ...page, rotation: newRotation }; return { ...page, rotation: newRotation };
} }
@@ -360,7 +394,8 @@ export class BulkRotateCommand extends DOMCommand {
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`); const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
if (pageElement) { if (pageElement) {
const img = pageElement.querySelector("img"); 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 // Restore original rotations in state
const updatedPages = currentDoc.pages.map((page) => 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 }); this.setDocument({ ...currentDoc, pages: updatedPages });
@@ -455,7 +492,9 @@ export class SplitAllCommand extends DOMCommand {
// Check if all splits are already active // Check if all splits are already active
const currentSplits = this.getSplitPositions(); 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) { if (hasAllSplits) {
// Remove all splits // Remove all splits
@@ -473,7 +512,9 @@ export class SplitAllCommand extends DOMCommand {
get description(): string { get description(): string {
const currentSplits = this.getSplitPositions(); 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"; 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 // Find the original page by matching the page ID from the original document
const originalPage = this.originalDocument?.pages[originalPageNum - 1]; const originalPage = this.originalDocument?.pages[originalPageNum - 1];
if (originalPage) { 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) { if (foundPage) {
updatedSelection.push(foundPage.pageNumber); updatedSelection.push(foundPage.pageNumber);
} }
@@ -655,7 +698,10 @@ export class InsertFilesCommand extends DOMCommand {
private setDocument: (doc: PDFDocument) => void, private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void, private setSelectedPages: (pages: number[]) => void,
private getSelectedPages: () => number[], private getSelectedPages: () => number[],
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<FileId, File>) => void, private updateFileContext?: (
updatedDocument: PDFDocument,
insertedFiles?: Map<FileId, File>,
) => void,
) { ) {
super(); super();
} }
@@ -677,7 +723,8 @@ export class InsertFilesCommand extends DOMCommand {
// Process all files and wait for their completion // Process all files and wait for their completion
const baseTimestamp = Date.now(); const baseTimestamp = Date.now();
const extractionPromises = this.files.map(async (file, index) => { 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 // Store inserted file for export
this.insertedFileMap.set(fileId, file); this.insertedFileMap.set(fileId, file);
// Use base timestamp + index to ensure unique but predictable file IDs // 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 // Add pages before insertion point
for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) { 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); newPages.push(page);
} }
@@ -720,7 +770,10 @@ export class InsertFilesCommand extends DOMCommand {
// Add remaining pages after insertion point // Add remaining pages after insertion point
for (let i = insertIndex; i < currentDoc.pages.length; i++) { 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); 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 { try {
const { thumbnailGenerationService } = await import("@app/services/thumbnailGenerationService"); const { thumbnailGenerationService } =
await import("@app/services/thumbnailGenerationService");
// Group pages by file ID to generate thumbnails efficiently // Group pages by file ID to generate thumbnails efficiently
const pagesByFileId = new Map<FileId, PDFPage[]>(); const pagesByFileId = new Map<FileId, PDFPage[]>();
@@ -789,7 +845,10 @@ export class InsertFilesCommand extends DOMCommand {
console.log("Generating thumbnails for file:", fileId); console.log("Generating thumbnails for file:", fileId);
console.log("Pages:", pages.length); console.log("Pages:", pages.length);
console.log("ArrayBuffer size:", arrayBuffer?.byteLength || "undefined"); console.log(
"ArrayBuffer size:",
arrayBuffer?.byteLength || "undefined",
);
try { try {
if (arrayBuffer && arrayBuffer.byteLength > 0) { if (arrayBuffer && arrayBuffer.byteLength > 0) {
@@ -802,12 +861,21 @@ export class InsertFilesCommand extends DOMCommand {
console.log("Generating thumbnails for page numbers:", pageNumbers); console.log("Generating thumbnails for page numbers:", pageNumbers);
// Generate thumbnails for all pages from this file at once // Generate thumbnails for all pages from this file at once
const results = await thumbnailGenerationService.generateThumbnails(fileId, arrayBuffer, pageNumbers, { const results = await thumbnailGenerationService.generateThumbnails(
scale: 0.2, fileId,
quality: 0.8, 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 // Update pages with generated thumbnails
for (let i = 0; i < results.length && i < pages.length; i++) { for (let i = 0; i < results.length && i < pages.length; i++) {
@@ -815,7 +883,9 @@ export class InsertFilesCommand extends DOMCommand {
const page = pages[i]; const page = pages[i];
if (result.success) { 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) { if (pageIndex >= 0) {
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail; updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
console.log("Updated thumbnail for page:", page.id); 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); console.error("No valid ArrayBuffer found for file ID:", fileId);
} }
} catch (error) { } catch (error) {
console.error("Failed to generate thumbnails for file:", fileId, error); console.error(
"Failed to generate thumbnails for file:",
fileId,
error,
);
} finally { } finally {
this.fileDataMap.delete(fileId); 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) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = async (event) => { reader.onload = async (event) => {
try { try {
const arrayBuffer = event.target?.result as ArrayBuffer; 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) { if (!arrayBuffer) {
reject(new Error("Failed to read file")); reject(new Error("Failed to read file"));
@@ -856,7 +936,8 @@ export class InsertFilesCommand extends DOMCommand {
const clonedArrayBuffer = arrayBuffer.slice(0); const clonedArrayBuffer = arrayBuffer.slice(0);
// Use PDF.js via the worker manager to extract pages // 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 pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
const pageCount = pdf.numPages; const pageCount = pdf.numPages;
@@ -864,13 +945,24 @@ export class InsertFilesCommand extends DOMCommand {
const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId; const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId;
console.log("Original ArrayBuffer size:", arrayBuffer.byteLength); 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 // Store the original ArrayBuffer for thumbnail generation
this.fileDataMap.set(fileId, arrayBuffer); this.fileDataMap.set(fileId, arrayBuffer);
console.log("After storing - fileDataMap size:", this.fileDataMap.size); console.log(
console.log("Stored value size:", this.fileDataMap.get(fileId)?.byteLength || "undefined"); "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++) { for (let i = 1; i <= pageCount; i++) {
const pageId = `${fileId}-page-${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 * @param opacity - Opacity value (0-1), defaults to 0.3
* @returns RGBA color string * @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); const rgb = getFileColor(index);
// Convert rgb(r, g, b) to rgba(r, g, b, a) // Convert rgb(r, g, b) to rgba(r, g, b, a)
return rgb.replace("rgb(", "rgba(").replace(")", `, ${opacity})`); return rgb.replace("rgb(", "rgba(").replace(")", `, ${opacity})`);
@@ -20,7 +20,9 @@ export const useEditedDocumentState = ({
fileOrder, fileOrder,
updateCurrentPages, updateCurrentPages,
}: UseEditedDocumentStateParams) => { }: UseEditedDocumentStateParams) => {
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null); const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(
null,
);
const editedDocumentRef = useRef<PDFDocument | null>(null); const editedDocumentRef = useRef<PDFDocument | null>(null);
const pagePositionCacheRef = useRef<Map<string, number>>(new Map()); const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map()); const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
@@ -89,7 +91,8 @@ export const useEditedDocumentState = ({
const currentEditedDocument = editedDocumentRef.current; const currentEditedDocument = editedDocumentRef.current;
if (!mergedPdfDocument || !currentEditedDocument) return; if (!mergedPdfDocument || !currentEditedDocument) return;
const signatureChanged = mergedDocSignature !== lastSyncedSignatureRef.current; const signatureChanged =
mergedDocSignature !== lastSyncedSignatureRef.current;
const metadataChanged = const metadataChanged =
currentEditedDocument.id !== mergedPdfDocument.id || currentEditedDocument.id !== mergedPdfDocument.id ||
currentEditedDocument.file !== mergedPdfDocument.file || currentEditedDocument.file !== mergedPdfDocument.file ||
@@ -117,7 +120,8 @@ export const useEditedDocumentState = ({
} }
const hasAdditions = newPages.length > 0; 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; let hasRemovals = false;
for (const page of prev.pages) { for (const page of prev.pages) {
@@ -142,12 +146,16 @@ export const useEditedDocumentState = ({
const nextInsertIndexByFile = new Map(placeholderPositions); const nextInsertIndexByFile = new Map(placeholderPositions);
if (hasRemovals) { if (hasRemovals) {
pages = pages.filter((page) => sourceIds.has(page.id) || isEphemeralPage(page)); pages = pages.filter(
(page) => sourceIds.has(page.id) || isEphemeralPage(page),
);
} }
if (hasAdditions) { if (hasAdditions) {
const mergedIndexMap = new Map<string, number>(); 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 const additions = newPages
.map((page) => ({ .map((page) => ({
@@ -163,36 +171,45 @@ export const useEditedDocumentState = ({
return a.mergedIndex - b.mergedIndex; return a.mergedIndex - b.mergedIndex;
}); });
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => { additions.forEach(
if (pages.some((existing) => existing.id === page.id)) { ({ page, neighborId, cachedIndex, mergedIndex }) => {
return; if (pages.some((existing) => existing.id === page.id)) {
} return;
}
let insertIndex: number; let insertIndex: number;
const originalFileId = page.originalFileId; const originalFileId = page.originalFileId;
const placeholderIndex = originalFileId !== undefined ? nextInsertIndexByFile.get(originalFileId) : undefined; const placeholderIndex =
originalFileId !== undefined
? nextInsertIndexByFile.get(originalFileId)
: undefined;
if (originalFileId && placeholderIndex !== undefined) { if (originalFileId && placeholderIndex !== undefined) {
insertIndex = Math.min(placeholderIndex, pages.length); insertIndex = Math.min(placeholderIndex, pages.length);
nextInsertIndexByFile.set(originalFileId, insertIndex + 1); nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
} else if (neighborId === null) { } else if (neighborId === null) {
insertIndex = 0; insertIndex = 0;
} else if (neighborId) { } else if (neighborId) {
const neighborIndex = pages.findIndex((p) => p.id === neighborId); const neighborIndex = pages.findIndex(
if (neighborIndex !== -1) { (p) => p.id === neighborId,
insertIndex = neighborIndex + 1; );
if (neighborIndex !== -1) {
insertIndex = neighborIndex + 1;
} else {
const fallbackIndex =
cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
} else { } else {
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length; const fallbackIndex =
cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length); insertIndex = Math.min(fallbackIndex, pages.length);
} }
} else {
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
insertIndex = Math.min(fallbackIndex, pages.length);
}
const clonedPage = { ...page }; const clonedPage = { ...page };
pages.splice(insertIndex, 0, clonedPage); 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( const handleRotatePages = useCallback(
(pageIds: string[], rotation: number) => { (pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation, getEditedDocument, setEditedDocument); const bulkRotateCommand = new BulkRotateCommand(
pageIds,
rotation,
getEditedDocument,
setEditedDocument,
);
executeCommandWithTracking(bulkRotateCommand); executeCommandWithTracking(bulkRotateCommand);
}, },
[executeCommandWithTracking, getEditedDocument, setEditedDocument], [executeCommandWithTracking, getEditedDocument, setEditedDocument],
@@ -72,7 +77,12 @@ export const usePageEditorCommands = ({
const createRotateCommand = useCallback( const createRotateCommand = useCallback(
(pageIds: string[], rotation: number) => ({ (pageIds: string[], rotation: number) => ({
execute: () => { execute: () => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation, getEditedDocument, setEditedDocument); const bulkRotateCommand = new BulkRotateCommand(
pageIds,
rotation,
getEditedDocument,
setEditedDocument,
);
executeCommandWithTracking(bulkRotateCommand); executeCommandWithTracking(bulkRotateCommand);
}, },
}), }),
@@ -123,7 +133,12 @@ export const usePageEditorCommands = ({
const createSplitCommand = useCallback( const createSplitCommand = useCallback(
(pageId: string, pageNumber: number) => ({ (pageId: string, pageNumber: number) => ({
execute: () => { execute: () => {
const splitCommand = new SplitCommand(pageId, pageNumber, () => splitPositionsRef.current, setSplitPositions); const splitCommand = new SplitCommand(
pageId,
pageNumber,
() => splitPositionsRef.current,
setSplitPositions,
);
executeCommandWithTracking(splitCommand); executeCommandWithTracking(splitCommand);
}, },
}), }),
@@ -208,12 +223,18 @@ export const usePageEditorCommands = ({
if (!displayDocument || selectedPageIds.length === 0) return; if (!displayDocument || selectedPageIds.length === 0) return;
const selectedSplitPageIds = displayDocument.pages 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); .map((page) => page.id);
if (selectedSplitPageIds.length === 0) return; 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 noSplitsCount = selectedSplitPageIds.length - existingSplitsCount;
const shouldRemoveSplits = existingSplitsCount > noSplitsCount; const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
@@ -234,7 +255,14 @@ export const usePageEditorCommands = ({
}; };
executeCommandWithTracking(smartSplitCommand); executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]); }, [
selectedPageIds,
displayDocument,
splitPositions,
setSplitPositions,
getPageNumbersFromIds,
executeCommandWithTracking,
]);
const handleSplitAll = handleSplit; const handleSplitAll = handleSplit;
@@ -243,7 +271,11 @@ export const usePageEditorCommands = ({
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const pageBreakCommand = new PageBreakCommand(selectedPageNumbers, getEditedDocument, setEditedDocument); const pageBreakCommand = new PageBreakCommand(
selectedPageNumbers,
getEditedDocument,
setEditedDocument,
);
executeCommandWithTracking(pageBreakCommand); executeCommandWithTracking(pageBreakCommand);
}, [ }, [
displayDocument, displayDocument,
@@ -257,7 +289,11 @@ export const usePageEditorCommands = ({
const handlePageBreakAll = handlePageBreak; const handlePageBreakAll = handlePageBreak;
const handleInsertFiles = useCallback( const handleInsertFiles = useCallback(
async (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => { async (
files: File[] | StirlingFileStub[],
insertAfterPage: number,
isFromStorage?: boolean,
) => {
console.log("[PageEditor] handleInsertFiles called:", { console.log("[PageEditor] handleInsertFiles called:", {
fileCount: files.length, fileCount: files.length,
insertAfterPage, insertAfterPage,
@@ -274,7 +310,9 @@ export const usePageEditorCommands = ({
} }
try { try {
const targetPage = workingDocument.pages.find((p) => p.pageNumber === insertAfterPage); const targetPage = workingDocument.pages.find(
(p) => p.pageNumber === insertAfterPage,
);
if (!targetPage) { if (!targetPage) {
console.log("[PageEditor] Target page not found:", insertAfterPage); console.log("[PageEditor] Target page not found:", insertAfterPage);
return; return;
@@ -317,7 +355,8 @@ export const usePageEditorCommands = ({
id: `${fileId}-${page.pageNumber ?? idx + 1}`, id: `${fileId}-${page.pageNumber ?? idx + 1}`,
pageNumber: page.pageNumber ?? idx + 1, pageNumber: page.pageNumber ?? idx + 1,
originalFileId: fileId, originalFileId: fileId,
originalPageNumber: page.originalPageNumber ?? page.pageNumber ?? idx + 1, originalPageNumber:
page.originalPageNumber ?? page.pageNumber ?? idx + 1,
rotation: page.rotation ?? 0, rotation: page.rotation ?? 0,
thumbnail: page.thumbnail ?? null, thumbnail: page.thumbnail ?? null,
selected: false, selected: false,
@@ -328,7 +367,9 @@ export const usePageEditorCommands = ({
} }
if (newPages.length > 0) { 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) { if (targetIndex >= 0) {
const updatedPages = [...workingDocument.pages]; const updatedPages = [...workingDocument.pages];
@@ -350,14 +391,26 @@ export const usePageEditorCommands = ({
console.error("Failed to insert files:", error); console.error("Failed to insert files:", error);
} }
}, },
[getEditedDocument, actions, selectors, updateFileOrderFromPages, setEditedDocument], [
getEditedDocument,
actions,
selectors,
updateFileOrderFromPages,
setEditedDocument,
],
); );
const handleReorderPages = useCallback( const handleReorderPages = useCallback(
(sourcePageNumber: number, targetIndex: number, draggedPageIds?: string[]) => { (
sourcePageNumber: number,
targetIndex: number,
draggedPageIds?: string[],
) => {
if (!displayDocument) return; if (!displayDocument) return;
const selectedPages = draggedPageIds ? getPageNumbersFromIds(draggedPageIds) : undefined; const selectedPages = draggedPageIds
? getPageNumbersFromIds(draggedPageIds)
: undefined;
const reorderCommand = new ReorderPagesCommand( const reorderCommand = new ReorderPagesCommand(
sourcePageNumber, 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 { export function useInitialPageDocument(): PDFDocument | null {
const { document: liveDocument } = usePageDocument(); 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 lastDocumentIdRef = useRef<string | null>(null);
const liveDocumentId = liveDocument?.id ?? null; const liveDocumentId = liveDocument?.id ?? null;
@@ -30,7 +32,11 @@ export function useInitialPageDocument(): PDFDocument | null {
return; 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); setInitialDocument(liveDocument);
}, [liveDocument, initialDocument]); }, [liveDocument, initialDocument]);
@@ -17,7 +17,12 @@ export interface PageDocumentHook {
*/ */
export function usePageDocument(): PageDocumentHook { export function usePageDocument(): PageDocumentHook {
const { state, selectors } = useFileState(); 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 // Use PageEditorContext's fileOrder instead of FileContext's global order
// This ensures the page editor respects its own workspace ordering // This ensures the page editor respects its own workspace ordering
@@ -55,9 +60,12 @@ export function usePageDocument(): PageDocumentHook {
const globalProcessing = state.ui.isProcessing; const globalProcessing = state.ui.isProcessing;
// Get primary file record outside useMemo to track processedFile changes // 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 processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages; const processedFileTotalPages =
primaryStirlingFileStub?.processedFile?.totalPages;
const placeholderDocumentRef = useRef<PDFDocument | null>(null); const placeholderDocumentRef = useRef<PDFDocument | null>(null);
const [placeholderVersion, setPlaceholderVersion] = useState(0); const [placeholderVersion, setPlaceholderVersion] = useState(0);
@@ -89,20 +97,24 @@ export function usePageDocument(): PageDocumentHook {
const analysis = await FileAnalyzer.quickPDFAnalysis(file); const analysis = await FileAnalyzer.quickPDFAnalysis(file);
if (canceled) return; if (canceled) return;
const totalPages = Math.max(1, analysis.pageCount || 1); const totalPages = Math.max(1, analysis.pageCount || 1);
const pages: PDFPage[] = Array.from({ length: totalPages }, (_, index) => ({ const pages: PDFPage[] = Array.from(
id: `placeholder-${primaryFileId}-page-${index + 1}`, { length: totalPages },
pageNumber: index + 1, (_, index) => ({
thumbnail: null, id: `placeholder-${primaryFileId}-page-${index + 1}`,
rotation: 0, pageNumber: index + 1,
selected: false, thumbnail: null,
originalFileId: primaryFileId, rotation: 0,
originalPageNumber: index + 1, selected: false,
})); originalFileId: primaryFileId,
originalPageNumber: index + 1,
}),
);
if (!canceled) { if (!canceled) {
placeholderDocumentRef.current = { placeholderDocumentRef.current = {
id: `placeholder-${primaryFileId}`, id: `placeholder-${primaryFileId}`,
name: selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name, name:
selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name,
file, file,
pages, pages,
totalPages, totalPages,
@@ -147,7 +159,13 @@ export function usePageDocument(): PageDocumentHook {
// Check if persisted document is still valid // Check if persisted document is still valid
// Must match signature AND have the same number of source files // Must match signature AND have the same number of source files
const persistedFileIds = persistedDocument 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 = const persistedIsValid =
persistedDocument && persistedDocument &&
@@ -160,14 +178,20 @@ export function usePageDocument(): PageDocumentHook {
console.log("[usePageDocument] Using persisted document"); console.log("[usePageDocument] Using persisted document");
return persistedDocument; return persistedDocument;
} else if (persistedDocument) { } else if (persistedDocument) {
console.log("[usePageDocument] Persisted document invalid - rebuilding:", { console.log(
sigMatch: persistedDocumentSignature === currentPagesSignature, "[usePageDocument] Persisted document invalid - rebuilding:",
persistedFiles: persistedFileIds.length, {
activeFiles: activeFileIds.length, sigMatch: persistedDocumentSignature === currentPagesSignature,
}); persistedFiles: persistedFileIds.length,
activeFiles: activeFileIds.length,
},
);
} }
if (!primaryStirlingFileStub?.processedFile && placeholderDocumentRef.current) { if (
!primaryStirlingFileStub?.processedFile &&
placeholderDocumentRef.current
) {
return 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 we have file IDs but no file record, something is wrong - return null to show loading
if (!primaryStirlingFileStub) { if (!primaryStirlingFileStub) {
console.log("🎬 PageEditor: No primary file record found, showing loading"); console.log(
"🎬 PageEditor: No primary file record found, showing loading",
);
return null; return null;
} }
const namingFileIds = selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds; const namingFileIds =
selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds;
const name = const name =
namingFileIds.length <= 1 namingFileIds.length <= 1
? namingFileIds[0] ? namingFileIds[0]
? (selectors.getStirlingFileStub(namingFileIds[0])?.name ?? "document.pdf") ? (selectors.getStirlingFileStub(namingFileIds[0])?.name ??
"document.pdf")
: "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 // Build page insertion map from files with insertion positions
const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds
@@ -218,7 +253,11 @@ export function usePageDocument(): PageDocumentHook {
let pages: PDFPage[]; let pages: PDFPage[];
// Helper function to create pages from a file (or placeholder if deselected) // 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); const stirlingFileStub = selectors.getStirlingFileStub(fileId);
if (!stirlingFileStub) { if (!stirlingFileStub) {
return []; return [];
@@ -262,17 +301,20 @@ export function usePageDocument(): PageDocumentHook {
if (processedFile?.totalPages) { if (processedFile?.totalPages) {
// Fallback: create pages without thumbnails but with correct count // Fallback: create pages without thumbnails but with correct count
return Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({ return Array.from(
id: `${fileId}-${pageIndex + 1}`, { length: processedFile.totalPages },
pageNumber: startPageNumber + pageIndex, (_, pageIndex) => ({
originalPageNumber: pageIndex + 1, id: `${fileId}-${pageIndex + 1}`,
originalFileId: fileId, pageNumber: startPageNumber + pageIndex,
rotation: 0, originalPageNumber: pageIndex + 1,
thumbnail: null, originalFileId: fileId,
selected: false, rotation: 0,
splitAfter: false, thumbnail: null,
isPlaceholder: false, selected: false,
})); splitAfter: false,
isPlaceholder: false,
}),
);
} }
// No processedFile yet - create a single loading placeholder // 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 // Process each insertion by finding the page ID and inserting after it
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) { 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; if (targetPageIndex === -1) continue;
@@ -352,14 +396,22 @@ export function usePageDocument(): PageDocumentHook {
pageNumber: index + 1, pageNumber: index + 1,
})); }));
const currentPagesSet = currentPages ? new Set(currentPages.map((page) => page.id)) : null; const currentPagesSet = currentPages
if (currentPagesSet && currentPages && currentPagesSet.size === pages.length) { ? new Set(currentPages.map((page) => page.id))
: null;
if (
currentPagesSet &&
currentPages &&
currentPagesSet.size === pages.length
) {
const sameIds = pages.every((page) => currentPagesSet.has(page.id)); const sameIds = pages.every((page) => currentPagesSet.has(page.id));
if (sameIds) { if (sameIds) {
const mergedById = new Map(pages.map((page) => [page.id, page])); const mergedById = new Map(pages.map((page) => [page.id, page]));
pages = currentPages.map((currentPage, index) => { pages = currentPages.map((currentPage, index) => {
const source = mergedById.get(currentPage.id); const source = mergedById.get(currentPage.id);
const mergedPage = source ? { ...source, ...currentPage } : currentPage; const mergedPage = source
? { ...source, ...currentPage }
: currentPage;
return { return {
...mergedPage, ...mergedPage,
pageNumber: index + 1, pageNumber: index + 1,
@@ -20,7 +20,8 @@ export interface PageEditorDropdownState {
fileColorMap: Map<FileId, number>; 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 { export function usePageEditorDropdownState(): PageEditorDropdownState {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
@@ -42,9 +43,14 @@ export function usePageEditorDropdownState(): PageEditorDropdownState {
.filter((file): file is PageEditorDropdownFile => file !== null); .filter((file): file is PageEditorDropdownFile => file !== null);
}, [fileOrder, selectors, state.ui.selectedFileIds]); }, [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>( return useMemo<PageEditorDropdownState>(
() => ({ () => ({
@@ -55,6 +61,12 @@ export function usePageEditorDropdownState(): PageEditorDropdownState {
onReorder: reorderFiles, onReorder: reorderFiles,
fileColorMap, 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)) { 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 normalized;
} }
return removePlaceholderPages(processed); return removePlaceholderPages(processed);
@@ -104,17 +108,23 @@ export const usePageEditorExport = ({
setExportLoading(true); setExportLoading(true);
try { try {
const processedDocuments = documentManipulationService.applyDOMChangesToDocument( const processedDocuments =
displayDocument, documentManipulationService.applyDOMChangesToDocument(
displayDocument, displayDocument,
splitPositions, displayDocument,
); splitPositions,
);
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments); const normalizedDocuments =
const documentWithDOMState = Array.isArray(normalizedDocuments) ? normalizedDocuments[0] : normalizedDocuments; normalizeProcessedDocuments(processedDocuments);
const documentWithDOMState = Array.isArray(normalizedDocuments)
? normalizedDocuments[0]
: normalizedDocuments;
if (!documentWithDOMState || documentWithDOMState.pages.length === 0) { 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); setExportLoading(false);
return; return;
} }
@@ -126,14 +136,23 @@ export const usePageEditorExport = ({
const sourceFiles = getSourceFiles(); const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename(); const exportFilename = getExportFilename();
const result = sourceFiles const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(documentWithDOMState, sourceFiles, validSelectedPageIds, { ? await pdfExportService.exportPDFMultiFile(
selectedOnly: true, documentWithDOMState,
filename: exportFilename, sourceFiles,
}) validSelectedPageIds,
: await pdfExportService.exportPDF(documentWithDOMState, validSelectedPageIds, { {
selectedOnly: true, selectedOnly: true,
filename: exportFilename, filename: exportFilename,
}); },
)
: await pdfExportService.exportPDF(
documentWithDOMState,
validSelectedPageIds,
{
selectedOnly: true,
filename: exportFilename,
},
);
pdfExportService.downloadFile(result.blob, result.filename); pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
@@ -158,26 +177,36 @@ export const usePageEditorExport = ({
setExportLoading(true); setExportLoading(true);
try { try {
const processedDocuments = documentManipulationService.applyDOMChangesToDocument( const processedDocuments =
displayDocument, documentManipulationService.applyDOMChangesToDocument(
displayDocument, displayDocument,
splitPositions, displayDocument,
); splitPositions,
);
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments); const normalizedDocuments =
normalizeProcessedDocuments(processedDocuments);
if ( if (
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) || (Array.isArray(normalizedDocuments) &&
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0) 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); setExportLoading(false);
return; return;
} }
const sourceFiles = getSourceFiles(); const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename(); const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(normalizedDocuments, sourceFiles, exportFilename); const files = await exportProcessedDocumentsToFiles(
normalizedDocuments,
sourceFiles,
exportFilename,
);
if (files.length > 1) { if (files.length > 1) {
const JSZip = await import("jszip"); const JSZip = await import("jszip");
@@ -203,33 +232,50 @@ export const usePageEditorExport = ({
console.error("Export failed:", error); console.error("Export failed:", error);
setExportLoading(false); setExportLoading(false);
} }
}, [displayDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges, setExportLoading]); }, [
displayDocument,
splitPositions,
getSourceFiles,
getExportFilename,
setHasUnsavedChanges,
setExportLoading,
]);
const applyChanges = useCallback(async () => { const applyChanges = useCallback(async () => {
if (!displayDocument) return; if (!displayDocument) return;
setExportLoading(true); setExportLoading(true);
try { try {
const processedDocuments = documentManipulationService.applyDOMChangesToDocument( const processedDocuments =
displayDocument, documentManipulationService.applyDOMChangesToDocument(
displayDocument, displayDocument,
splitPositions, displayDocument,
); splitPositions,
);
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments); const normalizedDocuments =
normalizeProcessedDocuments(processedDocuments);
if ( if (
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) || (Array.isArray(normalizedDocuments) &&
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0) 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); setExportLoading(false);
return; return;
} }
const sourceFiles = getSourceFiles(); const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename(); const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(normalizedDocuments, sourceFiles, exportFilename); const files = await exportProcessedDocumentsToFiles(
normalizedDocuments,
sourceFiles,
exportFilename,
);
// Add "_multitool" suffix to filenames // Add "_multitool" suffix to filenames
const renamedFiles = files.map((file) => { const renamedFiles = files.map((file) => {
@@ -52,7 +52,9 @@ export function usePageEditorState(): PageEditorState {
// Helper functions // Helper functions
const togglePage = useCallback((pageId: string) => { const togglePage = useCallback((pageId: string) => {
setSelectedPageIds((prev) => { 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; return newSelection;
}); });
}, []); // Empty deps - uses updater function so always has latest state }, []); // Empty deps - uses updater function so always has latest state
@@ -60,7 +62,9 @@ export function usePageEditorState(): PageEditorState {
const toggleSelectAll = useCallback((allPageIds: string[]) => { const toggleSelectAll = useCallback((allPageIds: string[]) => {
if (!allPageIds.length) return; if (!allPageIds.length) return;
setSelectedPageIds((prev) => (prev.length === allPageIds.length ? [] : allPageIds)); setSelectedPageIds((prev) =>
prev.length === allPageIds.length ? [] : allPageIds,
);
}, []); }, []);
const animateReorder = useCallback(() => { const animateReorder = useCallback(() => {
@@ -53,7 +53,11 @@ export const usePageSelectionManager = ({
); );
useEffect(() => { 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); const allPageIds = displayDocument.pages.map((page) => page.id);
setSelectedPageIds(allPageIds); setSelectedPageIds(allPageIds);
setSelectionMode(true); 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; setHasUnsavedChanges: (dirty: boolean) => void;
} }
export const useUndoManagerState = ({ setHasUnsavedChanges }: UseUndoManagerStateParams) => { export const useUndoManagerState = ({
setHasUnsavedChanges,
}: UseUndoManagerStateParams) => {
const undoManagerRef = useRef(new UndoManager()); const undoManagerRef = useRef(new UndoManager());
const [canUndo, setCanUndo] = useState(false); const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false); const [canRedo, setCanRedo] = useState(false);
@@ -1,6 +1,9 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 LocalIcon from "@app/components/shared/LocalIcon";
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton"; import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
@@ -22,7 +25,9 @@ interface PageEditorRightRailButtonsParams {
closePdf: () => void; closePdf: () => void;
} }
export function usePageEditorRightRailButtons(params: PageEditorRightRailButtonsParams) { export function usePageEditorRightRailButtons(
params: PageEditorRightRailButtonsParams,
) {
const { const {
totalPages, totalPages,
selectedPageCount, selectedPageCount,
@@ -46,9 +51,18 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
// Lift i18n labels out of memo for clarity // Lift i18n labels out of memo for clarity
const selectAllLabel = t("rightRail.selectAll", "Select All"); const selectAllLabel = t("rightRail.selectAll", "Select All");
const deselectAllLabel = t("rightRail.deselectAll", "Deselect All"); const deselectAllLabel = t("rightRail.deselectAll", "Deselect All");
const selectByNumberLabel = t("rightRail.selectByNumber", "Select by Page Numbers"); const selectByNumberLabel = t(
const deleteSelectedLabel = t("rightRail.deleteSelected", "Delete Selected Pages"); "rightRail.selectByNumber",
const exportSelectedLabel = t("rightRail.exportSelected", "Export Selected Pages"); "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 saveChangesLabel = t("rightRail.saveChanges", "Save Changes");
const closePdfLabel = t("rightRail.closePdf", "Close PDF"); const closePdfLabel = t("rightRail.closePdf", "Close PDF");
@@ -67,7 +81,13 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
}, },
{ {
id: "page-deselect-all", 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, tooltip: deselectAllLabel,
ariaLabel: deselectAllLabel, ariaLabel: deselectAllLabel,
section: "top" as const, section: "top" as const,
@@ -99,7 +119,13 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
}, },
{ {
id: "page-delete-selected", 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, tooltip: deleteSelectedLabel,
ariaLabel: deleteSelectedLabel, ariaLabel: deleteSelectedLabel,
section: "top" as const, 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. * 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>(); const map = new Map<string, number>();
if (!document) return map; if (!document) return map;
document.pages.forEach((page, index) => { 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 * Convert a set of split page IDs (the page preceding each split) into
* the current index positions inside the document. * 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>(); const indexes = new Set<number>();
if (!document || !splitPageIds || splitPageIds.size === 0) { if (!document || !splitPageIds || splitPageIds.size === 0) {
return indexes; return indexes;
@@ -7,6 +7,8 @@ interface QuickAccessBarFooterExtensionsProps {
className?: string; className?: string;
} }
export function QuickAccessBarFooterExtensions(_props: QuickAccessBarFooterExtensionsProps) { export function QuickAccessBarFooterExtensions(
_props: QuickAccessBarFooterExtensionsProps,
) {
return null; return null;
} }
@@ -2,6 +2,8 @@ interface RightRailFooterExtensionsProps {
className?: string; className?: string;
} }
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) { export function RightRailFooterExtensions(
_props: RightRailFooterExtensionsProps,
) {
return null; return null;
} }
@@ -2,7 +2,10 @@ import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import AppsIcon from "@mui/icons-material/AppsRounded"; import AppsIcon from "@mui/icons-material/AppsRounded";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; 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 { useSidebarNavigation } from "@app/hooks/useSidebarNavigation";
import { handleUnlessSpecialClick } from "@app/utils/clickHandlers"; import { handleUnlessSpecialClick } from "@app/utils/clickHandlers";
import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton"; import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton";
@@ -12,9 +15,17 @@ interface AllToolsNavButtonProps {
setActiveButton: (id: string) => void; setActiveButton: (id: string) => void;
} }
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => { const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
activeButton,
setActiveButton,
}) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); const {
handleReaderToggle,
handleBackToTools,
selectedToolKey,
leftPanelView,
} = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState(); const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions(); const { actions: navigationActions } = useNavigationActions();
const { getHomeNavigation } = useSidebarNavigation(); 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) // 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(); 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 { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
import { useNavigate, useLocation } from "react-router-dom"; import { useNavigate, useLocation } from "react-router-dom";
import LocalIcon from "@app/components/shared/LocalIcon"; 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 { useAppConfig } from "@app/contexts/AppConfigContext";
import "@app/components/shared/AppConfigModal.css"; import "@app/components/shared/AppConfigModal.css";
import { useIsMobile } from "@app/hooks/useIsMobile"; 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 { 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"; import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
interface AppConfigModalProps { interface AppConfigModalProps {
@@ -17,7 +29,10 @@ interface AppConfigModalProps {
onClose: () => void; onClose: () => void;
} }
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose }) => { const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
opened,
onClose,
}) => {
const [active, setActive] = useState<NavKey>("general"); const [active, setActive] = useState<NavKey>("general");
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -42,7 +57,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
const section = getSectionFromPath(location.pathname); const section = getSectionFromPath(location.pathname);
if (opened && section) { if (opened && section) {
setActive(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 // If at /settings without a section, redirect to general
navigate("/settings/general", { replace: true }); navigate("/settings/general", { replace: true });
} }
@@ -64,7 +83,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
} }
}; };
window.addEventListener("appConfig:navigate", handler as EventListener); window.addEventListener("appConfig:navigate", handler as EventListener);
return () => window.removeEventListener("appConfig:navigate", handler as EventListener); return () =>
window.removeEventListener(
"appConfig:navigate",
handler as EventListener,
);
}, [navigate]); }, [navigate]);
const colors = useMemo( const colors = useMemo(
@@ -86,7 +109,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
const loginEnabled = config?.enableLogin ?? false; const loginEnabled = config?.enableLogin ?? false;
// Left navigation structure and icons // Left navigation structure and icons
const configNavSections = useConfigNavSections(isAdmin, runningEE, loginEnabled); const configNavSections = useConfigNavSections(
isAdmin,
runningEE,
loginEnabled,
);
const activeLabel = useMemo(() => { const activeLabel = useMemo(() => {
for (const section of configNavSections) { for (const section of configNavSections) {
@@ -151,7 +178,12 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{configNavSections.map((section) => ( {configNavSections.map((section) => (
<div key={section.title} className="modal-nav-section"> <div key={section.title} className="modal-nav-section">
{!isMobile && ( {!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} {section.title}
</Text> </Text>
)} )}
@@ -159,10 +191,14 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{section.items.map((item) => { {section.items.map((item) => {
const isActive = active === item.key; const isActive = active === item.key;
const isDisabled = item.disabled ?? false; 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 iconSize = isMobile ? 28 : 18;
const showPlanWarning = const showPlanWarning =
item.key === "adminPlan" && licenseAlert.active && licenseAlert.audience === "admin"; item.key === "adminPlan" &&
licenseAlert.active &&
licenseAlert.audience === "admin";
const navItemContent = ( const navItemContent = (
<div <div
@@ -174,20 +210,32 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
}} }}
className={`modal-nav-item ${isMobile ? "mobile" : ""}`} className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
style={{ style={{
background: isActive ? colors.navItemActiveBg : "transparent", background: isActive
? colors.navItemActiveBg
: "transparent",
opacity: isDisabled ? 0.6 : 1, opacity: isDisabled ? 0.6 : 1,
cursor: isDisabled ? "not-allowed" : "pointer", cursor: isDisabled ? "not-allowed" : "pointer",
}} }}
data-tour={`admin-${item.key}-nav`} 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 && ( {!isMobile && (
<Group gap={4} align="center" wrap="nowrap"> <Group gap={4} align="center" wrap="nowrap">
<Text size="sm" fw={500} style={{ color }}> <Text size="sm" fw={500} style={{ color }}>
{item.label} {item.label}
</Text> </Text>
{item.badge && ( {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} {item.badge}
</Badge> </Badge>
)} )}
@@ -196,7 +244,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
icon="warning-rounded" icon="warning-rounded"
width={14} width={14}
height={14} height={14}
style={{ color: "var(--mantine-color-orange-7)" }} style={{
color: "var(--mantine-color-orange-7)",
}}
/> />
)} )}
</Group> </Group>
@@ -215,7 +265,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{navItemContent} {navItemContent}
</Tooltip> </Tooltip>
) : ( ) : (
<React.Fragment key={item.key}>{navItemContent}</React.Fragment> <React.Fragment key={item.key}>
{navItemContent}
</React.Fragment>
); );
})} })}
</div> </div>
@@ -239,8 +291,18 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
{activeLabel} {activeLabel}
</Text> </Text>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<SettingsSearchBar configNavSections={configNavSections} onNavigate={handleNavigation} isMobile={isMobile} /> <SettingsSearchBar
<ActionIcon ref={closeButtonRef} variant="subtle" onClick={handleClose} aria-label="Close" data-autofocus> 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} /> <LocalIcon icon="close-rounded" width={18} height={18} />
</ActionIcon> </ActionIcon>
</Group> </Group>

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