mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
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:
@@ -4,6 +4,7 @@ public/vendor/
|
||||
public/pdfjs*/
|
||||
public/js/thirdParty/
|
||||
public/css/cookieconsent.css
|
||||
src-tauri/target/
|
||||
*.min.*
|
||||
*.md
|
||||
*.wxs
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -10,7 +10,10 @@ const nodeGlobs = ["scripts/**/*.{js,ts,mjs}", "*.config.{js,ts,mjs}"];
|
||||
|
||||
const baseRestrictedImportPatterns = [
|
||||
{ regex: "^\\.", message: "Use @app/* imports instead of relative imports." },
|
||||
{ regex: "^src/", message: "Use @app/* imports instead of absolute src/ imports." },
|
||||
{
|
||||
regex: "^src/",
|
||||
message: "Use @app/* imports instead of absolute src/ imports.",
|
||||
},
|
||||
];
|
||||
|
||||
export default defineConfig(
|
||||
@@ -64,7 +67,8 @@ export default defineConfig(
|
||||
...baseRestrictedImportPatterns,
|
||||
{
|
||||
regex: "^@tauri-apps/",
|
||||
message: "Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.",
|
||||
message:
|
||||
"Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
+4
-1
@@ -6,7 +6,10 @@
|
||||
<link rel="icon" href="modern-logo/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta name="description" content="The Free Adobe Acrobat alternative (10M+ Downloads)" />
|
||||
<meta
|
||||
name="description"
|
||||
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
|
||||
<link rel="manifest" href="manifest.json" />
|
||||
|
||||
|
||||
@@ -10,9 +10,19 @@ const frontendDir = process.cwd();
|
||||
const tauriDir = resolve(frontendDir, "src-tauri");
|
||||
const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml");
|
||||
|
||||
execFileSync("cargo", ["build", "--release", "--manifest-path", provisionerManifest], { stdio: "inherit" });
|
||||
execFileSync(
|
||||
"cargo",
|
||||
["build", "--release", "--manifest-path", provisionerManifest],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
const provisionerExe = join(tauriDir, "provisioner", "target", "release", "stirling-provisioner.exe");
|
||||
const provisionerExe = join(
|
||||
tauriDir,
|
||||
"provisioner",
|
||||
"target",
|
||||
"release",
|
||||
"stirling-provisioner.exe",
|
||||
);
|
||||
if (!existsSync(provisionerExe)) {
|
||||
throw new Error(`Provisioner binary not found at ${provisionerExe}`);
|
||||
}
|
||||
@@ -26,9 +36,19 @@ copyFileSync(provisionerExe, destExe);
|
||||
// --- Thumbnail handler DLL ---
|
||||
const thumbManifest = join(tauriDir, "thumbnail-handler", "Cargo.toml");
|
||||
|
||||
execFileSync("cargo", ["build", "--release", "--manifest-path", thumbManifest], { stdio: "inherit" });
|
||||
execFileSync(
|
||||
"cargo",
|
||||
["build", "--release", "--manifest-path", thumbManifest],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
|
||||
const thumbDll = join(tauriDir, "thumbnail-handler", "target", "release", "stirling_thumbnail_handler.dll");
|
||||
const thumbDll = join(
|
||||
tauriDir,
|
||||
"thumbnail-handler",
|
||||
"target",
|
||||
"release",
|
||||
"stirling_thumbnail_handler.dll",
|
||||
);
|
||||
if (!existsSync(thumbDll)) {
|
||||
throw new Error(`Thumbnail handler DLL not found at ${thumbDll}`);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Check for verbose flag
|
||||
const isVerbose = process.argv.includes("--verbose") || process.argv.includes("-v");
|
||||
const isVerbose =
|
||||
process.argv.includes("--verbose") || process.argv.includes("-v");
|
||||
|
||||
// Logging functions
|
||||
const info = (message) => console.log(message);
|
||||
@@ -41,62 +42,82 @@ function scanForUsedIcons() {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
|
||||
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
|
||||
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
|
||||
const localIconMatches = content.match(
|
||||
/<LocalIcon\s+[^>]*icon="([^"]+)"/g,
|
||||
);
|
||||
if (localIconMatches) {
|
||||
localIconMatches.forEach((match) => {
|
||||
const iconMatch = match.match(/icon="([^"]+)"/);
|
||||
if (iconMatch) {
|
||||
usedIcons.add(iconMatch[1]);
|
||||
debug(` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
|
||||
debug(
|
||||
` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Match LocalIcon usage: <LocalIcon icon='icon-name' ...>
|
||||
const localIconSingleQuoteMatches = content.match(/<LocalIcon\s+[^>]*icon='([^']+)'/g);
|
||||
const localIconSingleQuoteMatches = content.match(
|
||||
/<LocalIcon\s+[^>]*icon='([^']+)'/g,
|
||||
);
|
||||
if (localIconSingleQuoteMatches) {
|
||||
localIconSingleQuoteMatches.forEach((match) => {
|
||||
const iconMatch = match.match(/icon='([^']+)'/);
|
||||
if (iconMatch) {
|
||||
usedIcons.add(iconMatch[1]);
|
||||
debug(` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
|
||||
debug(
|
||||
` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
|
||||
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
|
||||
const spanMatches = content.match(
|
||||
/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g,
|
||||
);
|
||||
if (spanMatches) {
|
||||
spanMatches.forEach((match) => {
|
||||
const iconMatch = match.match(/>([^<]+)<\/span>/);
|
||||
if (iconMatch && iconMatch[1].trim()) {
|
||||
const iconName = iconMatch[1].trim();
|
||||
usedIcons.add(iconName);
|
||||
debug(` Found (legacy): ${iconName} in ${path.relative(srcDir, filePath)}`);
|
||||
debug(
|
||||
` Found (legacy): ${iconName} in ${path.relative(srcDir, filePath)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
|
||||
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
|
||||
const iconMatches = content.match(
|
||||
/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g,
|
||||
);
|
||||
if (iconMatches) {
|
||||
iconMatches.forEach((match) => {
|
||||
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
|
||||
if (iconMatch) {
|
||||
usedIcons.add(iconMatch[1]);
|
||||
debug(` Found (Icon): ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
|
||||
debug(
|
||||
` Found (Icon): ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Match icon config usage: icon: 'icon-name' or icon: "icon-name"
|
||||
const iconPropertyMatches = content.match(/icon:\s*(['"])([a-z0-9-]+)\1/g);
|
||||
const iconPropertyMatches = content.match(
|
||||
/icon:\s*(['"])([a-z0-9-]+)\1/g,
|
||||
);
|
||||
if (iconPropertyMatches) {
|
||||
iconPropertyMatches.forEach((match) => {
|
||||
const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/);
|
||||
if (iconMatch) {
|
||||
usedIcons.add(iconMatch[2]);
|
||||
debug(` Found (config): ${iconMatch[2]} in ${path.relative(srcDir, filePath)}`);
|
||||
debug(
|
||||
` Found (config): ${iconMatch[2]} in ${path.relative(srcDir, filePath)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -118,7 +139,13 @@ async function main() {
|
||||
const usedIcons = scanForUsedIcons();
|
||||
|
||||
// Check if we need to regenerate (compare with existing)
|
||||
const outputPath = path.join(__dirname, "..", "src", "assets", "material-symbols-icons.json");
|
||||
const outputPath = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"assets",
|
||||
"material-symbols-icons.json",
|
||||
);
|
||||
let needsRegeneration = true;
|
||||
|
||||
if (fs.existsSync(outputPath)) {
|
||||
@@ -159,11 +186,17 @@ async function main() {
|
||||
|
||||
// Check for missing icons
|
||||
const extractedIconNames = Object.keys(extractedIcons.icons || {});
|
||||
const missingIcons = usedIcons.filter((icon) => !extractedIconNames.includes(icon));
|
||||
const missingIcons = usedIcons.filter(
|
||||
(icon) => !extractedIconNames.includes(icon),
|
||||
);
|
||||
|
||||
if (missingIcons.length > 0) {
|
||||
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`);
|
||||
info("💡 These icons don't exist in Material Symbols. Please use available alternatives.");
|
||||
info(
|
||||
`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`,
|
||||
);
|
||||
info(
|
||||
"💡 These icons don't exist in Material Symbols. Please use available alternatives.",
|
||||
);
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
@@ -175,8 +208,12 @@ async function main() {
|
||||
// Write the extracted icon set to a file (outputPath already defined above)
|
||||
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
|
||||
|
||||
info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`);
|
||||
info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`);
|
||||
info(
|
||||
`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`,
|
||||
);
|
||||
info(
|
||||
`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`,
|
||||
);
|
||||
info(`💾 Saved to: ${outputPath}`);
|
||||
|
||||
// Generate TypeScript types
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const { execSync } = require("node:child_process");
|
||||
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require("node:fs");
|
||||
const {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
} = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const { argv } = require("node:process");
|
||||
@@ -16,7 +21,13 @@ const POSTPROCESS_ONLY = !!INPUT_FILE;
|
||||
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
|
||||
*/
|
||||
|
||||
const OUTPUT_FILE = path.join(__dirname, "..", "src", "assets", "3rdPartyLicenses.json");
|
||||
const OUTPUT_FILE = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"assets",
|
||||
"3rdPartyLicenses.json",
|
||||
);
|
||||
const PACKAGE_JSON = path.join(__dirname, "..", "package.json");
|
||||
|
||||
// Ensure the output directory exists
|
||||
@@ -30,7 +41,9 @@ console.log("🔍 Generating frontend license report...");
|
||||
try {
|
||||
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
|
||||
if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) {
|
||||
console.error("Fork PR detected: only --input (postprocess-only) mode is allowed.");
|
||||
console.error(
|
||||
"Fork PR detected: only --input (postprocess-only) mode is allowed.",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
@@ -90,13 +103,21 @@ try {
|
||||
licenseType = "Unknown";
|
||||
}
|
||||
|
||||
if ("posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
|
||||
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
|
||||
if (
|
||||
"posthog-js" === dep.name &&
|
||||
licenseType.startsWith("SEE LICENSE IN LICENSE")
|
||||
) {
|
||||
licenseType =
|
||||
"SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
|
||||
}
|
||||
|
||||
return {
|
||||
name: dep.name,
|
||||
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || "unknown",
|
||||
version:
|
||||
dep.installedVersion ||
|
||||
dep.definedVersion ||
|
||||
dep.remoteVersion ||
|
||||
"unknown",
|
||||
licenseType: licenseType,
|
||||
repository: dep.link,
|
||||
url: dep.link,
|
||||
@@ -107,12 +128,17 @@ try {
|
||||
// Transform to match Java backend format
|
||||
const transformedData = {
|
||||
dependencies: licenseArray.map((dep) => {
|
||||
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown";
|
||||
const licenseType = Array.isArray(dep.licenseType)
|
||||
? dep.licenseType.join(", ")
|
||||
: dep.licenseType || "Unknown";
|
||||
const licenseUrl = dep.link || getLicenseUrl(licenseType);
|
||||
|
||||
return {
|
||||
moduleName: dep.name,
|
||||
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
moduleUrl:
|
||||
dep.repository ||
|
||||
dep.url ||
|
||||
`https://www.npmjs.com/package/${dep.name}`,
|
||||
moduleVersion: dep.version,
|
||||
moduleLicense: licenseType,
|
||||
moduleLicenseUrl: licenseUrl,
|
||||
@@ -122,7 +148,9 @@ try {
|
||||
|
||||
// Log summary of license types found
|
||||
const licenseSummary = licenseArray.reduce((acc, dep) => {
|
||||
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown";
|
||||
const license = Array.isArray(dep.licenseType)
|
||||
? dep.licenseType.join(", ")
|
||||
: dep.licenseType || "Unknown";
|
||||
acc[license] = (acc[license] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -150,7 +178,10 @@ try {
|
||||
}
|
||||
|
||||
// Check for potentially problematic licenses
|
||||
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray);
|
||||
const problematicLicenses = checkLicenseCompatibility(
|
||||
licenseSummary,
|
||||
licenseArray,
|
||||
);
|
||||
if (problematicLicenses.length > 0) {
|
||||
console.log("\n⚠️ License compatibility warnings:");
|
||||
problematicLicenses.forEach((warning) => {
|
||||
@@ -158,7 +189,13 @@ try {
|
||||
});
|
||||
|
||||
// Write license warnings to a separate file for CI/CD
|
||||
const warningsFile = path.join(__dirname, "..", "src", "assets", "license-warnings.json");
|
||||
const warningsFile = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"src",
|
||||
"assets",
|
||||
"license-warnings.json",
|
||||
);
|
||||
writeFileSync(
|
||||
warningsFile,
|
||||
JSON.stringify(
|
||||
@@ -257,15 +294,21 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
// Copyleft licenses
|
||||
"GPL-2.0": "Strong copyleft license - requires derivative works to be GPL",
|
||||
"GPL-3.0": "Strong copyleft license - requires derivative works to be GPL",
|
||||
"LGPL-2.1": "Weak copyleft license - may require source disclosure for modifications",
|
||||
"LGPL-3.0": "Weak copyleft license - may require source disclosure for modifications",
|
||||
"AGPL-3.0": "Network copyleft license - requires source disclosure for network use",
|
||||
"AGPL-1.0": "Network copyleft license - requires source disclosure for network use",
|
||||
"LGPL-2.1":
|
||||
"Weak copyleft license - may require source disclosure for modifications",
|
||||
"LGPL-3.0":
|
||||
"Weak copyleft license - may require source disclosure for modifications",
|
||||
"AGPL-3.0":
|
||||
"Network copyleft license - requires source disclosure for network use",
|
||||
"AGPL-1.0":
|
||||
"Network copyleft license - requires source disclosure for network use",
|
||||
|
||||
// Other potentially problematic licenses
|
||||
WTFPL: "Potentially problematic license - legal uncertainty",
|
||||
"CC-BY-SA-4.0": "ShareAlike license - requires derivative works to use same license",
|
||||
"CC-BY-SA-3.0": "ShareAlike license - requires derivative works to use same license",
|
||||
"CC-BY-SA-4.0":
|
||||
"ShareAlike license - requires derivative works to use same license",
|
||||
"CC-BY-SA-3.0":
|
||||
"ShareAlike license - requires derivative works to use same license",
|
||||
"CC-BY-NC-4.0": "Non-commercial license - prohibits commercial use",
|
||||
"CC-BY-NC-3.0": "Non-commercial license - prohibits commercial use",
|
||||
"OSL-3.0": "Copyleft license - requires derivative works to be OSL",
|
||||
@@ -323,7 +366,9 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
|
||||
// Check if this license only affects our own packages
|
||||
const affectedPackages = licenseArray.filter((dep) => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
||||
const depLicense = Array.isArray(dep.licenseType)
|
||||
? dep.licenseType.join(", ")
|
||||
: dep.licenseType;
|
||||
return depLicense === license;
|
||||
});
|
||||
|
||||
@@ -335,7 +380,10 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
dep.name.toLowerCase().includes("stirlingpdf"),
|
||||
);
|
||||
|
||||
if (isOnlyOurPackages && (license === "UNLICENSED" || license.startsWith("SEE LICENSE IN"))) {
|
||||
if (
|
||||
isOnlyOurPackages &&
|
||||
(license === "UNLICENSED" || license.startsWith("SEE LICENSE IN"))
|
||||
) {
|
||||
return; // Skip warnings for our own Stirling-PDF packages
|
||||
}
|
||||
|
||||
@@ -361,18 +409,25 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
}
|
||||
|
||||
// For AND licenses or OR licenses with no good options, check for problematic components
|
||||
const hasProblematicComponent = Object.keys(problematicLicenses).some((problematic) => license.includes(problematic));
|
||||
const hasProblematicComponent = Object.keys(problematicLicenses).some(
|
||||
(problematic) => license.includes(problematic),
|
||||
);
|
||||
|
||||
if (hasProblematicComponent) {
|
||||
const affectedPackages = licenseArray
|
||||
.filter((dep) => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
||||
const depLicense = Array.isArray(dep.licenseType)
|
||||
? dep.licenseType.join(", ")
|
||||
: dep.licenseType;
|
||||
return depLicense === license;
|
||||
})
|
||||
.map((dep) => ({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
url:
|
||||
dep.repository ||
|
||||
dep.url ||
|
||||
`https://www.npmjs.com/package/${dep.name}`,
|
||||
}));
|
||||
|
||||
const licenseType = license.includes("AND") ? "AND" : "OR";
|
||||
@@ -397,21 +452,30 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
if (problematicLicenses[license]) {
|
||||
const affectedPackages = licenseArray
|
||||
.filter((dep) => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
||||
const depLicense = Array.isArray(dep.licenseType)
|
||||
? dep.licenseType.join(", ")
|
||||
: dep.licenseType;
|
||||
return depLicense === license;
|
||||
})
|
||||
.map((dep) => ({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
url:
|
||||
dep.repository ||
|
||||
dep.url ||
|
||||
`https://www.npmjs.com/package/${dep.name}`,
|
||||
}));
|
||||
|
||||
const packageList =
|
||||
affectedPackages
|
||||
.map((pkg) => pkg.name)
|
||||
.slice(0, 5)
|
||||
.join(", ") + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : "");
|
||||
const licenseUrl = getLicenseUrl(license) || "https://opensource.org/licenses";
|
||||
.join(", ") +
|
||||
(affectedPackages.length > 5
|
||||
? `, and ${affectedPackages.length - 5} more`
|
||||
: "");
|
||||
const licenseUrl =
|
||||
getLicenseUrl(license) || "https://opensource.org/licenses";
|
||||
|
||||
warnings.push({
|
||||
message: `⚠️ This PR contains ${count} package${count > 1 ? "s" : ""} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
|
||||
@@ -425,13 +489,18 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
||||
// Unknown license type - flag for manual review
|
||||
const affectedPackages = licenseArray
|
||||
.filter((dep) => {
|
||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
||||
const depLicense = Array.isArray(dep.licenseType)
|
||||
? dep.licenseType.join(", ")
|
||||
: dep.licenseType;
|
||||
return depLicense === license;
|
||||
})
|
||||
.map((dep) => ({
|
||||
name: dep.name,
|
||||
version: dep.version,
|
||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
||||
url:
|
||||
dep.repository ||
|
||||
dep.url ||
|
||||
`https://www.npmjs.com/package/${dep.name}`,
|
||||
}));
|
||||
|
||||
warnings.push({
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
|
||||
/* Font Stack */
|
||||
--font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
|
||||
"Helvetica Neue", sans-serif;
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
|
||||
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -10,15 +10,39 @@
|
||||
<!-- Page 1: Hero / Cover Page -->
|
||||
<div class="page page-1">
|
||||
<div class="decorative-shapes">
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="" />
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="" />
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="" />
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="" />
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="" />
|
||||
<img
|
||||
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
|
||||
class="shape shape-1"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg"
|
||||
class="shape shape-2"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
|
||||
class="shape shape-3"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg"
|
||||
class="shape shape-4"
|
||||
alt=""
|
||||
/>
|
||||
<img
|
||||
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
|
||||
class="shape shape-5"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="logo-container">
|
||||
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo" />
|
||||
<img
|
||||
src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg"
|
||||
alt="Stirling PDF"
|
||||
class="hero-logo"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
|
||||
<div class="hero-stats">
|
||||
@@ -40,21 +64,31 @@
|
||||
<div class="content-wrapper">
|
||||
<h2 class="page-title">What is Stirling PDF?</h2>
|
||||
<p class="intro-text">
|
||||
Stirling PDF is a robust, web-based PDF manipulation tool. It enables you to carry out various operations on PDF
|
||||
files, including splitting, merging, converting, rearranging, adding images, rotating, compressing, and more.
|
||||
Stirling PDF is a robust, web-based PDF manipulation tool. It enables
|
||||
you to carry out various operations on PDF files, including splitting,
|
||||
merging, converting, rearranging, adding images, rotating,
|
||||
compressing, and more.
|
||||
</p>
|
||||
|
||||
<div class="value-props">
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>50+ PDF Operations</h3>
|
||||
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
|
||||
<p>
|
||||
Comprehensive toolkit covering all your PDF needs. From basic
|
||||
operations to advanced processing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
@@ -66,24 +100,42 @@
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Workflow Automation</h3>
|
||||
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
|
||||
<p>
|
||||
Chain multiple operations together and save them as reusable
|
||||
workflows. Perfect for recurring tasks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
<path
|
||||
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Multi-Language Support</h3>
|
||||
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
|
||||
<p>
|
||||
Available in over 30 languages with community-contributed
|
||||
translations. Accessible to users worldwide.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||
@@ -91,30 +143,47 @@
|
||||
</div>
|
||||
<h3>Privacy First</h3>
|
||||
<p>
|
||||
Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.
|
||||
Self-hosted solution means your data stays on your infrastructure.
|
||||
You have full control over your documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Open Source</h3>
|
||||
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
|
||||
<p>
|
||||
Transparent, community-driven development. Inspect the code,
|
||||
contribute features, and adapt as needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="value-prop">
|
||||
<div class="value-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<polyline points="16 18 22 12 16 6" />
|
||||
<polyline points="8 6 2 12 8 18" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>API Access</h3>
|
||||
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
|
||||
<p>
|
||||
RESTful API for integration with external tools and scripts.
|
||||
Automate PDF operations programmatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,8 +198,15 @@
|
||||
<div class="feature-card" data-category="general">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||
/>
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
@@ -151,7 +227,12 @@
|
||||
<div class="feature-card" data-category="security">
|
||||
<div class="feature-header">
|
||||
<div class="feature-icon-large">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
|
||||
@@ -20,7 +20,9 @@ const args = process.argv.slice(2);
|
||||
const isDesktop = args.includes("--desktop");
|
||||
const isSaas = args.includes("--saas");
|
||||
|
||||
console.log("setup-env: see frontend/README.md#environment-variables for documentation");
|
||||
console.log(
|
||||
"setup-env: see frontend/README.md#environment-variables for documentation",
|
||||
);
|
||||
|
||||
function getExampleKeys(exampleFile: string): string[] {
|
||||
const examplePath = join(root, exampleFile);
|
||||
@@ -44,7 +46,9 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean {
|
||||
|
||||
config({ path: envPath });
|
||||
|
||||
const missing = getExampleKeys(exampleFile).filter((k) => !(k in process.env));
|
||||
const missing = getExampleKeys(exampleFile).filter(
|
||||
(k) => !(k in process.env),
|
||||
);
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
@@ -62,7 +66,8 @@ let failed = false;
|
||||
failed = ensureEnvFile(".env", "config/.env.example") || failed;
|
||||
|
||||
if (isDesktop) {
|
||||
failed = ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed;
|
||||
failed =
|
||||
ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed;
|
||||
}
|
||||
|
||||
if (isSaas) {
|
||||
@@ -75,7 +80,9 @@ const allExampleKeys = new Set([
|
||||
...getExampleKeys("config/.env.desktop.example"),
|
||||
...getExampleKeys("config/.env.saas.example"),
|
||||
]);
|
||||
const unknownViteVars = Object.keys(process.env).filter((k) => k.startsWith("VITE_") && !allExampleKeys.has(k));
|
||||
const unknownViteVars = Object.keys(process.env).filter(
|
||||
(k) => k.startsWith("VITE_") && !allExampleKeys.has(k),
|
||||
);
|
||||
if (unknownViteVars.length > 0) {
|
||||
console.warn(
|
||||
"setup-env: the following VITE_ vars are set but not listed in any example file:\n" +
|
||||
|
||||
@@ -21,7 +21,9 @@ export function AppLayout({ children }: AppLayoutProps) {
|
||||
height: 100% !important;
|
||||
}
|
||||
`}</style>
|
||||
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
style={{ height: "100vh", display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
{banner}
|
||||
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,10 @@ import { FilesModalProvider } from "@app/contexts/FilesModalContext";
|
||||
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
||||
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import {
|
||||
PreferencesProvider,
|
||||
usePreferences,
|
||||
} from "@app/contexts/PreferencesContext";
|
||||
import {
|
||||
AppConfigProvider,
|
||||
AppConfigProviderProps,
|
||||
@@ -67,7 +70,10 @@ function BrandingAssetManager() {
|
||||
}
|
||||
|
||||
// Avoid requirement to have props which are required in app providers anyway
|
||||
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, "children" | "retryOptions">;
|
||||
type AppConfigProviderOverrides = Omit<
|
||||
AppConfigProviderProps,
|
||||
"children" | "retryOptions"
|
||||
>;
|
||||
|
||||
export interface AppProvidersProps {
|
||||
children: ReactNode;
|
||||
@@ -84,7 +90,8 @@ function ServerDefaultsSync() {
|
||||
if (config) {
|
||||
const serverDefaults = {
|
||||
hideUnavailableTools: config.defaultHideUnavailableTools ?? false,
|
||||
hideUnavailableConversions: config.defaultHideUnavailableConversions ?? false,
|
||||
hideUnavailableConversions:
|
||||
config.defaultHideUnavailableConversions ?? false,
|
||||
};
|
||||
updateServerDefaults(serverDefaults);
|
||||
}
|
||||
@@ -97,17 +104,27 @@ function ServerDefaultsSync() {
|
||||
* Core application providers
|
||||
* Contains all providers needed for the core
|
||||
*/
|
||||
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
|
||||
export function AppProviders({
|
||||
children,
|
||||
appConfigRetryOptions,
|
||||
appConfigProviderProps,
|
||||
}: AppProvidersProps) {
|
||||
return (
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<BannerProvider>
|
||||
<AppConfigProvider retryOptions={appConfigRetryOptions} {...appConfigProviderProps}>
|
||||
<AppConfigProvider
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<ScarfTrackingInitializer />
|
||||
<AppConfigLoader />
|
||||
<ServerDefaultsSync />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<FileContextProvider
|
||||
enableUrlSync={true}
|
||||
enablePersistence={true}
|
||||
>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
@@ -124,7 +141,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<AnnotationProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>{children}</AdminTourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</AnnotationProvider>
|
||||
|
||||
@@ -11,7 +11,10 @@ import DesktopLayout from "@app/components/fileManager/DesktopLayout";
|
||||
import DragOverlay from "@app/components/fileManager/DragOverlay";
|
||||
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
|
||||
import { Z_INDEX_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||
import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from "@app/services/googleDrivePickerService";
|
||||
import {
|
||||
isGoogleDriveConfigured,
|
||||
extractGoogleDriveBackendConfig,
|
||||
} from "@app/services/googleDrivePickerService";
|
||||
import { loadScript } from "@app/utils/scriptLoader";
|
||||
import { useAllFiles } from "@app/contexts/FileContext";
|
||||
|
||||
@@ -20,7 +23,12 @@ interface FileManagerProps {
|
||||
}
|
||||
|
||||
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
|
||||
const {
|
||||
isFilesModalOpen,
|
||||
closeFilesModal,
|
||||
onFileUpload,
|
||||
onRecentFileSelect,
|
||||
} = useFilesModalContext();
|
||||
const { config } = useAppConfig();
|
||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -101,7 +109,9 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
return () => {
|
||||
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
|
||||
// Blob URLs are managed by FileContext and tool operations
|
||||
console.log("FileManager unmounting - FileContext handles blob URL cleanup");
|
||||
console.log(
|
||||
"FileManager unmounting - FileContext handles blob URL cleanup",
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -109,7 +119,12 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
// Use useMemo to only track Google Drive config changes, not all config updates
|
||||
const googleDriveBackendConfig = useMemo(
|
||||
() => extractGoogleDriveBackendConfig(config),
|
||||
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId],
|
||||
[
|
||||
config?.googleDriveEnabled,
|
||||
config?.googleDriveClientId,
|
||||
config?.googleDriveApiKey,
|
||||
config?.googleDriveAppId,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,7 +14,12 @@ interface StorageStatsCardProps {
|
||||
onReloadFiles: () => void;
|
||||
}
|
||||
|
||||
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => {
|
||||
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
||||
storageStats,
|
||||
filesCount,
|
||||
onClearAll,
|
||||
onReloadFiles,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!storageStats) return null;
|
||||
@@ -27,19 +32,27 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, files
|
||||
<StorageIcon />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{t("fileManager.storage", "Storage")}: {formatFileSize(storageStats.used)}
|
||||
{t("fileManager.storage", "Storage")}:{" "}
|
||||
{formatFileSize(storageStats.used)}
|
||||
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`}
|
||||
</Text>
|
||||
{storageStats.quota && (
|
||||
<Progress
|
||||
value={storageUsagePercent}
|
||||
color={storageUsagePercent > 80 ? "red" : storageUsagePercent > 60 ? "yellow" : "blue"}
|
||||
color={
|
||||
storageUsagePercent > 80
|
||||
? "red"
|
||||
: storageUsagePercent > 60
|
||||
? "yellow"
|
||||
: "blue"
|
||||
}
|
||||
size="sm"
|
||||
mt={4}
|
||||
/>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{storageStats.fileCount} {t("fileManager.filesStored", "files stored")}
|
||||
{storageStats.fileCount}{" "}
|
||||
{t("fileManager.filesStored", "files stored")}
|
||||
</Text>
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
@@ -54,7 +67,12 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, files
|
||||
{t("fileManager.clearAll", "Clear All")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="light" color="blue" size="xs" onClick={onReloadFiles}>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onReloadFiles}
|
||||
>
|
||||
Reload Files
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -26,7 +26,9 @@ interface PDFAnnotationContextValue {
|
||||
setSignatureConfig: (config: any | null) => void;
|
||||
}
|
||||
|
||||
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
|
||||
const PDFAnnotationContext = createContext<
|
||||
PDFAnnotationContextValue | undefined
|
||||
>(undefined);
|
||||
|
||||
interface PDFAnnotationProviderProps {
|
||||
children: ReactNode;
|
||||
@@ -75,13 +77,19 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
||||
setSignatureConfig,
|
||||
};
|
||||
|
||||
return <PDFAnnotationContext.Provider value={contextValue}>{children}</PDFAnnotationContext.Provider>;
|
||||
return (
|
||||
<PDFAnnotationContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</PDFAnnotationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
||||
const context = useContext(PDFAnnotationContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("usePDFAnnotation must be used within a PDFAnnotationProvider");
|
||||
throw new Error(
|
||||
"usePDFAnnotation must be used within a PDFAnnotationProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
@@ -34,7 +34,10 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
const [selectedColor, setSelectedColor] = useState("#000000");
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
const [historyAvailability, setHistoryAvailability] = useState({
|
||||
canUndo: false,
|
||||
canRedo: false,
|
||||
});
|
||||
const historyApiInstance = historyApiRef.current;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -77,7 +80,9 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
onRedo={redo}
|
||||
canUndo={historyAvailability.canUndo}
|
||||
canRedo={historyAvailability.canRedo}
|
||||
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
||||
onPlaceSignature={
|
||||
config.showPlaceButton ? handlePlaceSignature : undefined
|
||||
}
|
||||
hasSignatureData={!!signatureData}
|
||||
disabled={disabled}
|
||||
showPlaceButton={config.showPlaceButton}
|
||||
@@ -94,8 +99,13 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
||||
})}
|
||||
|
||||
{/* Instructions for placing signature */}
|
||||
<Alert color="blue" title={t("sign.instructions.title", "How to add signature")}>
|
||||
<Text size="sm">Click anywhere on the PDF to place your annotation.</Text>
|
||||
<Alert
|
||||
color="blue"
|
||||
title={t("sign.instructions.title", "How to add signature")}
|
||||
>
|
||||
<Text size="sm">
|
||||
Click anywhere on the PDF to place your annotation.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Stack,
|
||||
ColorSwatch,
|
||||
ColorPicker as MantineColorPicker,
|
||||
Group,
|
||||
} from "@mantine/core";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import ColorizeIcon from "@mui/icons-material/Colorize";
|
||||
|
||||
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
|
||||
// the button is hidden in the UI if the API is not supported.
|
||||
const supportsEyeDropper = typeof window !== "undefined" && "EyeDropper" in window;
|
||||
const supportsEyeDropper =
|
||||
typeof window !== "undefined" && "EyeDropper" in window;
|
||||
|
||||
interface EyeDropper {
|
||||
open(): Promise<{ sRGBHex: string }>;
|
||||
@@ -18,7 +27,12 @@ interface ColorControlProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) {
|
||||
export function ColorControl({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
disabled = false,
|
||||
}: ColorControlProps) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
// Buffer the colour locally so the picker stays responsive during drag.
|
||||
// Only propagate to the parent (which triggers expensive annotation updates)
|
||||
@@ -40,7 +54,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
|
||||
}, [onChange]);
|
||||
|
||||
return (
|
||||
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow withinPortal>
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
position="bottom"
|
||||
withArrow
|
||||
withinPortal
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={label}>
|
||||
<ActionIcon
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React from "react";
|
||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from "@mantine/core";
|
||||
import {
|
||||
Modal,
|
||||
Stack,
|
||||
ColorPicker as MantineColorPicker,
|
||||
Group,
|
||||
Button,
|
||||
ColorSwatch,
|
||||
Slider,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ColorPickerProps {
|
||||
@@ -27,16 +36,30 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const resolvedTitle = title ?? t("colorPicker.title", "Choose colour");
|
||||
const resolvedOpacityLabel = opacityLabel ?? t("annotation.opacity", "Opacity");
|
||||
const resolvedOpacityLabel =
|
||||
opacityLabel ?? t("annotation.opacity", "Opacity");
|
||||
|
||||
return (
|
||||
<Modal opened={isOpen} onClose={onClose} title={resolvedTitle} size="sm" centered>
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
title={resolvedTitle}
|
||||
size="sm"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<MantineColorPicker
|
||||
format="hex"
|
||||
value={selectedColor}
|
||||
onChange={onColorChange}
|
||||
swatches={["#000000", "#0066cc", "#cc0000", "#cc6600", "#009900", "#6600cc"]}
|
||||
swatches={[
|
||||
"#000000",
|
||||
"#0066cc",
|
||||
"#cc0000",
|
||||
"#cc6600",
|
||||
"#009900",
|
||||
"#6600cc",
|
||||
]}
|
||||
swatchesPerRow={6}
|
||||
size="lg"
|
||||
fullWidth
|
||||
@@ -74,6 +97,18 @@ interface ColorSwatchButtonProps {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({ color, onClick, size = 24 }) => {
|
||||
return <ColorSwatch color={color} size={size} radius={0} style={{ cursor: "pointer" }} onClick={onClick} />;
|
||||
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||
color,
|
||||
onClick,
|
||||
size = 24,
|
||||
}) => {
|
||||
return (
|
||||
<ColorSwatch
|
||||
color={color}
|
||||
size={size}
|
||||
radius={0}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -47,7 +47,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const padRef = useRef<SignaturePad | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(null);
|
||||
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const initPad = (canvas: HTMLCanvasElement) => {
|
||||
if (!padRef.current) {
|
||||
@@ -125,7 +127,17 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
trimmedCanvas.height = trimHeight;
|
||||
const trimmedCtx = trimmedCanvas.getContext("2d");
|
||||
if (trimmedCtx) {
|
||||
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
|
||||
trimmedCtx.drawImage(
|
||||
canvas,
|
||||
minX,
|
||||
minY,
|
||||
trimWidth,
|
||||
trimHeight,
|
||||
0,
|
||||
0,
|
||||
trimWidth,
|
||||
trimHeight,
|
||||
);
|
||||
}
|
||||
|
||||
return trimmedCanvas.toDataURL("image/png");
|
||||
@@ -140,7 +152,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
|
||||
const scale = Math.min(
|
||||
canvas.width / img.width,
|
||||
canvas.height / img.height,
|
||||
);
|
||||
const scaledWidth = img.width * scale;
|
||||
const scaledHeight = img.height * scale;
|
||||
const x = (canvas.width - scaledWidth) / 2;
|
||||
@@ -181,7 +196,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
if (previewCanvasRef.current) {
|
||||
const ctx = previewCanvasRef.current.getContext("2d");
|
||||
if (ctx) {
|
||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
||||
ctx.clearRect(
|
||||
0,
|
||||
0,
|
||||
previewCanvasRef.current.width,
|
||||
previewCanvasRef.current.height,
|
||||
);
|
||||
}
|
||||
}
|
||||
setSavedSignatureData(null); // Clear saved signature
|
||||
@@ -230,7 +250,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
<Paper withBorder p="md">
|
||||
<Stack gap="sm">
|
||||
<PrivateContent>
|
||||
<Text fw={500}>{t("sign.canvas.heading", "Draw your signature")}</Text>
|
||||
<Text fw={500}>
|
||||
{t("sign.canvas.heading", "Draw your signature")}
|
||||
</Text>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={width}
|
||||
@@ -264,7 +286,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
||||
<Text size="sm" fw={500}>
|
||||
{t("sign.canvas.colorLabel", "Colour")}
|
||||
</Text>
|
||||
<ColorSwatchButton color={selectedColor} onClick={onColorSwatchClick} />
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
onClick={onColorSwatchClick}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
|
||||
@@ -44,7 +44,12 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
disabled={undoDisabled}
|
||||
color={undoDisabled ? "gray" : "blue"}
|
||||
>
|
||||
<LocalIcon icon="undo" width={20} height={20} style={{ color: "currentColor" }} />
|
||||
<LocalIcon
|
||||
icon="undo"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ color: "currentColor" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -58,7 +63,12 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
disabled={redoDisabled}
|
||||
color={redoDisabled ? "gray" : "blue"}
|
||||
>
|
||||
<LocalIcon icon="redo" width={20} height={20} style={{ color: "currentColor" }} />
|
||||
<LocalIcon
|
||||
icon="redo"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ color: "currentColor" }}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -67,7 +77,13 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
||||
|
||||
{/* Place Signature Button */}
|
||||
{showPlaceButton && onPlaceSignature && (
|
||||
<Button variant="filled" color="blue" onClick={onPlaceSignature} disabled={disabled || !hasSignatureData} ml="auto">
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
onClick={onPlaceSignature}
|
||||
disabled={disabled || !hasSignatureData}
|
||||
ml="auto"
|
||||
>
|
||||
{placeButtonText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -27,22 +27,33 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const [removeBackground, setRemoveBackground] = useState(false);
|
||||
const [currentFile, setCurrentFile] = useState<File | null>(null);
|
||||
const [originalImageData, setOriginalImageData] = useState<string | null>(null);
|
||||
const [originalImageData, setOriginalImageData] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const processImage = async (imageSource: File | string, shouldRemoveBackground: boolean): Promise<void> => {
|
||||
const processImage = async (
|
||||
imageSource: File | string,
|
||||
shouldRemoveBackground: boolean,
|
||||
): Promise<void> => {
|
||||
if (shouldRemoveBackground && allowBackgroundRemoval) {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const transparentImageDataUrl = await removeWhiteBackground(imageSource, {
|
||||
const transparentImageDataUrl = await removeWhiteBackground(
|
||||
imageSource,
|
||||
{
|
||||
autoDetectCorner: true,
|
||||
tolerance: 15,
|
||||
});
|
||||
},
|
||||
);
|
||||
onProcessedImageData?.(transparentImageDataUrl);
|
||||
} catch (error) {
|
||||
console.error("Error removing background:", error);
|
||||
alert({
|
||||
title: t("sign.image.backgroundRemovalFailedTitle", "Background removal failed"),
|
||||
title: t(
|
||||
"sign.image.backgroundRemovalFailedTitle",
|
||||
"Background removal failed",
|
||||
),
|
||||
body: t(
|
||||
"sign.image.backgroundRemovalFailedMessage",
|
||||
"Could not remove the background from the image. Using original image instead.",
|
||||
@@ -72,7 +83,10 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
if (file && !disabled) {
|
||||
try {
|
||||
// Validate that it's actually an image file or SVG
|
||||
if (!file.type.startsWith("image/") && !file.name.toLowerCase().endsWith(".svg")) {
|
||||
if (
|
||||
!file.type.startsWith("image/") &&
|
||||
!file.name.toLowerCase().endsWith(".svg")
|
||||
) {
|
||||
console.error("Selected file is not an image or SVG");
|
||||
return;
|
||||
}
|
||||
@@ -83,7 +97,9 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
let dataUrlToProcess: string;
|
||||
|
||||
// Check if file is SVG
|
||||
const isSvg = file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
|
||||
const isSvg =
|
||||
file.type === "image/svg+xml" ||
|
||||
file.name.toLowerCase().endsWith(".svg");
|
||||
|
||||
if (isSvg) {
|
||||
// For SVG, convert to PNG so it can be embedded in PDF
|
||||
@@ -129,7 +145,10 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
let width = 800; // Default width
|
||||
let height = 600; // Default height
|
||||
|
||||
if (svgElement.hasAttribute("width") && svgElement.hasAttribute("height")) {
|
||||
if (
|
||||
svgElement.hasAttribute("width") &&
|
||||
svgElement.hasAttribute("height")
|
||||
) {
|
||||
width = parseFloat(svgElement.getAttribute("width") || "800");
|
||||
height = parseFloat(svgElement.getAttribute("height") || "600");
|
||||
} else if (svgElement.hasAttribute("viewBox")) {
|
||||
@@ -141,7 +160,12 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
}
|
||||
|
||||
// Ensure reasonable dimensions
|
||||
if (width === 0 || height === 0 || !isFinite(width) || !isFinite(height)) {
|
||||
if (
|
||||
width === 0 ||
|
||||
height === 0 ||
|
||||
!isFinite(width) ||
|
||||
!isFinite(height)
|
||||
) {
|
||||
width = 800;
|
||||
height = 600;
|
||||
}
|
||||
@@ -158,7 +182,9 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
|
||||
// Create an image element to render SVG
|
||||
const img = new Image();
|
||||
const blob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" });
|
||||
const blob = new Blob([svgText], {
|
||||
type: "image/svg+xml;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
img.onload = () => {
|
||||
@@ -239,7 +265,9 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
<PrivateContent>
|
||||
<FileInput
|
||||
label={label}
|
||||
placeholder={placeholder || t("sign.image.placeholder", "Select image file")}
|
||||
placeholder={
|
||||
placeholder || t("sign.image.placeholder", "Select image file")
|
||||
}
|
||||
accept="image/*,.svg"
|
||||
onChange={handleImageChange}
|
||||
disabled={disabled || isProcessing}
|
||||
@@ -247,9 +275,14 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
||||
</PrivateContent>
|
||||
{allowBackgroundRemoval && (
|
||||
<Checkbox
|
||||
label={t("sign.image.removeBackground", "Remove white background (make transparent)")}
|
||||
label={t(
|
||||
"sign.image.removeBackground",
|
||||
"Remove white background (make transparent)",
|
||||
)}
|
||||
checked={removeBackground}
|
||||
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)}
|
||||
onChange={(event) =>
|
||||
handleBackgroundRemovalChange(event.currentTarget.checked)
|
||||
}
|
||||
disabled={disabled || !currentFile || isProcessing}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Stack,
|
||||
Slider,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import OpacityIcon from "@mui/icons-material/Opacity";
|
||||
@@ -9,7 +16,11 @@ interface OpacityControlProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) {
|
||||
export function OpacityControl({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: OpacityControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
@@ -46,7 +57,13 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
|
||||
<Text size="xs" fw={500}>
|
||||
{t("annotation.opacity", "Opacity")}
|
||||
</Text>
|
||||
<Slider value={value} onChange={onChange} min={10} max={100} label={(val) => `${val}%`} />
|
||||
<Slider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={10}
|
||||
max={100}
|
||||
label={(val) => `${val}%`}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Stack,
|
||||
Slider,
|
||||
Text,
|
||||
Group,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
|
||||
@@ -18,7 +27,12 @@ interface PropertiesPopoverProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function PropertiesPopover({ annotationType, annotation, onUpdate, disabled = false }: PropertiesPopoverProps) {
|
||||
export function PropertiesPopover({
|
||||
annotationType,
|
||||
annotation,
|
||||
onUpdate,
|
||||
disabled = false,
|
||||
}: PropertiesPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
@@ -30,7 +44,9 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
const obj = annotation?.object as (PdfAnnotationObject & AnnotationObjectProps) | undefined;
|
||||
const obj = annotation?.object as
|
||||
| (PdfAnnotationObject & AnnotationObjectProps)
|
||||
| undefined;
|
||||
|
||||
// Get current values
|
||||
const fontSize = obj?.fontSize ?? 14;
|
||||
@@ -171,7 +187,9 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
|
||||
});
|
||||
}}
|
||||
>
|
||||
{borderVisible ? t("annotation.borderOn", "Border: On") : t("annotation.borderOff", "Border: Off")}
|
||||
{borderVisible
|
||||
? t("annotation.borderOn", "Border: On")
|
||||
: t("annotation.borderOff", "Border: Off")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
@@ -207,7 +225,8 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
|
||||
</Tooltip>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
{(annotationType === "text" || annotationType === "note") && renderTextNoteControls()}
|
||||
{(annotationType === "text" || annotationType === "note") &&
|
||||
renderTextNoteControls()}
|
||||
{annotationType === "shape" && renderShapeControls()}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from "@mantine/core";
|
||||
import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Select,
|
||||
Combobox,
|
||||
useCombobox,
|
||||
Group,
|
||||
Box,
|
||||
SegmentedControl,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Popover,
|
||||
Stack,
|
||||
Slider,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import LineWeightIcon from "@mui/icons-material/LineWeight";
|
||||
@@ -11,7 +18,13 @@ interface WidthControlProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function WidthControl({ value, onChange, min, max, disabled = false }: WidthControlProps) {
|
||||
export function WidthControl({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
disabled = false,
|
||||
}: WidthControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
@@ -48,7 +61,13 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
|
||||
<Text size="xs" fw={500}>
|
||||
{t("annotation.width", "Width")}
|
||||
</Text>
|
||||
<Slider value={value} onChange={onChange} min={min} max={max} label={(val) => `${val}pt`} />
|
||||
<Slider
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
min={min}
|
||||
max={max}
|
||||
label={(val) => `${val}pt`}
|
||||
/>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
|
||||
@@ -8,7 +8,10 @@ interface DrawingToolProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disabled = false }) => {
|
||||
export const DrawingTool: React.FC<DrawingToolProps> = ({
|
||||
onDrawingChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [selectedColor] = useState("#000000");
|
||||
const [penSize, setPenSize] = useState(2);
|
||||
const [penSizeInput, setPenSizeInput] = useState("2");
|
||||
@@ -20,7 +23,11 @@ export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disab
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onDrawingChange} disabled={disabled}>
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onDrawingChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<DrawingCanvas
|
||||
selectedColor={selectedColor}
|
||||
|
||||
@@ -8,7 +8,10 @@ interface ImageToolProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled = false }) => {
|
||||
export const ImageTool: React.FC<ImageToolProps> = ({
|
||||
onImageChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const [, setImageData] = useState<string | null>(null);
|
||||
|
||||
const handleImageUpload = async (file: File | null) => {
|
||||
@@ -45,7 +48,11 @@ export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled =
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onImageChange} disabled={disabled}>
|
||||
<BaseAnnotationTool
|
||||
config={toolConfig}
|
||||
onSignatureDataChange={onImageChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<ImageUploader
|
||||
onImageChange={handleImageUpload}
|
||||
|
||||
@@ -16,7 +16,11 @@ interface AddFileCardProps {
|
||||
multiple?: boolean;
|
||||
}
|
||||
|
||||
const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps) => {
|
||||
const AddFileCard = ({
|
||||
onFileSelect,
|
||||
accept,
|
||||
multiple = true,
|
||||
}: AddFileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
@@ -84,7 +88,9 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
|
||||
<div className={styles.logoMark}>
|
||||
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
|
||||
</div>
|
||||
<div className={styles.headerIndex}>{t("fileEditor.addFiles", "Add Files")}</div>
|
||||
<div className={styles.headerIndex}>
|
||||
{t("fileEditor.addFiles", "Add Files")}
|
||||
</div>
|
||||
<div className={styles.kebab} />
|
||||
</div>
|
||||
|
||||
@@ -131,8 +137,15 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && <span>{t("landing.addFiles", "Add Files")}</span>}
|
||||
<LocalIcon
|
||||
icon="add"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
className="text-[var(--accent-interactive)]"
|
||||
/>
|
||||
{!isUploadHover && (
|
||||
<span>{t("landing.addFiles", "Add Files")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
@@ -160,14 +173,22 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--accent-interactive)" }}
|
||||
/>
|
||||
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: ".5rem" }}>
|
||||
{terminology.uploadFromComputer}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: ".8rem", textAlign: "center", marginTop: "0.5rem" }}
|
||||
style={{
|
||||
fontSize: ".8rem",
|
||||
textAlign: "center",
|
||||
marginTop: "0.5rem",
|
||||
}}
|
||||
>
|
||||
{terminology.dropFilesHere}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from "@app/contexts/FileContext";
|
||||
import {
|
||||
useFileSelection,
|
||||
useFileState,
|
||||
useFileManagement,
|
||||
useFileActions,
|
||||
useFileContext,
|
||||
} from "@app/contexts/FileContext";
|
||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
@@ -22,7 +28,10 @@ interface FileEditorProps {
|
||||
supportedExtensions?: string[];
|
||||
}
|
||||
|
||||
const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEditorProps) => {
|
||||
const FileEditor = ({
|
||||
toolMode = false,
|
||||
supportedExtensions = ["pdf"],
|
||||
}: FileEditorProps) => {
|
||||
// Utility function to check if a file extension is supported
|
||||
const isFileSupported = useCallback(
|
||||
(fileName: string): boolean => {
|
||||
@@ -40,7 +49,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
const { clearAllFileErrors } = fileContextActions;
|
||||
|
||||
// Extract needed values from state (memoized to prevent infinite loops)
|
||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [state.files.byId, state.files.ids]);
|
||||
const activeStirlingFileStubs = useMemo(
|
||||
() => selectors.getStirlingFileStubs(),
|
||||
[state.files.byId, state.files.ids],
|
||||
);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
const totalItems = state.files.ids.length;
|
||||
const selectedCount = selectedFileIds.length;
|
||||
@@ -58,11 +70,27 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
const [_error, _setError] = useState<string | null>(null);
|
||||
|
||||
// Toast helpers
|
||||
const showStatus = useCallback((message: string, type: "neutral" | "success" | "warning" | "error" = "neutral") => {
|
||||
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
|
||||
}, []);
|
||||
const showStatus = useCallback(
|
||||
(
|
||||
message: string,
|
||||
type: "neutral" | "success" | "warning" | "error" = "neutral",
|
||||
) => {
|
||||
alert({
|
||||
alertType: type,
|
||||
title: message,
|
||||
expandable: false,
|
||||
durationMs: 4000,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const showError = useCallback((message: string) => {
|
||||
alert({ alertType: "error", title: "Error", body: message, expandable: true });
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: "Error",
|
||||
body: message,
|
||||
expandable: true,
|
||||
});
|
||||
}, []);
|
||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||
|
||||
@@ -83,7 +111,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
}, [toolMode]);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
// Get selected file IDs from context (defensive programming)
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
||||
const contextSelectedIds = Array.isArray(selectedFileIds)
|
||||
? selectedFileIds
|
||||
: [];
|
||||
|
||||
// Create refs for frequently changing values to stabilize callbacks
|
||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||
@@ -95,7 +125,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
const handleSelectAllFiles = useCallback(() => {
|
||||
// Respect maxAllowed: if limited, select the last N files
|
||||
const allIds = state.files.ids;
|
||||
const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds;
|
||||
const idsToSelect = Number.isFinite(maxAllowed)
|
||||
? allIds.slice(-maxAllowed)
|
||||
: allIds;
|
||||
setSelectedFiles(idsToSelect);
|
||||
try {
|
||||
clearAllFileErrors();
|
||||
@@ -147,7 +179,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
await addFiles(uploadedFiles, { selectFiles: true });
|
||||
// After auto-selection, enforce maxAllowed if needed
|
||||
if (Number.isFinite(maxAllowed)) {
|
||||
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map((r) => r.id);
|
||||
const nowSelectedIds = selectors
|
||||
.getSelectedStirlingFileStubs()
|
||||
.map((r) => r.id);
|
||||
if (nowSelectedIds.length > maxAllowed) {
|
||||
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
|
||||
}
|
||||
@@ -155,7 +189,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
showStatus(`Added ${uploadedFiles.length} file(s)`, "success");
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to process files";
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to process files";
|
||||
showError(errorMessage);
|
||||
console.error("File processing error:", err);
|
||||
}
|
||||
@@ -182,14 +217,18 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
// Add file to selection
|
||||
// Determine max files allowed from the active tool (negative or undefined means unlimited)
|
||||
const rawMax = selectedTool?.maxFiles;
|
||||
const maxAllowed = !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||
const maxAllowed =
|
||||
!toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||
|
||||
if (maxAllowed === 1) {
|
||||
// Only one file allowed -> replace selection with the new file
|
||||
newSelection = [contextFileId];
|
||||
} else {
|
||||
// If at capacity, drop the oldest selected and append the new one
|
||||
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
|
||||
if (
|
||||
Number.isFinite(maxAllowed) &&
|
||||
currentSelectedIds.length >= maxAllowed
|
||||
) {
|
||||
newSelection = [...currentSelectedIds.slice(1), contextFileId];
|
||||
} else {
|
||||
newSelection = [...currentSelectedIds, contextFileId];
|
||||
@@ -200,7 +239,13 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
// Update context (this automatically updates tool selection since they use the same action)
|
||||
setSelectedFiles(newSelection);
|
||||
},
|
||||
[setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles],
|
||||
[
|
||||
setSelectedFiles,
|
||||
toolMode,
|
||||
_setStatus,
|
||||
activeStirlingFileStubs,
|
||||
selectedTool?.maxFiles,
|
||||
],
|
||||
);
|
||||
|
||||
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
|
||||
@@ -226,13 +271,17 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
|
||||
// Handle multi-file selection reordering
|
||||
const filesToMove =
|
||||
selectedFileIds.length > 1 ? selectedFileIds.filter((id) => currentIds.includes(id)) : [sourceFileId];
|
||||
selectedFileIds.length > 1
|
||||
? selectedFileIds.filter((id) => currentIds.includes(id))
|
||||
: [sourceFileId];
|
||||
|
||||
// Create new order
|
||||
const newOrder = [...currentIds];
|
||||
|
||||
// Remove files to move from their current positions (in reverse order to maintain indices)
|
||||
const sourceIndices = filesToMove.map((id) => newOrder.findIndex((nId) => nId === id)).sort((a, b) => b - a); // Sort descending
|
||||
const sourceIndices = filesToMove
|
||||
.map((id) => newOrder.findIndex((nId) => nId === id))
|
||||
.sort((a, b) => b - a); // Sort descending
|
||||
|
||||
sourceIndices.forEach((index) => {
|
||||
newOrder.splice(index, 1);
|
||||
@@ -278,11 +327,19 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
removeFiles([contextFileId], false);
|
||||
|
||||
// Remove from context selections
|
||||
const currentSelected = selectedFileIds.filter((id) => id !== contextFileId);
|
||||
const currentSelected = selectedFileIds.filter(
|
||||
(id) => id !== contextFileId,
|
||||
);
|
||||
setSelectedFiles(currentSelected);
|
||||
}
|
||||
},
|
||||
[activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds],
|
||||
[
|
||||
activeStirlingFileStubs,
|
||||
selectors,
|
||||
removeFiles,
|
||||
setSelectedFiles,
|
||||
selectedFileIds,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDownloadFile = useCallback(
|
||||
@@ -315,7 +372,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
isDirty: false,
|
||||
});
|
||||
} else {
|
||||
console.log("[FileEditor] Skipping clean mark:", { savedPath: result.savedPath, isDirty: record.isDirty });
|
||||
console.log("[FileEditor] Skipping clean mark:", {
|
||||
savedPath: result.savedPath,
|
||||
isDirty: record.isDirty,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -329,7 +389,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
if (record && file) {
|
||||
try {
|
||||
// Extract and store files using shared service method
|
||||
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
|
||||
const result = await zipFileService.extractAndStoreFilesWithHistory(
|
||||
file,
|
||||
record,
|
||||
);
|
||||
|
||||
if (result.success && result.extractedStubs.length > 0) {
|
||||
// Add extracted file stubs to FileContext
|
||||
@@ -376,7 +439,12 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
navActions.setWorkbench("viewer");
|
||||
}
|
||||
},
|
||||
[activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench],
|
||||
[
|
||||
activeStirlingFileStubs,
|
||||
setActiveFileId,
|
||||
setActiveFileIndex,
|
||||
navActions.setWorkbench,
|
||||
],
|
||||
);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||
@@ -417,7 +485,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
</Text>
|
||||
<Text c="dimmed">No files loaded</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Upload PDF files, ZIP archives, or load from storage to get started
|
||||
Upload PDF files, ZIP archives, or load from storage to get
|
||||
started
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
@@ -432,7 +501,12 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
||||
}}
|
||||
>
|
||||
{/* Add File Card - only show when files exist */}
|
||||
{activeStirlingFileStubs.length > 0 && <AddFileCard key="add-file-card" onFileSelect={handleFileUpload} />}
|
||||
{activeStirlingFileStubs.length > 0 && (
|
||||
<AddFileCard
|
||||
key="add-file-card"
|
||||
onFileSelect={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,8 @@ interface FileEditorFileNameProps {
|
||||
file: StirlingFileStub;
|
||||
}
|
||||
|
||||
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => <PrivateContent>{file.name}</PrivateContent>;
|
||||
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
);
|
||||
|
||||
export default FileEditorFileName;
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import React, { useState, useCallback, useRef, useMemo } from "react";
|
||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from "@mantine/core";
|
||||
import {
|
||||
Text,
|
||||
ActionIcon,
|
||||
CheckboxIndicator,
|
||||
Tooltip,
|
||||
Modal,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { alert } from "@app/components/toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -14,7 +24,10 @@ import PushPinIcon from "@mui/icons-material/PushPin";
|
||||
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { zipFileService } from "@app/services/zipFileService";
|
||||
|
||||
@@ -24,7 +37,9 @@ import { useFileState } from "@app/contexts/file/fileHooks";
|
||||
import { FileId } from "@app/types/file";
|
||||
import { formatFileSize } from "@app/utils/fileUtils";
|
||||
import ToolChain from "@app/components/shared/ToolChain";
|
||||
import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu";
|
||||
import HoverActionMenu, {
|
||||
HoverAction,
|
||||
} from "@app/components/shared/HoverActionMenu";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||
@@ -42,7 +57,11 @@ interface FileEditorThumbnailProps {
|
||||
onCloseFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
_onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onReorderFiles?: (
|
||||
sourceFileId: FileId,
|
||||
targetFileId: FileId,
|
||||
selectedFileIds: FileId[],
|
||||
) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
onUnzipFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
@@ -67,7 +86,14 @@ const FileEditorThumbnail = ({
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const DownloadOutlinedIcon = icons.download;
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions, openEncryptedUnlockPrompt } = useFileContext();
|
||||
const {
|
||||
pinFile,
|
||||
unpinFile,
|
||||
isFilePinned,
|
||||
activeFiles,
|
||||
actions: fileActions,
|
||||
openEncryptedUnlockPrompt,
|
||||
} = useFileContext();
|
||||
const { state, selectors } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
@@ -117,18 +143,29 @@ const FileEditorThumbnail = ({
|
||||
const isCBZ = extLower === "cbz";
|
||||
const isCBR = extLower === "cbr";
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
const sharingEnabled =
|
||||
uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled =
|
||||
sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
||||
const isSharedFile = file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink;
|
||||
const isSharedFile =
|
||||
file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink;
|
||||
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
||||
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
||||
const isUploaded = Boolean(file.remoteStorageId);
|
||||
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||
const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate);
|
||||
const canUpload =
|
||||
uploadEnabled &&
|
||||
isOwnedOrLocal &&
|
||||
file.isLeaf &&
|
||||
(!isUploaded || !isUpToDate);
|
||||
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
|
||||
|
||||
const pageLabel = useMemo(() => (pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : ""), [pageCount]);
|
||||
const pageLabel = useMemo(
|
||||
() =>
|
||||
pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : "",
|
||||
[pageCount],
|
||||
);
|
||||
|
||||
const dateLabel = useMemo(() => {
|
||||
const d = new Date(file.lastModified);
|
||||
@@ -198,7 +235,12 @@ const FileEditorThumbnail = ({
|
||||
|
||||
const handleConfirmClose = useCallback(() => {
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: "neutral", title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
alert({
|
||||
alertType: "neutral",
|
||||
title: `Closed ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500,
|
||||
});
|
||||
setShowCloseModal(false);
|
||||
}, [file.id, file.name, onCloseFile]);
|
||||
|
||||
@@ -222,16 +264,33 @@ const FileEditorThumbnail = ({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to save ${file.name}:`, error);
|
||||
alert({ alertType: "error", title: "Save failed", body: `Could not save ${file.name}`, expandable: true });
|
||||
alert({
|
||||
alertType: "error",
|
||||
title: "Save failed",
|
||||
body: `Could not save ${file.name}`,
|
||||
expandable: true,
|
||||
});
|
||||
setShowCloseModal(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Then close
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: "success", title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: `Saved and closed ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3500,
|
||||
});
|
||||
setShowCloseModal(false);
|
||||
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
|
||||
}, [
|
||||
file.id,
|
||||
file.name,
|
||||
file.localFilePath,
|
||||
onCloseFile,
|
||||
selectors,
|
||||
fileActions,
|
||||
]);
|
||||
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowCloseModal(false);
|
||||
@@ -298,7 +357,12 @@ const FileEditorThumbnail = ({
|
||||
e.stopPropagation();
|
||||
if (onUnzipFile) {
|
||||
onUnzipFile(file.id);
|
||||
alert({ alertType: "success", title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: `Unzipping ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 2500,
|
||||
});
|
||||
}
|
||||
},
|
||||
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
|
||||
@@ -381,7 +445,10 @@ const FileEditorThumbnail = ({
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className={`${styles.header} ${getHeaderClassName()}`} data-has-error={hasError}>
|
||||
<div
|
||||
className={`${styles.header} ${getHeaderClassName()}`}
|
||||
data-has-error={hasError}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{hasError ? (
|
||||
@@ -402,16 +469,27 @@ const FileEditorThumbnail = ({
|
||||
</div>
|
||||
|
||||
{/* Centered index */}
|
||||
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
||||
<div
|
||||
className={styles.headerIndex}
|
||||
aria-label={`Position ${index + 1}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{isEncrypted && (
|
||||
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
|
||||
<Tooltip
|
||||
label={t(
|
||||
"encryptedPdfUnlock.unlockPrompt",
|
||||
"Unlock PDF to continue",
|
||||
)}
|
||||
>
|
||||
<ActionIcon
|
||||
aria-label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}
|
||||
aria-label={t(
|
||||
"encryptedPdfUnlock.unlockPrompt",
|
||||
"Unlock PDF to continue",
|
||||
)}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
@@ -426,7 +504,9 @@ const FileEditorThumbnail = ({
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip
|
||||
label={
|
||||
isPinned ? t("unpin", "Unpin File (replace after tool run)") : t("pin", "Pin File (keep active after tool run)")
|
||||
isPinned
|
||||
? t("unpin", "Unpin File (replace after tool run)")
|
||||
: t("pin", "Pin File (keep active after tool run)")
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
@@ -443,15 +523,29 @@ const FileEditorThumbnail = ({
|
||||
if (actualFile) {
|
||||
if (isPinned) {
|
||||
unpinFile(actualFile);
|
||||
alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
alert({
|
||||
alertType: "neutral",
|
||||
title: `Unpinned ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
} else {
|
||||
pinFile(actualFile);
|
||||
alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
||||
alert({
|
||||
alertType: "success",
|
||||
title: `Pinned ${file.name}`,
|
||||
expandable: false,
|
||||
durationMs: 3000,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
{isPinned ? (
|
||||
<PushPinIcon fontSize="small" />
|
||||
) : (
|
||||
<PushPinOutlinedIcon fontSize="small" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@@ -470,7 +564,13 @@ const FileEditorThumbnail = ({
|
||||
<Text size="lg" fw={700} className={styles.title} title={file.name}>
|
||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" className={styles.meta} lineClamp={3} title={`${extUpper || "FILE"} • ${prettySize}`}>
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
className={styles.meta}
|
||||
lineClamp={3}
|
||||
title={`${extUpper || "FILE"} • ${prettySize}`}
|
||||
>
|
||||
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
||||
{`v${file.versionNumber} - `}
|
||||
{dateLabel}
|
||||
@@ -482,7 +582,11 @@ const FileEditorThumbnail = ({
|
||||
{/* Preview area */}
|
||||
<div
|
||||
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||
style={isSupported || hasError ? undefined : { filter: "grayscale(80%)", opacity: 0.6 }}
|
||||
style={
|
||||
isSupported || hasError
|
||||
? undefined
|
||||
: { filter: "grayscale(80%)", opacity: 0.6 }
|
||||
}
|
||||
>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnailUrl ? (
|
||||
@@ -513,7 +617,12 @@ const FileEditorThumbnail = ({
|
||||
/>
|
||||
</PrivateContent>
|
||||
) : file.type?.startsWith("application/pdf") ? (
|
||||
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
|
||||
<Stack
|
||||
align="center"
|
||||
justify="center"
|
||||
gap="xs"
|
||||
style={{ height: "100%" }}
|
||||
>
|
||||
<Loader size="sm" />
|
||||
<Text size="xs" c="dimmed">
|
||||
Loading thumbnail...
|
||||
@@ -554,7 +663,11 @@ const FileEditorThumbnail = ({
|
||||
</div>
|
||||
|
||||
{/* Hover Menu */}
|
||||
<HoverActionMenu show={showHoverMenu || isMobile} actions={hoverActions} position="outside" />
|
||||
<HoverActionMenu
|
||||
show={showHoverMenu || isMobile}
|
||||
actions={hoverActions}
|
||||
position="outside"
|
||||
/>
|
||||
|
||||
{/* Close Confirmation Modal */}
|
||||
<Modal
|
||||
@@ -567,7 +680,9 @@ const FileEditorThumbnail = ({
|
||||
<Stack gap="md">
|
||||
{file.isDirty && file.localFilePath ? (
|
||||
<>
|
||||
<Text size="md">{t("confirmCloseUnsaved", "This file has unsaved changes.")}</Text>
|
||||
<Text size="md">
|
||||
{t("confirmCloseUnsaved", "This file has unsaved changes.")}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
</Text>
|
||||
@@ -575,7 +690,11 @@ const FileEditorThumbnail = ({
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
{t("confirmCloseCancel", "Cancel")}
|
||||
</Button>
|
||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="red"
|
||||
onClick={handleConfirmClose}
|
||||
>
|
||||
{t("confirmCloseDiscard", "Discard changes and close")}
|
||||
</Button>
|
||||
<Button variant="filled" onClick={handleSaveAndClose}>
|
||||
@@ -585,7 +704,12 @@ const FileEditorThumbnail = ({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="md">{t("confirmCloseMessage", "Are you sure you want to close this file?")}</Text>
|
||||
<Text size="md">
|
||||
{t(
|
||||
"confirmCloseMessage",
|
||||
"Are you sure you want to close this file?",
|
||||
)}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
</Text>
|
||||
@@ -593,7 +717,11 @@ const FileEditorThumbnail = ({
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
{t("confirmCloseCancel", "Cancel")}
|
||||
</Button>
|
||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="red"
|
||||
onClick={handleConfirmClose}
|
||||
>
|
||||
{t("confirmCloseConfirm", "Close File")}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -623,8 +751,20 @@ const FileEditorThumbnail = ({
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{canUpload && <UploadToServerModal opened={showUploadModal} onClose={() => setShowUploadModal(false)} file={file} />}
|
||||
{canShare && <ShareFileModal opened={showShareModal} onClose={() => setShowShareModal(false)} file={file} />}
|
||||
{canUpload && (
|
||||
<UploadToServerModal
|
||||
opened={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
{canShare && (
|
||||
<ShareFileModal
|
||||
opened={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons";
|
||||
import {
|
||||
useRightRailButtons,
|
||||
RightRailButtonWithAction,
|
||||
} from "@app/hooks/useRightRailButtons";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
|
||||
interface FileEditorRightRailButtonsParams {
|
||||
@@ -26,7 +29,10 @@ export function useFileEditorRightRailButtons({
|
||||
id: "file-select-all",
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t("rightRail.selectAll", "Select All"),
|
||||
ariaLabel: typeof t === "function" ? t("rightRail.selectAll", "Select All") : "Select All",
|
||||
ariaLabel:
|
||||
typeof t === "function"
|
||||
? t("rightRail.selectAll", "Select All")
|
||||
: "Select All",
|
||||
section: "top" as const,
|
||||
order: 10,
|
||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
||||
@@ -35,9 +41,18 @@ export function useFileEditorRightRailButtons({
|
||||
},
|
||||
{
|
||||
id: "file-deselect-all",
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="crop-square-outline"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
tooltip: t("rightRail.deselectAll", "Deselect All"),
|
||||
ariaLabel: typeof t === "function" ? t("rightRail.deselectAll", "Deselect All") : "Deselect All",
|
||||
ariaLabel:
|
||||
typeof t === "function"
|
||||
? t("rightRail.deselectAll", "Deselect All")
|
||||
: "Deselect All",
|
||||
section: "top" as const,
|
||||
order: 20,
|
||||
disabled: selectedCount === 0,
|
||||
@@ -48,7 +63,10 @@ export function useFileEditorRightRailButtons({
|
||||
id: "file-close-selected",
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
|
||||
ariaLabel: typeof t === "function" ? t("rightRail.closeSelected", "Close Selected Files") : "Close Selected Files",
|
||||
ariaLabel:
|
||||
typeof t === "function"
|
||||
? t("rightRail.closeSelected", "Close Selected Files")
|
||||
: "Close Selected Files",
|
||||
section: "top" as const,
|
||||
order: 30,
|
||||
disabled: selectedCount === 0,
|
||||
@@ -56,7 +74,15 @@ export function useFileEditorRightRailButtons({
|
||||
onClick: onCloseSelected,
|
||||
},
|
||||
],
|
||||
[t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected],
|
||||
[
|
||||
t,
|
||||
i18n.language,
|
||||
totalItems,
|
||||
selectedCount,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
onCloseSelected,
|
||||
],
|
||||
);
|
||||
|
||||
useRightRailButtons(buttons);
|
||||
|
||||
@@ -35,9 +35,14 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
const hasMultipleFiles = numberOfFiles > 1;
|
||||
const showOwner = Boolean(
|
||||
currentFile && (currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink),
|
||||
currentFile &&
|
||||
(currentFile.remoteOwnedByCurrentUser === false ||
|
||||
currentFile.remoteSharedViaLink),
|
||||
);
|
||||
const ownerLabel = currentFile ? currentFile.remoteOwnerUsername || t("fileManager.ownerUnknown", "Unknown") : "";
|
||||
const ownerLabel = currentFile
|
||||
? currentFile.remoteOwnerUsername ||
|
||||
t("fileManager.ownerUnknown", "Unknown")
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Stack gap="xs" style={{ height: "100%" }}>
|
||||
@@ -78,7 +83,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<PictureAsPdfIcon style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }} />
|
||||
<PictureAsPdfIcon
|
||||
style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }}
|
||||
/>
|
||||
</Center>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -86,7 +93,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{/* File info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
<PrivateContent>{currentFile ? currentFile.name : "No file selected"}</PrivateContent>
|
||||
<PrivateContent>
|
||||
{currentFile ? currentFile.name : "No file selected"}
|
||||
</PrivateContent>
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile ? getFileSize(currentFile) : ""}
|
||||
@@ -101,7 +110,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{/* Compact tool chain for mobile */}
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(" → ")}
|
||||
{currentFile.toolHistory
|
||||
.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId))
|
||||
.join(" → ")}
|
||||
</Text>
|
||||
)}
|
||||
{currentFile && showOwner && (
|
||||
@@ -114,10 +125,20 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
{/* Navigation arrows for multiple files */}
|
||||
{hasMultipleFiles && (
|
||||
<Box style={{ display: "flex", gap: "0.25rem" }}>
|
||||
<ActionIcon variant="subtle" size="sm" onClick={onPrevious} disabled={isAnimating}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onPrevious}
|
||||
disabled={isAnimating}
|
||||
>
|
||||
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" size="sm" onClick={onNext} disabled={isAnimating}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={onNext}
|
||||
disabled={isAnimating}
|
||||
>
|
||||
<ChevronRightIcon style={{ fontSize: 16 }} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
@@ -131,7 +152,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
disabled={!hasSelection}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
|
||||
backgroundColor: hasSelection
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,12 @@ const DesktopLayout: React.FC = () => {
|
||||
const { activeSource, recentFiles, modalHeight } = useFileManagerContext();
|
||||
|
||||
return (
|
||||
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: "nowrap", minWidth: 0 }}>
|
||||
<Grid
|
||||
gutter="xs"
|
||||
h="100%"
|
||||
grow={false}
|
||||
style={{ flexWrap: "nowrap", minWidth: 0 }}
|
||||
>
|
||||
{/* Column 1: File Sources */}
|
||||
<Grid.Col
|
||||
span="content"
|
||||
@@ -74,9 +79,16 @@ const DesktopLayout: React.FC = () => {
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<FileListArea
|
||||
scrollAreaHeight={activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%"}
|
||||
scrollAreaHeight={
|
||||
activeSource === "recent" && recentFiles.length > 0
|
||||
? `calc(${modalHeight} - 7rem)`
|
||||
: "100%"
|
||||
}
|
||||
scrollAreaStyle={{
|
||||
height: activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%",
|
||||
height:
|
||||
activeSource === "recent" && recentFiles.length > 0
|
||||
? `calc(${modalHeight} - 7rem)`
|
||||
: "100%",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 0,
|
||||
|
||||
@@ -32,7 +32,9 @@ const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
|
||||
}}
|
||||
>
|
||||
<Stack align="center" gap="md">
|
||||
<UploadFileIcon style={{ fontSize: "4rem", color: theme.colors.blue[6] }} />
|
||||
<UploadFileIcon
|
||||
style={{ fontSize: "4rem", color: theme.colors.blue[6] }}
|
||||
/>
|
||||
<Text size="xl" fw={500} c="blue.6">
|
||||
{t("fileManager.dropFilesHere", "Drop files here to upload")}
|
||||
</Text>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Group, Text, Stack, useMantineColorScheme } from "@mantine/core";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Text,
|
||||
Stack,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import HistoryIcon from "@mui/icons-material/History";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||
@@ -49,7 +55,9 @@ const EmptyFilesState: React.FC = () => {
|
||||
>
|
||||
{/* No Recent Files Message */}
|
||||
<Stack align="center" gap="sm">
|
||||
<HistoryIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
|
||||
<HistoryIcon
|
||||
style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }}
|
||||
/>
|
||||
<Text c="dimmed" ta="center" size="lg">
|
||||
{t("fileManager.noRecentFiles", "No recent files")}
|
||||
</Text>
|
||||
@@ -91,7 +99,8 @@ const EmptyFilesState: React.FC = () => {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transition: "width .5s ease, padding .5s ease, border-radius .5s ease",
|
||||
transition:
|
||||
"width .5s ease, padding .5s ease, border-radius .5s ease",
|
||||
}}
|
||||
onClick={handleUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
@@ -102,12 +111,19 @@ const EmptyFilesState: React.FC = () => {
|
||||
height="1.25rem"
|
||||
style={{ color: "var(--accent-interactive)" }}
|
||||
/>
|
||||
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: ".5rem" }}>
|
||||
{terminology.uploadFromComputer}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Instruction Text */}
|
||||
<span className="text-[var(--accent-interactive)]" style={{ fontSize: ".8rem", textAlign: "center" }}>
|
||||
<span
|
||||
className="text-[var(--accent-interactive)]"
|
||||
style={{ fontSize: ".8rem", textAlign: "center" }}
|
||||
>
|
||||
{terminology.dropFilesHere}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { Group, Text, ActionIcon, Tooltip, SegmentedControl } from "@mantine/core";
|
||||
import {
|
||||
Group,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
SegmentedControl,
|
||||
} from "@mantine/core";
|
||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||
@@ -33,33 +39,51 @@ const FileActions: React.FC = () => {
|
||||
onStorageFilterChange,
|
||||
} = useFileManagerContext();
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
const sharingEnabled =
|
||||
uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled =
|
||||
sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
const showStorageFilter = uploadEnabled;
|
||||
const storageFilterOptions = sharingEnabled
|
||||
? [
|
||||
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
||||
{ value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") },
|
||||
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") },
|
||||
{
|
||||
value: "sharedWithMe",
|
||||
label: t("fileManager.filterSharedWithMe", "Shared with me"),
|
||||
},
|
||||
{
|
||||
value: "sharedByMe",
|
||||
label: t("fileManager.filterSharedByMe", "Shared by me"),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
||||
];
|
||||
useEffect(() => {
|
||||
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) {
|
||||
if (
|
||||
!sharingEnabled &&
|
||||
(storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")
|
||||
) {
|
||||
onStorageFilterChange("all");
|
||||
}
|
||||
}, [sharingEnabled, storageFilter, onStorageFilterChange]);
|
||||
const hasSelection = selectedFileIds.length > 0;
|
||||
const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false);
|
||||
const hasOnlyOwnedSelection = selectedFiles.every(
|
||||
(file) => file.remoteOwnedByCurrentUser !== false,
|
||||
);
|
||||
const hasDownloadAccess = selectedFiles.every((file) => {
|
||||
const role = (file.remoteOwnedByCurrentUser !== false ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase();
|
||||
const role = (
|
||||
file.remoteOwnedByCurrentUser !== false
|
||||
? "editor"
|
||||
: (file.remoteAccessRole ?? "viewer")
|
||||
).toLowerCase();
|
||||
return role === "editor" || role === "commenter" || role === "viewer";
|
||||
});
|
||||
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||
const canBulkShare =
|
||||
shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||
|
||||
const handleSelectAll = () => {
|
||||
onSelectAll();
|
||||
@@ -82,7 +106,8 @@ const FileActions: React.FC = () => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||
const allFilesSelected =
|
||||
filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -99,7 +124,11 @@ const FileActions: React.FC = () => {
|
||||
{/* Left: Select All + Filter */}
|
||||
<Group gap="xs">
|
||||
<Tooltip
|
||||
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
|
||||
label={
|
||||
allFilesSelected
|
||||
? t("fileManager.deselectAll", "Deselect All")
|
||||
: t("fileManager.selectAll", "Select All")
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
@@ -116,7 +145,11 @@ const FileActions: React.FC = () => {
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={storageFilter}
|
||||
onChange={(value) => onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")}
|
||||
onChange={(value) =>
|
||||
onStorageFilterChange(
|
||||
value as "all" | "local" | "sharedWithMe" | "sharedByMe",
|
||||
)
|
||||
}
|
||||
data={storageFilterOptions}
|
||||
/>
|
||||
)}
|
||||
@@ -132,7 +165,9 @@ const FileActions: React.FC = () => {
|
||||
>
|
||||
{hasSelection && (
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })}
|
||||
{t("fileManager.selectedCount", "{{count}} selected", {
|
||||
count: selectedFileIds.length,
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,8 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
// Get the currently displayed file
|
||||
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||
const currentFile =
|
||||
selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||
const hasSelection = selectedFiles.length > 0;
|
||||
|
||||
// Use IndexedDB hook for the current file
|
||||
@@ -33,7 +34,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
if (isAnimating) return;
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setCurrentFileIndex((prev) => (prev > 0 ? prev - 1 : selectedFiles.length - 1));
|
||||
setCurrentFileIndex((prev) =>
|
||||
prev > 0 ? prev - 1 : selectedFiles.length - 1,
|
||||
);
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
};
|
||||
@@ -42,7 +45,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
if (isAnimating) return;
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => {
|
||||
setCurrentFileIndex((prev) => (prev < selectedFiles.length - 1 ? prev + 1 : 0));
|
||||
setCurrentFileIndex((prev) =>
|
||||
prev < selectedFiles.length - 1 ? prev + 1 : 0,
|
||||
);
|
||||
setIsAnimating(false);
|
||||
}, 150);
|
||||
};
|
||||
@@ -73,7 +78,14 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
return (
|
||||
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
|
||||
{/* Section 1: Thumbnail Preview */}
|
||||
<Box style={{ width: "100%", height: "min(35vh, 280px)", textAlign: "center", flexShrink: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "min(35vh, 280px)",
|
||||
textAlign: "center",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<FilePreview
|
||||
file={currentFile}
|
||||
thumbnail={getCurrentThumbnail()}
|
||||
@@ -95,7 +107,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
||||
disabled={!hasSelection}
|
||||
fullWidth
|
||||
style={{
|
||||
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
|
||||
backgroundColor: hasSelection
|
||||
? "var(--btn-open-file)"
|
||||
: "var(--mantine-color-gray-4)",
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -39,7 +39,8 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
<Box ml="md" mt="xs" mb="sm">
|
||||
<Group align="center" mb="sm">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t("fileManager.fileHistory", "File History")} ({sortedHistory.length})
|
||||
{t("fileManager.fileHistory", "File History")} (
|
||||
{sortedHistory.length})
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from "@mantine/core";
|
||||
import {
|
||||
Stack,
|
||||
Card,
|
||||
Box,
|
||||
Text,
|
||||
Badge,
|
||||
Group,
|
||||
Divider,
|
||||
ScrollArea,
|
||||
Button,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { detectFileExtension, getFileSize } from "@app/utils/fileUtils";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
@@ -14,31 +24,43 @@ interface FileInfoCardProps {
|
||||
modalHeight: string;
|
||||
}
|
||||
|
||||
const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight }) => {
|
||||
const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
currentFile,
|
||||
modalHeight,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { onMakeCopy } = useFileManagerContext();
|
||||
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
||||
const isSharedWithYou = useMemo(() => {
|
||||
if (!currentFile) return false;
|
||||
return currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink;
|
||||
return (
|
||||
currentFile.remoteOwnedByCurrentUser === false ||
|
||||
currentFile.remoteSharedViaLink
|
||||
);
|
||||
}, [currentFile]);
|
||||
const isOwnedRemote = useMemo(() => {
|
||||
if (!currentFile) return false;
|
||||
return Boolean(currentFile.remoteStorageId) && currentFile.remoteOwnedByCurrentUser !== false;
|
||||
return (
|
||||
Boolean(currentFile.remoteStorageId) &&
|
||||
currentFile.remoteOwnedByCurrentUser !== false
|
||||
);
|
||||
}, [currentFile]);
|
||||
const localUpdatedAt = currentFile?.createdAt ?? currentFile?.lastModified ?? 0;
|
||||
const localUpdatedAt =
|
||||
currentFile?.createdAt ?? currentFile?.lastModified ?? 0;
|
||||
const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0;
|
||||
const isUploaded = Boolean(currentFile?.remoteStorageId);
|
||||
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||
const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote;
|
||||
const isLocalOnly = !currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink;
|
||||
const isLocalOnly =
|
||||
!currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink;
|
||||
const isSharedByYou = useMemo(() => {
|
||||
if (!currentFile) return false;
|
||||
return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks);
|
||||
}, [currentFile, isOwnedRemote]);
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const sharingEnabled =
|
||||
uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const ownerLabel = useMemo(() => {
|
||||
if (!currentFile) return "";
|
||||
if (currentFile.remoteOwnerUsername) {
|
||||
@@ -56,7 +78,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
||||
withBorder
|
||||
p={0}
|
||||
mah={`calc(${modalHeight} * 0.45)`}
|
||||
style={{ overflow: "hidden", flexShrink: 1, display: "flex", flexDirection: "column" }}
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
flexShrink: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
bg="gray.4"
|
||||
@@ -75,9 +102,16 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
<PrivateContent>{t("fileManager.fileName", "Name")}</PrivateContent>
|
||||
<PrivateContent>
|
||||
{t("fileManager.fileName", "Name")}
|
||||
</PrivateContent>
|
||||
</Text>
|
||||
<Text size="sm" fw={500} style={{ maxWidth: "60%", textAlign: "right" }} truncate>
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{ maxWidth: "60%", textAlign: "right" }}
|
||||
truncate
|
||||
>
|
||||
{currentFile ? currentFile.name : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -112,7 +146,9 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
||||
{t("fileManager.lastModified", "Last modified")}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ""}
|
||||
{currentFile
|
||||
? new Date(currentFile.lastModified).toLocaleDateString()
|
||||
: ""}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
@@ -122,7 +158,11 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
||||
{t("fileManager.fileVersion", "Version")}
|
||||
</Text>
|
||||
{currentFile && (
|
||||
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? "blue" : "gray"}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={currentFile?.versionNumber ? "blue" : "gray"}
|
||||
>
|
||||
v{currentFile ? currentFile.versionNumber || 1 : ""}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -154,7 +194,11 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("fileManager.toolChain", "Tools Applied")}
|
||||
</Text>
|
||||
<ToolChain toolChain={currentFile.toolHistory} displayStyle="badges" size="xs" />
|
||||
<ToolChain
|
||||
toolChain={currentFile.toolHistory}
|
||||
displayStyle="badges"
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
@@ -162,7 +206,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
||||
{currentFile && isSharedWithYou && (
|
||||
<>
|
||||
<Divider />
|
||||
<Button size="sm" variant="light" onClick={() => onMakeCopy(currentFile)} fullWidth>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => onMakeCopy(currentFile)}
|
||||
fullWidth
|
||||
>
|
||||
{t("fileManager.makeCopy", "Make a copy")}
|
||||
</Button>
|
||||
</>
|
||||
@@ -177,7 +226,10 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
||||
</Text>
|
||||
{uploadEnabled && isOutOfSync ? (
|
||||
<Badge size="xs" variant="light" color="yellow">
|
||||
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
|
||||
{t(
|
||||
"fileManager.changesNotUploaded",
|
||||
"Changes not uploaded",
|
||||
)}
|
||||
</Badge>
|
||||
) : uploadEnabled ? (
|
||||
<Badge size="xs" variant="light" color="teal">
|
||||
@@ -206,7 +258,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
||||
{t("fileManager.sharedByYou", "Shared by you")}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Button size="sm" variant="light" onClick={() => setShowShareManageModal(true)} fullWidth>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="light"
|
||||
onClick={() => setShowShareManageModal(true)}
|
||||
fullWidth
|
||||
>
|
||||
{t("storageShare.manage", "Manage sharing")}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -12,7 +12,10 @@ interface FileListAreaProps {
|
||||
scrollAreaStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const FileListArea: React.FC<FileListAreaProps> = ({ scrollAreaHeight, scrollAreaStyle = {} }) => {
|
||||
const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
scrollAreaHeight,
|
||||
scrollAreaStyle = {},
|
||||
}) => {
|
||||
const {
|
||||
activeSource,
|
||||
recentFiles,
|
||||
@@ -94,9 +97,14 @@ const FileListArea: React.FC<FileListAreaProps> = ({ scrollAreaHeight, scrollAre
|
||||
return (
|
||||
<Center style={{ height: "12.5rem" }}>
|
||||
<Stack align="center" gap="sm">
|
||||
<CloudIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
|
||||
<CloudIcon
|
||||
style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }}
|
||||
/>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("fileManager.googleDriveNotAvailable", "Google Drive integration coming soon")}
|
||||
{t(
|
||||
"fileManager.googleDriveNotAvailable",
|
||||
"Google Drive integration coming soon",
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from "@mantine/core";
|
||||
import {
|
||||
Group,
|
||||
Box,
|
||||
Text,
|
||||
ActionIcon,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Menu,
|
||||
Badge,
|
||||
} from "@mantine/core";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
@@ -60,14 +69,21 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { config } = useAppConfig();
|
||||
const { expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext();
|
||||
const {
|
||||
expandedFileIds,
|
||||
onToggleExpansion,
|
||||
onUnzipFile,
|
||||
refreshRecentFiles,
|
||||
} = useFileManagerContext();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
// Check if this is a ZIP file
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
// Check file extension
|
||||
const extLower = (file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || "").toLowerCase();
|
||||
const extLower = (
|
||||
file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || ""
|
||||
).toLowerCase();
|
||||
const isCBZ = extLower === "cbz";
|
||||
const isCBR = extLower === "cbr";
|
||||
|
||||
@@ -75,33 +91,55 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
|
||||
// Get version information for this file
|
||||
const leafFileId = (isLatestVersion ? file.id : file.originalFileId || file.id) as FileId;
|
||||
const leafFileId = (
|
||||
isLatestVersion ? file.id : file.originalFileId || file.id
|
||||
) as FileId;
|
||||
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
||||
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
||||
const isExpanded = expandedFileIds.has(leafFileId);
|
||||
const uploadEnabled = config?.storageEnabled === true;
|
||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
const sharingEnabled =
|
||||
uploadEnabled && config?.storageSharingEnabled === true;
|
||||
const shareLinksEnabled =
|
||||
sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
||||
const isSharedWithYou = sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
|
||||
const isSharedWithYou =
|
||||
sharingEnabled &&
|
||||
(file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
|
||||
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
||||
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
||||
const isUploaded = Boolean(file.remoteStorageId);
|
||||
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
|
||||
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
|
||||
const accessRole = (isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase();
|
||||
const hasReadAccess = isOwnedOrLocal || accessRole === "editor" || accessRole === "commenter" || accessRole === "viewer";
|
||||
const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate);
|
||||
const accessRole = (
|
||||
isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")
|
||||
).toLowerCase();
|
||||
const hasReadAccess =
|
||||
isOwnedOrLocal ||
|
||||
accessRole === "editor" ||
|
||||
accessRole === "commenter" ||
|
||||
accessRole === "viewer";
|
||||
const canUpload =
|
||||
uploadEnabled &&
|
||||
isOwnedOrLocal &&
|
||||
isLatestVersion &&
|
||||
(!isUploaded || !isUpToDate);
|
||||
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
|
||||
const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
|
||||
const canCopyShareLink = shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId);
|
||||
const canManageShare =
|
||||
sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
|
||||
const canCopyShareLink =
|
||||
shareLinksEnabled &&
|
||||
Boolean(file.remoteHasShareLinks) &&
|
||||
Boolean(file.remoteStorageId);
|
||||
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
|
||||
|
||||
const shareBaseUrl = useMemo(() => {
|
||||
const frontendUrl = (config?.frontendUrl || "").trim();
|
||||
if (frontendUrl) {
|
||||
const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl;
|
||||
const normalized = frontendUrl.endsWith("/")
|
||||
? frontendUrl.slice(0, -1)
|
||||
: frontendUrl;
|
||||
return `${normalized}/share/`;
|
||||
}
|
||||
return absoluteWithBasePath("/share/");
|
||||
@@ -110,10 +148,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const handleCopyShareLink = useCallback(async () => {
|
||||
if (!file.remoteStorageId) return;
|
||||
try {
|
||||
const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(
|
||||
`/api/v1/storage/files/${file.remoteStorageId}`,
|
||||
{ suppressErrorToast: true } as any,
|
||||
);
|
||||
const response = await apiClient.get<{
|
||||
shareLinks?: Array<{ token?: string }>;
|
||||
}>(`/api/v1/storage/files/${file.remoteStorageId}`, {
|
||||
suppressErrorToast: true,
|
||||
} as any);
|
||||
const links = response.data?.shareLinks ?? [];
|
||||
const token = links[links.length - 1]?.token;
|
||||
if (!token) {
|
||||
@@ -163,9 +202,13 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
MozUserSelect: "none",
|
||||
msUserSelect: "none",
|
||||
paddingLeft: isHistoryFile ? "2rem" : "0.75rem", // Indent history files
|
||||
borderLeft: isHistoryFile ? "3px solid var(--mantine-color-blue-4)" : "none", // Visual indicator for history
|
||||
borderLeft: isHistoryFile
|
||||
? "3px solid var(--mantine-color-blue-4)"
|
||||
: "none", // Visual indicator for history
|
||||
}}
|
||||
onClick={isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)}
|
||||
onClick={
|
||||
isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)
|
||||
}
|
||||
onDoubleClick={onDoubleClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
@@ -217,7 +260,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
{t("fileManager.sharedWithYou", "Shared with you")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== "editor" ? (
|
||||
{sharingEnabled &&
|
||||
isSharedWithYou &&
|
||||
accessRole &&
|
||||
accessRole !== "editor" ? (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{accessRole === "commenter"
|
||||
? t("storageShare.roleCommenter", "Commenter")
|
||||
@@ -228,15 +274,27 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
{t("fileManager.localOnly", "Local only")}
|
||||
</Badge>
|
||||
) : uploadEnabled && isOutOfSync ? (
|
||||
<Badge size="xs" variant="light" color="yellow" leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}
|
||||
>
|
||||
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
|
||||
</Badge>
|
||||
) : uploadEnabled && isUploaded ? (
|
||||
<Badge size="xs" variant="light" color="teal" leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}
|
||||
>
|
||||
{t("fileManager.synced", "Synced")}
|
||||
</Badge>
|
||||
) : null}
|
||||
{sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && (
|
||||
{sharingEnabled &&
|
||||
file.remoteOwnedByCurrentUser !== false &&
|
||||
file.remoteHasShareLinks && (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{t("fileManager.sharedByYou", "Shared by you")}
|
||||
</Badge>
|
||||
@@ -249,7 +307,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
|
||||
{/* Tool chain for processed files */}
|
||||
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||
<ToolChain toolChain={file.toolHistory} maxWidth={"150px"} displayStyle="text" size="xs" />
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
maxWidth={"150px"}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
@@ -368,7 +431,9 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
onToggleExpansion(leafFileId);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? t("fileManager.hideHistory", "Hide History") : t("fileManager.showHistory", "Show History")}
|
||||
{isExpanded
|
||||
? t("fileManager.hideHistory", "Hide History")
|
||||
: t("fileManager.showHistory", "Show History")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
@@ -436,7 +501,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
/>
|
||||
)}
|
||||
{canManageShare && (
|
||||
<ShareManagementModal opened={showShareManageModal} onClose={() => setShowShareManageModal(false)} file={file} />
|
||||
<ShareManagementModal
|
||||
opened={showShareManageModal}
|
||||
onClose={() => setShowShareManageModal(false)}
|
||||
file={file}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -32,10 +32,19 @@ const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => (
|
||||
/>
|
||||
);
|
||||
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = false }) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false,
|
||||
}) => {
|
||||
const {
|
||||
activeSource,
|
||||
onSourceChange,
|
||||
onLocalFileClick,
|
||||
onGoogleDriveSelect,
|
||||
onNewFilesSelect,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } =
|
||||
useGoogleDrivePicker();
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const UploadIcon = icons.upload;
|
||||
@@ -66,21 +75,29 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
|
||||
};
|
||||
|
||||
// Determine visibility of Google Drive button
|
||||
const shouldHideGoogleDrive = !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
|
||||
const shouldHideGoogleDrive =
|
||||
!isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
|
||||
|
||||
// Determine visibility of Mobile QR Scanner button
|
||||
const shouldHideMobileQR = !isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
|
||||
const shouldHideMobileQR =
|
||||
!isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
|
||||
|
||||
const buttonProps = {
|
||||
variant: (source: string) => (activeSource === source ? "filled" : "subtle"),
|
||||
getColor: (source: string) => (activeSource === source ? "var(--mantine-color-gray-2)" : undefined),
|
||||
variant: (source: string) =>
|
||||
activeSource === source ? "filled" : "subtle",
|
||||
getColor: (source: string) =>
|
||||
activeSource === source ? "var(--mantine-color-gray-2)" : undefined,
|
||||
getStyles: (source: string) => ({
|
||||
root: {
|
||||
backgroundColor: activeSource === source ? undefined : "transparent",
|
||||
color: activeSource === source ? "var(--mantine-color-gray-9)" : "var(--mantine-color-gray-6)",
|
||||
color:
|
||||
activeSource === source
|
||||
? "var(--mantine-color-gray-9)"
|
||||
: "var(--mantine-color-gray-6)",
|
||||
border: "none",
|
||||
"&:hover": {
|
||||
backgroundColor: activeSource === source ? undefined : "var(--mantine-color-gray-0)",
|
||||
backgroundColor:
|
||||
activeSource === source ? undefined : "var(--mantine-color-gray-0)",
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -97,7 +114,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
|
||||
color={buttonProps.getColor("recent")}
|
||||
styles={buttonProps.getStyles("recent")}
|
||||
>
|
||||
{horizontal ? t("fileManager.recent", "Recent") : t("fileManager.recent", "Recent")}
|
||||
{horizontal
|
||||
? t("fileManager.recent", "Recent")
|
||||
: t("fileManager.recent", "Recent")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -136,17 +155,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
"&:hover": {
|
||||
backgroundColor: isGoogleDriveEnabled ? "var(--mantine-color-gray-0)" : "transparent",
|
||||
backgroundColor: isGoogleDriveEnabled
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "transparent",
|
||||
},
|
||||
},
|
||||
}}
|
||||
title={
|
||||
!isGoogleDriveEnabled
|
||||
? t("fileManager.googleDriveNotAvailable", "Google Drive integration not available")
|
||||
? t(
|
||||
"fileManager.googleDriveNotAvailable",
|
||||
"Google Drive integration not available",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{horizontal ? t("fileManager.googleDriveShort", "Drive") : t("fileManager.googleDrive", "Google Drive")}
|
||||
{horizontal
|
||||
? t("fileManager.googleDriveShort", "Drive")
|
||||
: t("fileManager.googleDrive", "Google Drive")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -165,13 +191,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
"&:hover": {
|
||||
backgroundColor: isMobileUploadEnabled ? "var(--mantine-color-gray-0)" : "transparent",
|
||||
backgroundColor: isMobileUploadEnabled
|
||||
? "var(--mantine-color-gray-0)"
|
||||
: "transparent",
|
||||
},
|
||||
},
|
||||
}}
|
||||
title={!isMobileUploadEnabled ? t("fileManager.mobileUploadNotAvailable", "Mobile upload not available") : undefined}
|
||||
title={
|
||||
!isMobileUploadEnabled
|
||||
? t(
|
||||
"fileManager.mobileUploadNotAvailable",
|
||||
"Mobile upload not available",
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{horizontal ? t("fileManager.mobileShort", "Mobile") : t("fileManager.mobileUpload", "Mobile Upload")}
|
||||
{horizontal
|
||||
? t("fileManager.mobileShort", "Mobile")
|
||||
: t("fileManager.mobileUpload", "Mobile Upload")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -195,7 +232,14 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
|
||||
return (
|
||||
<>
|
||||
<Stack gap="xs" style={{ height: "100%" }}>
|
||||
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: "1rem" }}>
|
||||
<Text
|
||||
size="sm"
|
||||
pt="sm"
|
||||
fw={500}
|
||||
c="dimmed"
|
||||
mb="xs"
|
||||
style={{ paddingLeft: "1rem" }}
|
||||
>
|
||||
{t("fileManager.myFiles", "My Files")}
|
||||
</Text>
|
||||
{buttons}
|
||||
|
||||
@@ -27,7 +27,11 @@ const MobileLayout: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box h="100%" p="sm" style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<Box
|
||||
h="100%"
|
||||
p="sm"
|
||||
style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}
|
||||
>
|
||||
{/* Section 1: File Sources - Fixed at top */}
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<FileSourceButtons horizontal={true} />
|
||||
|
||||
@@ -23,7 +23,11 @@ const baseKeyStyle: React.CSSProperties = {
|
||||
color: "var(--mantine-color-text)",
|
||||
};
|
||||
|
||||
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = "sm", muted = false }) => {
|
||||
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({
|
||||
binding,
|
||||
size = "sm",
|
||||
muted = false,
|
||||
}) => {
|
||||
const { getDisplayParts } = useHotkeys();
|
||||
const parts = getDisplayParts(binding);
|
||||
|
||||
@@ -31,7 +35,10 @@ export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = "s
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyStyle = size === "md" ? { ...baseKeyStyle, fontSize: "0.85rem", padding: "0.2rem 0.5rem" } : baseKeyStyle;
|
||||
const keyStyle =
|
||||
size === "md"
|
||||
? { ...baseKeyStyle, fontSize: "0.85rem", padding: "0.2rem 0.5rem" }
|
||||
: baseKeyStyle;
|
||||
|
||||
return (
|
||||
<span
|
||||
|
||||
@@ -4,7 +4,11 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { useNavigationState, useNavigationActions, useNavigationGuard } from "@app/contexts/NavigationContext";
|
||||
import {
|
||||
useNavigationState,
|
||||
useNavigationActions,
|
||||
useNavigationGuard,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { isBaseWorkbench } from "@app/types/workbench";
|
||||
import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
@@ -102,7 +106,9 @@ export default function Workbench() {
|
||||
// Check if we're showing a custom workbench first
|
||||
// Custom workbenches may not require files in FileContext (e.g., sign request workbench)
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
const customView = customWorkbenchViews.find(
|
||||
(view) => view.workbenchId === currentView && view.data != null,
|
||||
);
|
||||
if (customView) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
@@ -148,7 +154,15 @@ export default function Workbench() {
|
||||
<div style={{ position: "relative", flex: "1 1 0", height: 0 }}>
|
||||
<PageEditor onFunctionsReady={setPageEditorFunctions} />
|
||||
{pageEditorFunctions && (
|
||||
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, zIndex: 100 }}>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<PageEditorControls
|
||||
onClosePdf={pageEditorFunctions.closePdf}
|
||||
onUndo={pageEditorFunctions.handleUndo}
|
||||
@@ -190,14 +204,20 @@ export default function Workbench() {
|
||||
}
|
||||
>
|
||||
{/* Top Controls */}
|
||||
{activeFiles.length > 0 && !customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && (
|
||||
{activeFiles.length > 0 &&
|
||||
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||
?.hideTopControls && (
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
customViews={customWorkbenchViews}
|
||||
activeFiles={activeFiles.map((f) => {
|
||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
||||
return {
|
||||
fileId: f.fileId,
|
||||
name: f.name,
|
||||
versionNumber: stub?.versionNumber,
|
||||
};
|
||||
})}
|
||||
currentFileIndex={activeFileIndex}
|
||||
onFileSelect={handleFileSelect}
|
||||
|
||||
@@ -2,7 +2,10 @@ import React from "react";
|
||||
import { Button, Group, ActionIcon } from "@mantine/core";
|
||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ButtonDefinition, type FlowState } from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import {
|
||||
ButtonDefinition,
|
||||
type FlowState,
|
||||
} from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import type { LicenseNotice } from "@app/types/types";
|
||||
import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
||||
|
||||
@@ -16,10 +19,19 @@ interface SlideButtonsProps {
|
||||
onAction: (action: ButtonAction) => void;
|
||||
}
|
||||
|
||||
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
|
||||
export function SlideButtons({
|
||||
slideDefinition,
|
||||
licenseNotice,
|
||||
flowState,
|
||||
onAction,
|
||||
}: SlideButtonsProps) {
|
||||
const { t } = useTranslation();
|
||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
|
||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
|
||||
const leftButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "left",
|
||||
);
|
||||
const rightButtons = slideDefinition.buttons.filter(
|
||||
(btn) => btn.group === "right",
|
||||
);
|
||||
|
||||
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||
variant === "primary"
|
||||
@@ -76,7 +88,9 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
||||
},
|
||||
}}
|
||||
>
|
||||
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
|
||||
{button.icon === "chevron-left" && (
|
||||
<ChevronLeftIcon fontSize="small" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +99,12 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
||||
const label = resolveButtonLabel(button);
|
||||
|
||||
return (
|
||||
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
|
||||
<Button
|
||||
key={button.key}
|
||||
onClick={() => onAction(button.action)}
|
||||
disabled={disabled}
|
||||
styles={buttonStyles(variant)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
@@ -6,11 +6,21 @@ import { isAuthRoute } from "@app/constants/routes";
|
||||
import { dispatchTourState } from "@app/constants/events";
|
||||
import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator";
|
||||
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
|
||||
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from "@app/components/onboarding/OnboardingTour";
|
||||
import OnboardingTour, {
|
||||
type AdvanceArgs,
|
||||
type CloseArgs,
|
||||
} from "@app/components/onboarding/OnboardingTour";
|
||||
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
|
||||
import { useServerLicenseRequest, useTourRequest } from "@app/components/onboarding/useOnboardingEffects";
|
||||
import {
|
||||
useServerLicenseRequest,
|
||||
useTourRequest,
|
||||
} from "@app/components/onboarding/useOnboardingEffects";
|
||||
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
|
||||
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import {
|
||||
SLIDE_DEFINITIONS,
|
||||
type SlideId,
|
||||
type ButtonAction,
|
||||
} from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
||||
import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext";
|
||||
import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
@@ -36,9 +46,18 @@ export default function Onboarding() {
|
||||
const onAuthRoute = isAuthRoute(location.pathname);
|
||||
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
|
||||
|
||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
||||
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
||||
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
|
||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } =
|
||||
useOnboardingDownload();
|
||||
const {
|
||||
showLicenseSlide,
|
||||
licenseNotice: externalLicenseNotice,
|
||||
closeLicenseSlide,
|
||||
} = useServerLicenseRequest();
|
||||
const {
|
||||
tourRequested: externalTourRequested,
|
||||
requestedTourType,
|
||||
clearTourRequest,
|
||||
} = useTourRequest();
|
||||
const { config, refetch: refetchConfig } = useAppConfig();
|
||||
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
|
||||
const [analyticsLoading, setAnalyticsLoading] = useState(false);
|
||||
@@ -75,10 +94,20 @@ export default function Onboarding() {
|
||||
|
||||
// Check if we should show analytics modal before onboarding
|
||||
useEffect(() => {
|
||||
if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) {
|
||||
if (
|
||||
!isLoading &&
|
||||
!analyticsModalDismissed &&
|
||||
serverExperience.effectiveIsAdmin &&
|
||||
config?.enableAnalytics == null
|
||||
) {
|
||||
setShowAnalyticsModal(true);
|
||||
}
|
||||
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
|
||||
}, [
|
||||
isLoading,
|
||||
analyticsModalDismissed,
|
||||
serverExperience.effectiveIsAdmin,
|
||||
config?.enableAnalytics,
|
||||
]);
|
||||
|
||||
const handleAnalyticsChoice = useCallback(
|
||||
async (enableAnalytics: boolean) => {
|
||||
@@ -90,12 +119,17 @@ export default function Onboarding() {
|
||||
formData.append("enabled", enableAnalytics.toString());
|
||||
|
||||
try {
|
||||
await apiClient.post("/api/v1/settings/update-enable-analytics", formData);
|
||||
await apiClient.post(
|
||||
"/api/v1/settings/update-enable-analytics",
|
||||
formData,
|
||||
);
|
||||
await refetchConfig();
|
||||
setShowAnalyticsModal(false);
|
||||
setAnalyticsModalDismissed(true);
|
||||
} catch (error) {
|
||||
setAnalyticsError(error instanceof Error ? error.message : "Unknown error");
|
||||
setAnalyticsError(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
} finally {
|
||||
setAnalyticsLoading(false);
|
||||
}
|
||||
@@ -137,7 +171,11 @@ export default function Onboarding() {
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
case "launch-auto": {
|
||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === "admin" ? "admin" : "whatsnew";
|
||||
const tourType =
|
||||
serverExperience.effectiveIsAdmin ||
|
||||
runtimeState.selectedRole === "admin"
|
||||
? "admin"
|
||||
: "whatsnew";
|
||||
actions.updateRuntimeState({ tourType });
|
||||
setIsTourOpen(true);
|
||||
break;
|
||||
@@ -170,7 +208,10 @@ export default function Onboarding() {
|
||||
],
|
||||
);
|
||||
|
||||
const isRTL = typeof document !== "undefined" ? document.documentElement.dir === "rtl" : false;
|
||||
const isRTL =
|
||||
typeof document !== "undefined"
|
||||
? document.documentElement.dir === "rtl"
|
||||
: false;
|
||||
const [isTourOpen, setIsTourOpen] = useState(false);
|
||||
|
||||
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
|
||||
@@ -241,7 +282,12 @@ export default function Onboarding() {
|
||||
default:
|
||||
return Object.values(userStepsConfig);
|
||||
}
|
||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]);
|
||||
}, [
|
||||
adminStepsConfig,
|
||||
runtimeState.tourType,
|
||||
userStepsConfig,
|
||||
whatsNewStepsConfig,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalTourRequested) {
|
||||
@@ -290,7 +336,12 @@ export default function Onboarding() {
|
||||
|
||||
const handleAdvanceTour = useCallback(
|
||||
(args: AdvanceArgs) => {
|
||||
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
||||
const {
|
||||
setCurrentStep,
|
||||
currentStep: tourCurrentStep,
|
||||
steps,
|
||||
setIsOpen,
|
||||
} = args;
|
||||
if (steps && tourCurrentStep === steps.length - 1) {
|
||||
setIsOpen(false);
|
||||
finishTour();
|
||||
@@ -310,7 +361,11 @@ export default function Onboarding() {
|
||||
);
|
||||
|
||||
const currentSlideDefinition = useMemo(() => {
|
||||
if (!currentStep || currentStep.type !== "modal-slide" || !currentStep.slideId) {
|
||||
if (
|
||||
!currentStep ||
|
||||
currentStep.type !== "modal-slide" ||
|
||||
!currentStep.slideId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
|
||||
@@ -356,7 +411,9 @@ export default function Onboarding() {
|
||||
|
||||
const currentModalSlideIndex = useMemo(() => {
|
||||
if (!currentStep || currentStep.type !== "modal-slide") return 0;
|
||||
const modalSlides = activeFlow.filter((step) => step.type === "modal-slide");
|
||||
const modalSlides = activeFlow.filter(
|
||||
(step) => step.type === "modal-slide",
|
||||
);
|
||||
return modalSlides.findIndex((step) => step.id === currentStep.id);
|
||||
}, [activeFlow, currentStep]);
|
||||
|
||||
@@ -464,9 +521,12 @@ export default function Onboarding() {
|
||||
// Remove back button for external license notice
|
||||
const slideDefinition = {
|
||||
...baseSlideDefinition,
|
||||
buttons: baseSlideDefinition.buttons.filter((btn) => btn.key !== "license-back"),
|
||||
buttons: baseSlideDefinition.buttons.filter(
|
||||
(btn) => btn.key !== "license-back",
|
||||
),
|
||||
};
|
||||
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
||||
const effectiveLicenseNotice =
|
||||
externalLicenseNotice || runtimeState.licenseNotice;
|
||||
const slideContent = slideDefinition.createSlide({
|
||||
osLabel: "",
|
||||
osUrl: "",
|
||||
@@ -482,7 +542,10 @@ export default function Onboarding() {
|
||||
<OnboardingModalSlide
|
||||
slideDefinition={slideDefinition}
|
||||
slideContent={slideContent}
|
||||
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }}
|
||||
runtimeState={{
|
||||
...runtimeState,
|
||||
licenseNotice: effectiveLicenseNotice,
|
||||
}}
|
||||
modalSlideCount={1}
|
||||
currentModalSlideIndex={0}
|
||||
onSkip={closeLicenseSlide}
|
||||
@@ -524,7 +587,9 @@ export default function Onboarding() {
|
||||
// Render the current onboarding step
|
||||
switch (currentStep.type) {
|
||||
case "tool-prompt":
|
||||
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
||||
return (
|
||||
<ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />
|
||||
);
|
||||
|
||||
case "modal-slide":
|
||||
if (!currentSlideDefinition || !currentSlideContent) return null;
|
||||
|
||||
@@ -10,7 +10,10 @@ import { Modal, Stack, ActionIcon } from "@mantine/core";
|
||||
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
import type { SlideDefinition, ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import type {
|
||||
SlideDefinition,
|
||||
ButtonAction,
|
||||
} from "@app/components/onboarding/onboardingFlowConfig";
|
||||
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
|
||||
import type { SlideConfig } from "@app/types/types";
|
||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||
@@ -47,7 +50,11 @@ export default function OnboardingModalSlide({
|
||||
return (
|
||||
<div className={styles.heroIconsContainer}>
|
||||
<div className={styles.iconWrapper}>
|
||||
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
|
||||
<img
|
||||
src={`${BASE_PATH}/modern-logo/logo512.png`}
|
||||
alt="Stirling icon"
|
||||
className={styles.downloadIcon}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -56,20 +63,45 @@ export default function OnboardingModalSlide({
|
||||
return (
|
||||
<div className={styles.heroLogoCircle}>
|
||||
{slideDefinition.hero.type === "rocket" && (
|
||||
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
||||
<LocalIcon
|
||||
icon="rocket-launch"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
)}
|
||||
{slideDefinition.hero.type === "shield" && (
|
||||
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
<LocalIcon
|
||||
icon="verified-user-outline"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
)}
|
||||
{slideDefinition.hero.type === "lock" && (
|
||||
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
||||
<LocalIcon
|
||||
icon="lock-outline"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
)}
|
||||
{slideDefinition.hero.type === "analytics" && (
|
||||
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
|
||||
<LocalIcon
|
||||
icon="analytics"
|
||||
width={64}
|
||||
height={64}
|
||||
className={styles.heroIcon}
|
||||
/>
|
||||
)}
|
||||
{slideDefinition.hero.type === "diamond" && (
|
||||
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
|
||||
)}
|
||||
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
|
||||
{slideDefinition.hero.type === "logo" && (
|
||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
||||
<img
|
||||
src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`}
|
||||
alt="Stirling logo"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -88,7 +120,12 @@ export default function OnboardingModalSlide({
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
styles={{
|
||||
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
|
||||
content: { overflow: "hidden", border: "none", background: "var(--bg-surface)", maxHeight: "90vh" },
|
||||
content: {
|
||||
overflow: "hidden",
|
||||
border: "none",
|
||||
background: "var(--bg-surface)",
|
||||
maxHeight: "90vh",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap={0} className={styles.modalContent}>
|
||||
@@ -129,20 +166,34 @@ export default function OnboardingModalSlide({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.modalBody} style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}>
|
||||
<div
|
||||
className={styles.modalBody}
|
||||
style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}
|
||||
>
|
||||
<Stack gap={16}>
|
||||
<div key={`title-${slideContent.key}`} className={`${styles.title} ${styles.titleText}`}>
|
||||
<div
|
||||
key={`title-${slideContent.key}`}
|
||||
className={`${styles.title} ${styles.titleText}`}
|
||||
>
|
||||
{slideContent.title}
|
||||
</div>
|
||||
|
||||
<div className={styles.bodyText}>
|
||||
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
||||
<div
|
||||
key={`body-${slideContent.key}`}
|
||||
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
|
||||
>
|
||||
{slideContent.body}
|
||||
</div>
|
||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||
</div>
|
||||
|
||||
{modalSlideCount > 1 && <OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />}
|
||||
{modalSlideCount > 1 && (
|
||||
<OnboardingStepper
|
||||
totalSteps={modalSlideCount}
|
||||
activeStep={currentModalSlideIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<SlideButtons
|
||||
|
||||
@@ -10,7 +10,11 @@ interface OnboardingStepperProps {
|
||||
* Renders a progress indicator where the active step is a pill and others are dots.
|
||||
* Colors come from theme.css variables.
|
||||
*/
|
||||
export function OnboardingStepper({ totalSteps, activeStep, className }: OnboardingStepperProps) {
|
||||
export function OnboardingStepper({
|
||||
totalSteps,
|
||||
activeStep,
|
||||
className,
|
||||
}: OnboardingStepperProps) {
|
||||
const items = Array.from({ length: totalSteps }, (_, index) => index);
|
||||
|
||||
return (
|
||||
@@ -26,7 +30,9 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
|
||||
{items.map((index) => {
|
||||
const isActive = index === activeStep;
|
||||
const baseStyles: React.CSSProperties = {
|
||||
background: isActive ? "var(--onboarding-step-active)" : "var(--onboarding-step-inactive)",
|
||||
background: isActive
|
||||
? "var(--onboarding-step-active)"
|
||||
: "var(--onboarding-step-inactive)",
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -57,7 +57,15 @@ interface OnboardingTourProps {
|
||||
onClose: (args: CloseArgs) => void;
|
||||
}
|
||||
|
||||
export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, onAdvance, onClose }: OnboardingTourProps) {
|
||||
export default function OnboardingTour({
|
||||
tourSteps,
|
||||
tourType,
|
||||
isRTL,
|
||||
t,
|
||||
isOpen,
|
||||
onAdvance,
|
||||
onClose,
|
||||
}: OnboardingTourProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -111,15 +119,31 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
|
||||
disableInteraction={true}
|
||||
disableDotsNavigation={false}
|
||||
prevButton={() => null}
|
||||
nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
||||
nextButton={({
|
||||
currentStep: tourCurrentStep,
|
||||
stepsLength,
|
||||
setCurrentStep,
|
||||
setIsOpen,
|
||||
}) => {
|
||||
const isLast = tourCurrentStep === stepsLength - 1;
|
||||
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
|
||||
onClick={() =>
|
||||
onAdvance({
|
||||
setCurrentStep,
|
||||
currentStep: tourCurrentStep,
|
||||
steps: tourSteps,
|
||||
setIsOpen,
|
||||
})
|
||||
}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label={isLast ? t("onboarding.finish", "Finish") : t("onboarding.next", "Next")}
|
||||
aria-label={
|
||||
isLast
|
||||
? t("onboarding.finish", "Finish")
|
||||
: t("onboarding.next", "Next")
|
||||
}
|
||||
>
|
||||
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
||||
</ActionIcon>
|
||||
@@ -127,10 +151,17 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
|
||||
}}
|
||||
components={{
|
||||
Close: ({ onClick }) => (
|
||||
<CloseButton onClick={onClick} size="md" style={{ position: "absolute", top: "8px", right: "8px" }} />
|
||||
<CloseButton
|
||||
onClick={onClick}
|
||||
size="md"
|
||||
style={{ position: "absolute", top: "8px", right: "8px" }}
|
||||
/>
|
||||
),
|
||||
Content: ({ content }: { content: string }) => (
|
||||
<div style={{ paddingRight: "16px" }} dangerouslySetInnerHTML={{ __html: content }} />
|
||||
<div
|
||||
style={{ paddingRight: "16px" }}
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { StepType } from "@reactour/tour";
|
||||
import type { TFunction } from "i18next";
|
||||
import { addGlowToElements, removeAllGlows } from "@app/components/onboarding/tourGlow";
|
||||
import {
|
||||
addGlowToElements,
|
||||
removeAllGlows,
|
||||
} from "@app/components/onboarding/tourGlow";
|
||||
|
||||
export enum AdminTourStep {
|
||||
WELCOME,
|
||||
@@ -26,8 +29,16 @@ interface CreateAdminStepsConfigArgs {
|
||||
actions: AdminStepActions;
|
||||
}
|
||||
|
||||
export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
|
||||
const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions;
|
||||
export function createAdminStepsConfig({
|
||||
t,
|
||||
actions,
|
||||
}: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
|
||||
const {
|
||||
saveAdminState,
|
||||
openConfigModal,
|
||||
navigateToSection,
|
||||
scrollNavToSection,
|
||||
} = actions;
|
||||
|
||||
return {
|
||||
[AdminTourStep.WELCOME]: {
|
||||
@@ -120,7 +131,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
||||
},
|
||||
[AdminTourStep.DATABASE_SECTION]: {
|
||||
selector: '[data-tour="admin-adminDatabase-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
|
||||
highlightedSelectors: [
|
||||
'[data-tour="admin-adminDatabase-nav"]',
|
||||
'[data-tour="settings-content-area"]',
|
||||
],
|
||||
content: t(
|
||||
"adminOnboarding.databaseSection",
|
||||
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
|
||||
@@ -131,13 +145,19 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
||||
removeAllGlows();
|
||||
navigateToSection("adminDatabase");
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
|
||||
addGlowToElements([
|
||||
'[data-tour="admin-adminDatabase-nav"]',
|
||||
'[data-tour="settings-content-area"]',
|
||||
]);
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
[AdminTourStep.CONNECTIONS_SECTION]: {
|
||||
selector: '[data-tour="admin-adminConnections-nav"]',
|
||||
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
|
||||
highlightedSelectors: [
|
||||
'[data-tour="admin-adminConnections-nav"]',
|
||||
'[data-tour="settings-content-area"]',
|
||||
],
|
||||
content: t(
|
||||
"adminOnboarding.connectionsSection",
|
||||
"The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
|
||||
@@ -148,7 +168,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
||||
removeAllGlows();
|
||||
navigateToSection("adminConnections");
|
||||
setTimeout(() => {
|
||||
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
|
||||
addGlowToElements([
|
||||
'[data-tour="admin-adminConnections-nav"]',
|
||||
'[data-tour="settings-content-area"]',
|
||||
]);
|
||||
}, 100);
|
||||
},
|
||||
actionAfter: async () => {
|
||||
|
||||
@@ -20,7 +20,14 @@ export type SlideId =
|
||||
| "analytics-choice"
|
||||
| "mfa-setup";
|
||||
|
||||
export type HeroType = "rocket" | "dual-icon" | "shield" | "diamond" | "logo" | "lock" | "analytics";
|
||||
export type HeroType =
|
||||
| "rocket"
|
||||
| "dual-icon"
|
||||
| "shield"
|
||||
| "diamond"
|
||||
| "logo"
|
||||
| "lock"
|
||||
| "analytics";
|
||||
|
||||
export type ButtonAction =
|
||||
| "next"
|
||||
@@ -91,7 +98,11 @@ export interface SlideDefinition {
|
||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
"first-login": {
|
||||
id: "first-login",
|
||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
||||
createSlide: ({
|
||||
firstLoginUsername,
|
||||
onPasswordChanged,
|
||||
usingDefaultCredentials,
|
||||
}) =>
|
||||
FirstLoginSlide({
|
||||
username: firstLoginUsername || "",
|
||||
onPasswordChanged: onPasswordChanged || (() => {}),
|
||||
@@ -148,7 +159,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
"security-check": {
|
||||
id: "security-check",
|
||||
createSlide: ({ selectedRole, onRoleSelect }) => SecurityCheckSlide({ selectedRole, onRoleSelect }),
|
||||
createSlide: ({ selectedRole, onRoleSelect }) =>
|
||||
SecurityCheckSlide({ selectedRole, onRoleSelect }),
|
||||
hero: { type: "shield" },
|
||||
buttons: [
|
||||
{
|
||||
@@ -172,7 +184,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
"admin-overview": {
|
||||
id: "admin-overview",
|
||||
createSlide: ({ licenseNotice, loginEnabled }) => PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
|
||||
createSlide: ({ licenseNotice, loginEnabled }) =>
|
||||
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
|
||||
hero: { type: "diamond" },
|
||||
buttons: [
|
||||
{
|
||||
@@ -262,7 +275,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
"analytics-choice": {
|
||||
id: "analytics-choice",
|
||||
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
|
||||
createSlide: ({ analyticsError }) =>
|
||||
AnalyticsChoiceSlide({ analyticsError }),
|
||||
hero: { type: "analytics" },
|
||||
buttons: [
|
||||
{
|
||||
@@ -285,7 +299,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||
},
|
||||
"mfa-setup": {
|
||||
id: "mfa-setup",
|
||||
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }),
|
||||
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) =>
|
||||
MFASetupSlide({ onMfaSetupComplete }),
|
||||
hero: { type: "lock" },
|
||||
buttons: [], // Form has its own submit button
|
||||
},
|
||||
|
||||
@@ -115,13 +115,15 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
id: "tour-overview",
|
||||
type: "modal-slide",
|
||||
slideId: "tour-overview",
|
||||
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
|
||||
condition: (ctx) =>
|
||||
!ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
|
||||
},
|
||||
{
|
||||
id: "server-license",
|
||||
type: "modal-slide",
|
||||
slideId: "server-license",
|
||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
||||
condition: (ctx) =>
|
||||
ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
||||
},
|
||||
{
|
||||
id: "mfa-setup",
|
||||
|
||||
@@ -16,7 +16,10 @@ export function markOnboardingCompleted(): void {
|
||||
try {
|
||||
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
|
||||
} catch (error) {
|
||||
console.error("[onboardingStorage] Error marking onboarding as completed:", error);
|
||||
console.error(
|
||||
"[onboardingStorage] Error marking onboarding as completed:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +28,10 @@ export function resetOnboardingProgress(): void {
|
||||
try {
|
||||
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
|
||||
} catch (error) {
|
||||
console.error("[onboardingStorage] Error resetting onboarding progress:", error);
|
||||
console.error(
|
||||
"[onboardingStorage] Error resetting onboarding progress:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +49,10 @@ export function markToursTooltipShown(): void {
|
||||
try {
|
||||
localStorage.setItem(TOURS_TOOLTIP_KEY, "true");
|
||||
} catch (error) {
|
||||
console.error("[onboardingStorage] Error marking tours tooltip as shown:", error);
|
||||
console.error(
|
||||
"[onboardingStorage] Error marking tours tooltip as shown:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +70,10 @@ export function migrateFromLegacyPreferences(): void {
|
||||
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
||||
|
||||
// If user had completed onboarding in old system, mark new system as complete
|
||||
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) {
|
||||
if (
|
||||
prefs.hasCompletedOnboarding === true ||
|
||||
prefs.hasSeenIntroOnboarding === true
|
||||
) {
|
||||
markOnboardingCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,19 +31,27 @@ function hasAuthToken(): boolean {
|
||||
}
|
||||
|
||||
// Get initial runtime state from session storage (survives remounts)
|
||||
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
|
||||
function getInitialRuntimeState(
|
||||
baseState: OnboardingRuntimeState,
|
||||
): OnboardingRuntimeState {
|
||||
if (typeof window === "undefined") {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
try {
|
||||
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
|
||||
const tourRequested =
|
||||
sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
|
||||
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
|
||||
const tourType =
|
||||
sessionTourType === "admin" || sessionTourType === "tools" || sessionTourType === "whatsnew"
|
||||
sessionTourType === "admin" ||
|
||||
sessionTourType === "tools" ||
|
||||
sessionTourType === "whatsnew"
|
||||
? sessionTourType
|
||||
: "whatsnew";
|
||||
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as "admin" | "user" | null;
|
||||
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as
|
||||
| "admin"
|
||||
| "user"
|
||||
| null;
|
||||
|
||||
return {
|
||||
...baseState,
|
||||
@@ -61,7 +69,10 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
|
||||
try {
|
||||
if (state.tourRequested !== undefined) {
|
||||
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? "true" : "false");
|
||||
sessionStorage.setItem(
|
||||
SESSION_TOUR_REQUESTED,
|
||||
state.tourRequested ? "true" : "false",
|
||||
);
|
||||
}
|
||||
if (state.tourType !== undefined) {
|
||||
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
|
||||
@@ -74,7 +85,10 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[useOnboardingOrchestrator] Error persisting runtime state:", error);
|
||||
console.error(
|
||||
"[useOnboardingOrchestrator] Error persisting runtime state:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +111,10 @@ function parseMfaRequired(settings: string | null | undefined): boolean {
|
||||
const parsed = JSON.parse(settings) as { mfaRequired?: string };
|
||||
return parsed.mfaRequired?.toLowerCase() === "true";
|
||||
} catch (error) {
|
||||
console.warn("[useOnboardingOrchestrator] Failed to parse account settings JSON:", error);
|
||||
console.warn(
|
||||
"[useOnboardingOrchestrator] Failed to parse account settings JSON:",
|
||||
error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -152,14 +169,18 @@ export interface UseOnboardingOrchestratorOptions {
|
||||
defaultRuntimeState?: OnboardingRuntimeState;
|
||||
}
|
||||
|
||||
export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOptions): UseOnboardingOrchestratorResult {
|
||||
export function useOnboardingOrchestrator(
|
||||
options?: UseOnboardingOrchestratorOptions,
|
||||
): UseOnboardingOrchestratorResult {
|
||||
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
|
||||
const serverExperience = useServerExperience();
|
||||
const { config, loading: configLoading } = useAppConfig();
|
||||
const location = useLocation();
|
||||
const bypassOnboarding = useBypassOnboarding();
|
||||
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() => getInitialRuntimeState(defaultState));
|
||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||
getInitialRuntimeState(defaultState),
|
||||
);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
||||
@@ -186,7 +207,8 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
requiresLicense:
|
||||
!serverExperience.hasPaidLicense &&
|
||||
(serverExperience.overFreeTierLimit === true ||
|
||||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)),
|
||||
(serverExperience.effectiveIsAdmin &&
|
||||
serverExperience.userCountResolved)),
|
||||
},
|
||||
}));
|
||||
}, [
|
||||
@@ -217,7 +239,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
requiresMfaSetup: parseMfaRequired(accountData.settings),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.log("[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:", error);
|
||||
console.log(
|
||||
"[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:",
|
||||
error,
|
||||
);
|
||||
// Account endpoint failed - user not logged in or security disabled
|
||||
}
|
||||
};
|
||||
@@ -227,17 +252,25 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
}
|
||||
}, [config?.enableLogin, configLoading]);
|
||||
|
||||
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
|
||||
const isOnAuthRoute = AUTH_ROUTES.some((route) =>
|
||||
location.pathname.startsWith(route),
|
||||
);
|
||||
const loginEnabled = config?.enableLogin === true;
|
||||
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
||||
const shouldBlockOnboarding = bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
|
||||
const shouldBlockOnboarding =
|
||||
bypassOnboarding ||
|
||||
isOnAuthRoute ||
|
||||
configLoading ||
|
||||
isUnauthenticatedWithLoginEnabled;
|
||||
|
||||
const conditionContext = useMemo<OnboardingConditionContext>(
|
||||
() => ({
|
||||
...serverExperience,
|
||||
...runtimeState,
|
||||
effectiveIsAdmin:
|
||||
serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === "admin"),
|
||||
serverExperience.effectiveIsAdmin ||
|
||||
(!serverExperience.loginEnabled &&
|
||||
runtimeState.selectedRole === "admin"),
|
||||
}),
|
||||
[serverExperience, runtimeState],
|
||||
);
|
||||
@@ -248,7 +281,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
|
||||
// Wait for config AND admin status before calculating initial step
|
||||
const adminStatusResolved =
|
||||
!configLoading && (config?.enableLogin === false || config?.enableLogin === undefined || config?.isAdmin !== undefined);
|
||||
!configLoading &&
|
||||
(config?.enableLogin === false ||
|
||||
config?.enableLogin === undefined ||
|
||||
config?.isAdmin !== undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (configLoading || !adminStatusResolved) return;
|
||||
@@ -276,9 +312,21 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
|
||||
const totalSteps = activeFlow.length;
|
||||
|
||||
const isComplete = isInitialized && (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
|
||||
const currentStep = currentStepIndex >= 0 && currentStepIndex < totalSteps ? activeFlow[currentStepIndex] : null;
|
||||
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
|
||||
const isComplete =
|
||||
isInitialized &&
|
||||
(totalSteps === 0 ||
|
||||
currentStepIndex >= totalSteps ||
|
||||
isOnboardingCompleted());
|
||||
const currentStep =
|
||||
currentStepIndex >= 0 && currentStepIndex < totalSteps
|
||||
? activeFlow[currentStepIndex]
|
||||
: null;
|
||||
const isActive =
|
||||
!shouldBlockOnboarding &&
|
||||
!isPaused &&
|
||||
!isComplete &&
|
||||
isInitialized &&
|
||||
currentStep !== null;
|
||||
const isLoading =
|
||||
configLoading ||
|
||||
!adminStatusResolved ||
|
||||
@@ -322,10 +370,13 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
||||
setCurrentStepIndex(nextIndex);
|
||||
}, [currentStepIndex, totalSteps]);
|
||||
|
||||
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
||||
const updateRuntimeState = useCallback(
|
||||
(updates: Partial<OnboardingRuntimeState>) => {
|
||||
persistRuntimeState(updates);
|
||||
setRuntimeState((prev) => ({ ...prev, ...updates }));
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const refreshFlow = useCallback(() => {
|
||||
initialIndexSet.current = false;
|
||||
|
||||
@@ -11,10 +11,15 @@ interface AnalyticsChoiceSlideProps {
|
||||
analyticsError?: string | null;
|
||||
}
|
||||
|
||||
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
|
||||
export default function AnalyticsChoiceSlide({
|
||||
analyticsError,
|
||||
}: AnalyticsChoiceSlideProps): SlideConfig {
|
||||
return {
|
||||
key: "analytics-choice",
|
||||
title: i18n.t("analytics.title", "Do you want to help make Stirling PDF better?"),
|
||||
title: i18n.t(
|
||||
"analytics.title",
|
||||
"Do you want to help make Stirling PDF better?",
|
||||
),
|
||||
body: (
|
||||
<div className={styles.bodyCopyInner}>
|
||||
<Trans
|
||||
@@ -33,13 +38,22 @@ export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoice
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => window.open("https://docs.stirlingpdf.com/analytics-telemetry/", "_blank")}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
"https://docs.stirlingpdf.com/analytics-telemetry/",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{i18n.t("analytics.learnMore", "Learn more about our analytics")}
|
||||
</Button>
|
||||
</div>
|
||||
{analyticsError && <div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>{analyticsError}</div>}
|
||||
{analyticsError && (
|
||||
<div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>
|
||||
{analyticsError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
background: {
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0);
|
||||
transform: translate3d(
|
||||
var(--circle-move-x, 40px),
|
||||
var(--circle-move-y, 24px),
|
||||
0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,16 @@ interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundP
|
||||
slideKey: string;
|
||||
}
|
||||
|
||||
export default function AnimatedSlideBackground({ gradientStops, circles, isActive }: AnimatedSlideBackgroundComponentProps) {
|
||||
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
|
||||
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
|
||||
export default function AnimatedSlideBackground({
|
||||
gradientStops,
|
||||
circles,
|
||||
isActive,
|
||||
}: AnimatedSlideBackgroundComponentProps) {
|
||||
const [prevGradient, setPrevGradient] = React.useState<
|
||||
[string, string] | null
|
||||
>(null);
|
||||
const [currentGradient, setCurrentGradient] =
|
||||
React.useState<[string, string]>(gradientStops);
|
||||
const [isTransitioning, setIsTransitioning] = React.useState(false);
|
||||
const isFirstMount = React.useRef(true);
|
||||
|
||||
@@ -29,7 +36,10 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
|
||||
}
|
||||
|
||||
// Only transition if gradient actually changed
|
||||
if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
|
||||
if (
|
||||
currentGradient[0] !== gradientStops[0] ||
|
||||
currentGradient[1] !== gradientStops[1]
|
||||
) {
|
||||
// Store previous gradient and start transition
|
||||
setPrevGradient(currentGradient);
|
||||
setIsTransitioning(true);
|
||||
@@ -69,10 +79,20 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
|
||||
style={currentGradientStyle}
|
||||
/>
|
||||
{circles.map((circle, index) => {
|
||||
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
|
||||
const {
|
||||
position,
|
||||
size,
|
||||
color,
|
||||
opacity,
|
||||
blur,
|
||||
amplitude = 48,
|
||||
duration = 15,
|
||||
delay = 0,
|
||||
} = circle;
|
||||
|
||||
const moveX = position === "bottom-left" ? amplitude : -amplitude;
|
||||
const moveY = position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
|
||||
const moveY =
|
||||
position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
|
||||
|
||||
const circleStyle: CircleStyles = {
|
||||
width: size,
|
||||
@@ -98,7 +118,13 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
|
||||
circleStyle.top = `${defaultOffset + offsetY}px`;
|
||||
}
|
||||
|
||||
return <div key={`circle-${index}-${position}`} className={styles.circle} style={circleStyle} />;
|
||||
return (
|
||||
<div
|
||||
key={`circle-${index}-${position}`}
|
||||
className={styles.circle}
|
||||
style={circleStyle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,10 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
import { DesktopInstallTitle, type OSOption } from "@app/components/onboarding/slides/DesktopInstallTitle";
|
||||
import {
|
||||
DesktopInstallTitle,
|
||||
type OSOption,
|
||||
} from "@app/components/onboarding/slides/DesktopInstallTitle";
|
||||
|
||||
export type { OSOption };
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
||||
|
||||
const displayLabel = currentOsOption.label || osLabel;
|
||||
const title = displayLabel
|
||||
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", { osLabel: displayLabel })
|
||||
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", {
|
||||
osLabel: displayLabel,
|
||||
})
|
||||
: t("onboarding.desktopInstall.title", "Download");
|
||||
|
||||
// If only one option or no options, don't show dropdown
|
||||
@@ -52,7 +54,15 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: "0.5rem",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
|
||||
<Menu position="bottom" offset={5} zIndex={10000}>
|
||||
<Menu.Target>
|
||||
@@ -80,7 +90,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
||||
backgroundColor: isSelected
|
||||
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
|
||||
: "transparent",
|
||||
color: isSelected ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" : "inherit",
|
||||
color: isSelected
|
||||
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))"
|
||||
: "inherit",
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
@@ -16,10 +16,16 @@ interface FirstLoginSlideProps {
|
||||
|
||||
const DEFAULT_PASSWORD = "stirling";
|
||||
|
||||
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
|
||||
function FirstLoginForm({
|
||||
username,
|
||||
onPasswordChanged,
|
||||
usingDefaultCredentials = false,
|
||||
}: FirstLoginSlideProps) {
|
||||
const { t } = useTranslation();
|
||||
// If using default credentials, pre-fill with "stirling" - user won't see this field
|
||||
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : "");
|
||||
const [currentPassword, setCurrentPassword] = useState(
|
||||
usingDefaultCredentials ? DEFAULT_PASSWORD : "",
|
||||
);
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -27,23 +33,39 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validation
|
||||
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
|
||||
if (
|
||||
(!usingDefaultCredentials && !currentPassword) ||
|
||||
!newPassword ||
|
||||
!confirmPassword
|
||||
) {
|
||||
setError(t("firstLogin.allFieldsRequired", "All fields are required"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match"));
|
||||
setError(
|
||||
t("firstLogin.passwordsDoNotMatch", "New passwords do not match"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
setError(t("firstLogin.passwordTooShort", "Password must be at least 8 characters"));
|
||||
setError(
|
||||
t(
|
||||
"firstLogin.passwordTooShort",
|
||||
"Password must be at least 8 characters",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword === currentPassword) {
|
||||
setError(t("firstLogin.passwordMustBeDifferent", "New password must be different from current password"));
|
||||
setError(
|
||||
t(
|
||||
"firstLogin.passwordMustBeDifferent",
|
||||
"New password must be different from current password",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,11 +73,18 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
|
||||
await accountService.changePasswordOnLogin(
|
||||
currentPassword,
|
||||
newPassword,
|
||||
confirmPassword,
|
||||
);
|
||||
|
||||
showToast({
|
||||
alertType: "success",
|
||||
title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."),
|
||||
title: t(
|
||||
"firstLogin.passwordChangedSuccess",
|
||||
"Password changed successfully! Please log in again.",
|
||||
),
|
||||
});
|
||||
|
||||
// Clear form
|
||||
@@ -73,7 +102,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
const axiosError = err as { response?: { data?: { message?: string } } };
|
||||
setError(
|
||||
axiosError.response?.data?.message ||
|
||||
t("firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password."),
|
||||
t(
|
||||
"firstLogin.passwordChangeFailed",
|
||||
"Failed to change password. Please check your current password.",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -85,18 +117,33 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
<div className={styles.securityCard}>
|
||||
<Stack gap="md">
|
||||
<div className={styles.securityAlertRow}>
|
||||
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: "#3B82F6", flexShrink: 0 }} />
|
||||
<LocalIcon
|
||||
icon="info-rounded"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ color: "#3B82F6", flexShrink: 0 }}
|
||||
/>
|
||||
<span>
|
||||
{t("firstLogin.welcomeMessage", "For security reasons, you must change your password on your first login.")}
|
||||
{t(
|
||||
"firstLogin.welcomeMessage",
|
||||
"For security reasons, you must change your password on your first login.",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Text size="sm" fw={500}>
|
||||
{t("firstLogin.loggedInAs", "Logged in as")}: <strong>{username}</strong>
|
||||
{t("firstLogin.loggedInAs", "Logged in as")}:{" "}
|
||||
<strong>{username}</strong>
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
|
||||
<Alert
|
||||
icon={
|
||||
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -105,7 +152,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
{!usingDefaultCredentials && (
|
||||
<PasswordInput
|
||||
label={t("firstLogin.currentPassword", "Current Password")}
|
||||
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")}
|
||||
placeholder={t(
|
||||
"firstLogin.enterCurrentPassword",
|
||||
"Enter your current password",
|
||||
)}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
|
||||
required
|
||||
@@ -117,7 +167,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
|
||||
<PasswordInput
|
||||
label={t("firstLogin.newPassword", "New Password")}
|
||||
placeholder={t("firstLogin.enterNewPassword", "Enter new password (min 8 characters)")}
|
||||
placeholder={t(
|
||||
"firstLogin.enterNewPassword",
|
||||
"Enter new password (min 8 characters)",
|
||||
)}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||
minLength={8}
|
||||
@@ -129,7 +182,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
|
||||
<PasswordInput
|
||||
label={t("firstLogin.confirmPassword", "Confirm New Password")}
|
||||
placeholder={t("firstLogin.reEnterNewPassword", "Re-enter new password")}
|
||||
placeholder={t(
|
||||
"firstLogin.reEnterNewPassword",
|
||||
"Re-enter new password",
|
||||
)}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||
required
|
||||
@@ -143,7 +199,12 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
||||
fullWidth
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
disabled={!newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
|
||||
disabled={
|
||||
!newPassword ||
|
||||
!confirmPassword ||
|
||||
newPassword.length < 8 ||
|
||||
confirmPassword.length < 8
|
||||
}
|
||||
size="md"
|
||||
mt="xs"
|
||||
>
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
||||
import { Alert, Box, Button, Group, Loader, Stack, Text, TextInput } from "@mantine/core";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type FormEvent,
|
||||
} from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { SlideConfig } from "@app/types/types";
|
||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||
@@ -16,7 +31,9 @@ interface MFASetupSlideProps {
|
||||
}
|
||||
|
||||
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
|
||||
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
|
||||
null,
|
||||
);
|
||||
const [mfaSetupCode, setMfaSetupCode] = useState("");
|
||||
const [mfaError, setMfaError] = useState("");
|
||||
const [mfaLoading, setMfaLoading] = useState(false);
|
||||
@@ -27,7 +44,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
const accountLogout = useAccountLogout();
|
||||
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
|
||||
|
||||
const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, "").slice(0, 6), []);
|
||||
const normalizeMfaCode = useCallback(
|
||||
(value: string) => value.replace(/\D/g, "").slice(0, 6),
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchMfaSetup = useCallback(async () => {
|
||||
try {
|
||||
@@ -38,7 +58,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
setMfaSetupData(data);
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(axiosError.response?.data?.error || "Unable to start two-factor setup. Please try again.");
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error ||
|
||||
"Unable to start two-factor setup. Please try again.",
|
||||
);
|
||||
} finally {
|
||||
setMfaLoading(false);
|
||||
}
|
||||
@@ -84,7 +107,8 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
} catch (err) {
|
||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||
setMfaError(
|
||||
axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again.",
|
||||
axiosError.response?.data?.error ||
|
||||
"Unable to enable two-factor authentication. Check the code and try again.",
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
@@ -137,12 +161,17 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
<div className={styles.mfaCard}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the
|
||||
6-digit code to finish.
|
||||
Secure your account by linking an authenticator app. Scan the QR
|
||||
code or enter the setup key, then confirm the 6-digit code to
|
||||
finish.
|
||||
</Text>
|
||||
|
||||
{mfaError && (
|
||||
<Alert icon={<LocalIcon icon="error" width={16} height={16} />} color="red" variant="light">
|
||||
<Alert
|
||||
icon={<LocalIcon icon="error" width={16} height={16} />}
|
||||
color="red"
|
||||
variant="light"
|
||||
>
|
||||
{mfaError}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -163,7 +192,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
label="Authentication code"
|
||||
placeholder="123456"
|
||||
value={mfaSetupCode}
|
||||
onChange={(event) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))}
|
||||
onChange={(event) =>
|
||||
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
|
||||
}
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
minLength={6}
|
||||
@@ -179,7 +210,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
>
|
||||
Regenerate QR code
|
||||
</Button>
|
||||
<Button type="submit" loading={submitting} disabled={!isReady || setupComplete || mfaSetupCode.length < 6}>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={submitting}
|
||||
disabled={
|
||||
!isReady || setupComplete || mfaSetupCode.length < 6
|
||||
}
|
||||
>
|
||||
Enable MFA
|
||||
</Button>
|
||||
<Button type="button" variant="light" onClick={onLogout}>
|
||||
@@ -200,7 +237,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function MFASetupSlide({ onMfaSetupComplete }: MFASetupSlideProps = {}): SlideConfig {
|
||||
export default function MFASetupSlide({
|
||||
onMfaSetupComplete,
|
||||
}: MFASetupSlideProps = {}): SlideConfig {
|
||||
return {
|
||||
key: "mfa-setup-slide",
|
||||
title: "Multi-Factor Authentication Setup",
|
||||
|
||||
@@ -22,7 +22,10 @@ const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ freeTierLimit, loginEnabled }) => {
|
||||
const AdminOverviewBody: React.FC<{
|
||||
freeTierLimit: number;
|
||||
loginEnabled: boolean;
|
||||
}> = ({ freeTierLimit, loginEnabled }) => {
|
||||
const adminBodyKey = loginEnabled
|
||||
? "onboarding.planOverview.adminBodyLoginEnabled"
|
||||
: "onboarding.planOverview.adminBodyLoginDisabled";
|
||||
@@ -32,7 +35,12 @@ const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean
|
||||
: "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.";
|
||||
|
||||
return (
|
||||
<Trans i18nKey={adminBodyKey} values={{ freeTierLimit }} components={{ strong: <strong /> }} defaults={defaultValue} />
|
||||
<Trans
|
||||
i18nKey={adminBodyKey}
|
||||
values={{ freeTierLimit }}
|
||||
components={{ strong: <strong /> }}
|
||||
defaults={defaultValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,11 +56,19 @@ const UserOverviewBody: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; loginEnabled: boolean }> = ({
|
||||
isAdmin,
|
||||
freeTierLimit,
|
||||
loginEnabled,
|
||||
}) => (isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />);
|
||||
const PlanOverviewBody: React.FC<{
|
||||
isAdmin: boolean;
|
||||
freeTierLimit: number;
|
||||
loginEnabled: boolean;
|
||||
}> = ({ isAdmin, freeTierLimit, loginEnabled }) =>
|
||||
isAdmin ? (
|
||||
<AdminOverviewBody
|
||||
freeTierLimit={freeTierLimit}
|
||||
loginEnabled={loginEnabled}
|
||||
/>
|
||||
) : (
|
||||
<UserOverviewBody />
|
||||
);
|
||||
|
||||
export default function PlanOverviewSlide({
|
||||
isAdmin,
|
||||
@@ -64,7 +80,13 @@ export default function PlanOverviewSlide({
|
||||
return {
|
||||
key: isAdmin ? "admin-overview" : "plan-overview",
|
||||
title: <PlanOverviewTitle isAdmin={isAdmin} />,
|
||||
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
|
||||
body: (
|
||||
<PlanOverviewBody
|
||||
isAdmin={isAdmin}
|
||||
freeTierLimit={freeTierLimit}
|
||||
loginEnabled={loginEnabled}
|
||||
/>
|
||||
),
|
||||
background: {
|
||||
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
|
||||
@@ -11,7 +11,10 @@ interface SecurityCheckSlideProps {
|
||||
onRoleSelect: (role: "admin" | "user" | null) => void;
|
||||
}
|
||||
|
||||
export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: SecurityCheckSlideProps): SlideConfig {
|
||||
export default function SecurityCheckSlide({
|
||||
selectedRole,
|
||||
onRoleSelect,
|
||||
}: SecurityCheckSlideProps): SlideConfig {
|
||||
return {
|
||||
key: "security-check",
|
||||
title: "Security Check",
|
||||
@@ -19,7 +22,12 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
|
||||
<div className={styles.securitySlideContent}>
|
||||
<div className={styles.securityCard}>
|
||||
<div className={styles.securityAlertRow}>
|
||||
<LocalIcon icon="error" width={20} height={20} style={{ color: "#F04438", flexShrink: 0 }} />
|
||||
<LocalIcon
|
||||
icon="error"
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ color: "#F04438", flexShrink: 0 }}
|
||||
/>
|
||||
<span>
|
||||
{i18n.t(
|
||||
"onboarding.securityCheck.message",
|
||||
@@ -35,7 +43,9 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
|
||||
{ value: "admin", label: "Admin" },
|
||||
{ value: "user", label: "User" },
|
||||
]}
|
||||
onChange={(value) => onRoleSelect((value as "admin" | "user") ?? null)}
|
||||
onChange={(value) =>
|
||||
onRoleSelect((value as "admin" | "user") ?? null)
|
||||
}
|
||||
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
|
||||
styles={{
|
||||
input: {
|
||||
|
||||
@@ -10,11 +10,14 @@ interface ServerLicenseSlideProps {
|
||||
|
||||
const DEFAULT_FREE_TIER_LIMIT = 5;
|
||||
|
||||
export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlideProps = {}): SlideConfig {
|
||||
export default function ServerLicenseSlide({
|
||||
licenseNotice,
|
||||
}: ServerLicenseSlideProps = {}): SlideConfig {
|
||||
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
||||
const totalUsers = licenseNotice?.totalUsers ?? null;
|
||||
const isOverLimit = licenseNotice?.isOverLimit ?? false;
|
||||
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
|
||||
const formattedTotalUsers =
|
||||
totalUsers != null ? totalUsers.toLocaleString() : null;
|
||||
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
|
||||
const title = isOverLimit
|
||||
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
|
||||
@@ -50,7 +53,9 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
|
||||
title,
|
||||
body,
|
||||
background: {
|
||||
gradientStops: isOverLimit ? ["#F472B6", "#8B5CF6"] : ["#F97316", "#F59E0B"],
|
||||
gradientStops: isOverLimit
|
||||
? ["#F472B6", "#8B5CF6"]
|
||||
: ["#F97316", "#F59E0B"],
|
||||
circles: UNIFIED_CIRCLE_CONFIG,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,6 +12,10 @@ export const addGlowToElements = (selectors: string[]) => {
|
||||
};
|
||||
|
||||
export const removeAllGlows = () => {
|
||||
document.querySelectorAll(".tour-content-glow").forEach((el) => el.classList.remove("tour-content-glow"));
|
||||
document.querySelectorAll(".tour-nav-glow").forEach((el) => el.classList.remove("tour-nav-glow"));
|
||||
document
|
||||
.querySelectorAll(".tour-content-glow")
|
||||
.forEach((el) => el.classList.remove("tour-content-glow"));
|
||||
document
|
||||
.querySelectorAll(".tour-nav-glow")
|
||||
.forEach((el) => el.classList.remove("tour-nav-glow"));
|
||||
};
|
||||
|
||||
@@ -38,7 +38,9 @@ function setStoredBypass(enabled: boolean): void {
|
||||
*/
|
||||
export function useBypassOnboarding(): boolean {
|
||||
const location = useLocation();
|
||||
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
|
||||
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() =>
|
||||
readStoredBypass(),
|
||||
);
|
||||
|
||||
const shouldBypassFromSearch = useMemo(() => {
|
||||
try {
|
||||
|
||||
@@ -36,7 +36,10 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
||||
case "windows":
|
||||
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
|
||||
case "mac-apple":
|
||||
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
||||
return {
|
||||
label: "Mac (Apple Silicon)",
|
||||
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
|
||||
};
|
||||
case "mac-intel":
|
||||
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
|
||||
case "linux-x64":
|
||||
@@ -51,8 +54,16 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
||||
() =>
|
||||
[
|
||||
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
|
||||
{ label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: "mac-apple" },
|
||||
{ label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL, value: "mac-intel" },
|
||||
{
|
||||
label: "Mac (Apple Silicon)",
|
||||
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
|
||||
value: "mac-apple",
|
||||
},
|
||||
{
|
||||
label: "Mac (Intel)",
|
||||
url: DOWNLOAD_URLS.MAC_INTEL,
|
||||
value: "mac-intel",
|
||||
},
|
||||
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
|
||||
].filter((opt) => opt.url),
|
||||
[],
|
||||
|
||||
@@ -14,7 +14,9 @@ export function useServerLicenseRequest(): {
|
||||
closeLicenseSlide: () => void;
|
||||
} {
|
||||
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
|
||||
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState["licenseNotice"] | null>(null);
|
||||
const [licenseNotice, setLicenseNotice] = useState<
|
||||
OnboardingRuntimeState["licenseNotice"] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -35,7 +37,11 @@ export function useServerLicenseRequest(): {
|
||||
};
|
||||
|
||||
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
||||
return () => window.removeEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
SERVER_LICENSE_REQUEST_EVENT,
|
||||
handleLicenseRequest,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const closeLicenseSlide = useCallback(() => {
|
||||
@@ -51,7 +57,8 @@ export function useTourRequest(): {
|
||||
clearTourRequest: () => void;
|
||||
} {
|
||||
const [tourRequested, setTourRequested] = useState(false);
|
||||
const [requestedTourType, setRequestedTourType] = useState<TourType>("whatsnew");
|
||||
const [requestedTourType, setRequestedTourType] =
|
||||
useState<TourType>("whatsnew");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
@@ -63,7 +70,8 @@ export function useTourRequest(): {
|
||||
};
|
||||
|
||||
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||
return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||
return () =>
|
||||
window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||
}, []);
|
||||
|
||||
const clearTourRequest = useCallback(() => {
|
||||
|
||||
@@ -36,7 +36,10 @@ interface CreateUserStepsConfigArgs {
|
||||
actions: UserStepActions;
|
||||
}
|
||||
|
||||
export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
|
||||
export function createUserStepsConfig({
|
||||
t,
|
||||
actions,
|
||||
}: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { StepType } from "@reactour/tour";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
|
||||
async function waitForElement(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
intervalMs = 100,
|
||||
): Promise<void> {
|
||||
if (typeof document === "undefined") return;
|
||||
const start = Date.now();
|
||||
// Immediate hit
|
||||
@@ -19,7 +23,11 @@ async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 1
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
|
||||
async function waitForHighlightable(
|
||||
selector: string,
|
||||
timeoutMs = 7000,
|
||||
intervalMs = 500,
|
||||
): Promise<void> {
|
||||
if (typeof document === "undefined") return;
|
||||
const start = Date.now();
|
||||
|
||||
@@ -68,7 +76,10 @@ interface CreateWhatsNewStepsConfigArgs {
|
||||
actions: WhatsNewStepActions;
|
||||
}
|
||||
|
||||
export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
|
||||
export function createWhatsNewStepsConfig({
|
||||
t,
|
||||
actions,
|
||||
}: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
|
||||
const {
|
||||
saveWorkbenchState,
|
||||
closeFilesModal,
|
||||
@@ -128,7 +139,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
||||
},
|
||||
[WhatsNewTourStep.RIGHT_RAIL]: {
|
||||
selector: '[data-tour="right-rail-controls"]',
|
||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
||||
highlightedSelectors: [
|
||||
'[data-tour="right-rail-controls"]',
|
||||
'[data-tour="right-rail-settings"]',
|
||||
],
|
||||
content: t(
|
||||
"onboarding.whatsNew.rightRail",
|
||||
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
|
||||
@@ -157,7 +171,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
||||
},
|
||||
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
||||
selector: '[data-tour="view-switcher"]',
|
||||
content: t("onboarding.whatsNew.pageEditorView", "Switch to the Page Editor to reorder, rotate, or delete pages."),
|
||||
content: t(
|
||||
"onboarding.whatsNew.pageEditorView",
|
||||
"Switch to the Page Editor to reorder, rotate, or delete pages.",
|
||||
),
|
||||
position: "bottom",
|
||||
padding: 8,
|
||||
action: async () => {
|
||||
|
||||
@@ -39,9 +39,17 @@ const BulkSelectionPanel = ({
|
||||
onToggleAdvanced={setAdvancedOpened}
|
||||
/>
|
||||
|
||||
<PageSelectionSyntaxHint input={csvInput} maxPages={maxPages} variant="panel" />
|
||||
<PageSelectionSyntaxHint
|
||||
input={csvInput}
|
||||
maxPages={maxPages}
|
||||
variant="panel"
|
||||
/>
|
||||
|
||||
<SelectedPagesDisplay selectedPageIds={selectedPageIds} displayDocument={displayDocument} syntaxError={null} />
|
||||
<SelectedPagesDisplay
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
syntaxError={null}
|
||||
/>
|
||||
|
||||
<AdvancedSelectionPanel
|
||||
csvInput={csvInput}
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from "react";
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useState,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
|
||||
import styles from "@app/components/pageEditor/DragDropGrid.module.css";
|
||||
import { Z_INDEX_SELECTION_BOX, Z_INDEX_DROP_INDICATOR, Z_INDEX_DRAG_BADGE } from "@app/styles/zIndex";
|
||||
import {
|
||||
Z_INDEX_SELECTION_BOX,
|
||||
Z_INDEX_DROP_INDICATOR,
|
||||
Z_INDEX_DRAG_BADGE,
|
||||
} from "@app/styles/zIndex";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import {
|
||||
DndContext,
|
||||
@@ -28,7 +38,11 @@ interface DragDropItem {
|
||||
|
||||
interface DragDropGridProps<T extends DragDropItem> {
|
||||
items: T[];
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
onReorderPages: (
|
||||
sourcePageNumber: number,
|
||||
targetIndex: number,
|
||||
selectedPageIds?: string[],
|
||||
) => void;
|
||||
renderItem: (
|
||||
item: T,
|
||||
index: number,
|
||||
@@ -40,7 +54,9 @@ interface DragDropGridProps<T extends DragDropItem> {
|
||||
dragHandleProps?: any,
|
||||
zoomLevel?: number,
|
||||
) => React.ReactNode;
|
||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||
getThumbnailData?: (
|
||||
itemId: string,
|
||||
) => { src: string; rotation: number } | null;
|
||||
zoomLevel?: number;
|
||||
selectedFileIds?: string[];
|
||||
selectedPageIds?: string[];
|
||||
@@ -67,7 +83,10 @@ function resolveDropHint(
|
||||
}
|
||||
|
||||
const items: ItemRect[] = Array.from(itemRefs.current.entries())
|
||||
.filter((entry): entry is [string, HTMLDivElement] => !!entry[1] && entry[0] !== activeId)
|
||||
.filter(
|
||||
(entry): entry is [string, HTMLDivElement] =>
|
||||
!!entry[1] && entry[0] !== activeId,
|
||||
)
|
||||
.map(([itemId, element]) => ({
|
||||
id: itemId,
|
||||
rect: element.getBoundingClientRect(),
|
||||
@@ -90,7 +109,8 @@ function resolveDropHint(
|
||||
return;
|
||||
}
|
||||
|
||||
const isSameRow = Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance;
|
||||
const isSameRow =
|
||||
Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance;
|
||||
if (isSameRow) {
|
||||
currentRow.push(item);
|
||||
} else {
|
||||
@@ -174,7 +194,9 @@ function resolveTargetIndex<T extends DragDropItem>(
|
||||
};
|
||||
|
||||
if (hoveredId) {
|
||||
const filteredIndex = filteredItems.findIndex((item) => item.id === hoveredId);
|
||||
const filteredIndex = filteredItems.findIndex(
|
||||
(item) => item.id === hoveredId,
|
||||
);
|
||||
if (filteredIndex !== -1) {
|
||||
const adjustedIndex = filteredIndex + (dropSide === "right" ? 1 : 0);
|
||||
return convertFilteredIndexToOriginal(adjustedIndex);
|
||||
@@ -198,7 +220,9 @@ interface DraggableItemProps<T extends DragDropItem> {
|
||||
clearBoxSelection: () => void;
|
||||
activeDragIds: string[];
|
||||
justMoved: boolean;
|
||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
||||
getThumbnailData?: (
|
||||
itemId: string,
|
||||
) => { src: string; rotation: number } | null;
|
||||
onUpdateDropTarget: (itemId: string | null) => void;
|
||||
renderItem: (
|
||||
item: T,
|
||||
@@ -247,11 +271,15 @@ const DraggableItemInner = <T extends DragDropItem>({
|
||||
}
|
||||
|
||||
const element = itemRefs.current.get(item.id);
|
||||
const imgElement = element?.querySelector("img.ph-no-capture") as HTMLImageElement;
|
||||
const imgElement = element?.querySelector(
|
||||
"img.ph-no-capture",
|
||||
) as HTMLImageElement;
|
||||
if (imgElement?.src) {
|
||||
return {
|
||||
src: imgElement.src,
|
||||
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0,
|
||||
rotation: imgElement.dataset.originalRotation
|
||||
? parseInt(imgElement.dataset.originalRotation)
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -312,8 +340,12 @@ const DraggableItem = React.memo(DraggableItemInner, (prevProps, nextProps) => {
|
||||
}
|
||||
|
||||
// Check if page selection changed (for checkbox selection, not box selection)
|
||||
const prevSelectedSet = prevProps.selectedPageIds ? new Set(prevProps.selectedPageIds) : null;
|
||||
const nextSelectedSet = nextProps.selectedPageIds ? new Set(nextProps.selectedPageIds) : null;
|
||||
const prevSelectedSet = prevProps.selectedPageIds
|
||||
? new Set(prevProps.selectedPageIds)
|
||||
: null;
|
||||
const nextSelectedSet = nextProps.selectedPageIds
|
||||
? new Set(nextProps.selectedPageIds)
|
||||
: null;
|
||||
|
||||
if (prevSelectedSet && nextSelectedSet) {
|
||||
const prevSelected = prevSelectedSet.has(prevProps.item.id);
|
||||
@@ -348,17 +380,29 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getScrollElement = useCallback(() => {
|
||||
return containerRef.current?.closest("[data-scrolling-container]") as HTMLElement | null;
|
||||
return containerRef.current?.closest(
|
||||
"[data-scrolling-container]",
|
||||
) as HTMLElement | null;
|
||||
}, []);
|
||||
|
||||
// Create stable signature for items to ensure useMemo detects changes
|
||||
const itemsSignature = useMemo(() => items.map((item) => item.id).join(","), [items]);
|
||||
const selectedFileIdsSignature = useMemo(() => selectedFileIds?.join(",") || "", [selectedFileIds]);
|
||||
const itemsSignature = useMemo(
|
||||
() => items.map((item) => item.id).join(","),
|
||||
[items],
|
||||
);
|
||||
const selectedFileIdsSignature = useMemo(
|
||||
() => selectedFileIds?.join(",") || "",
|
||||
[selectedFileIds],
|
||||
);
|
||||
|
||||
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
|
||||
const { filteredItems: visibleItems, filteredToOriginalIndex } =
|
||||
useMemo(() => {
|
||||
const filtered: T[] = [];
|
||||
const indexMap: number[] = [];
|
||||
const selectedIds = selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null;
|
||||
const selectedIds =
|
||||
selectedFileIds && selectedFileIds.length > 0
|
||||
? new Set(selectedFileIds)
|
||||
: null;
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const isPlaceholder = Boolean(item.isPlaceholder);
|
||||
@@ -366,7 +410,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
return;
|
||||
}
|
||||
|
||||
const belongsToVisibleFile = !selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId);
|
||||
const belongsToVisibleFile =
|
||||
!selectedIds ||
|
||||
!item.originalFileId ||
|
||||
selectedIds.has(item.originalFileId);
|
||||
|
||||
if (!belongsToVisibleFile) {
|
||||
return;
|
||||
@@ -389,8 +436,14 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
}, [visibleItems]);
|
||||
|
||||
// Box selection state
|
||||
const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null);
|
||||
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null);
|
||||
const [boxSelectStart, setBoxSelectStart] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [boxSelectEnd, setBoxSelectEnd] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
|
||||
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
|
||||
const [justMovedIds, setJustMovedIds] = useState<string[]>([]);
|
||||
@@ -398,7 +451,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
|
||||
// Drag state
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null);
|
||||
const [dragPreview, setDragPreview] = useState<{
|
||||
src: string;
|
||||
rotation: number;
|
||||
} | null>(null);
|
||||
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
|
||||
const [dropSide, setDropSide] = useState<DropSide>(null);
|
||||
|
||||
@@ -438,7 +494,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||
window.addEventListener("pointermove", handlePointerMove, {
|
||||
passive: true,
|
||||
});
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
if (rafId !== null) {
|
||||
@@ -449,7 +507,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
|
||||
// Responsive grid configuration
|
||||
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||
const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||
const OVERSCAN =
|
||||
visibleItems.length > 1000
|
||||
? GRID_CONSTANTS.OVERSCAN_LARGE
|
||||
: GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||
|
||||
// Calculate items per row based on container width
|
||||
const calculateItemsPerRow = useCallback(() => {
|
||||
@@ -459,8 +520,11 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
if (containerWidth === 0) return 4; // Container not measured yet
|
||||
|
||||
// Convert rem to pixels for calculation
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||
const remToPx = parseFloat(
|
||||
getComputedStyle(document.documentElement).fontSize,
|
||||
);
|
||||
const ITEM_WIDTH =
|
||||
parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
||||
|
||||
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
||||
@@ -501,7 +565,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
count: Math.ceil(visibleItems.length / itemsPerRow),
|
||||
getScrollElement,
|
||||
estimateSize: () => {
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const remToPx = parseFloat(
|
||||
getComputedStyle(document.documentElement).fontSize,
|
||||
);
|
||||
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
|
||||
},
|
||||
overscan: OVERSCAN,
|
||||
@@ -524,10 +590,19 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
|
||||
// Re-measure virtualizer when zoom or items per row changes
|
||||
// Also remeasure when items change (not just length) to handle item additions/removals
|
||||
const visibleItemsSignature = useMemo(() => visibleItems.map((item) => item.id).join(","), [visibleItems]);
|
||||
const visibleItemsSignature = useMemo(
|
||||
() => visibleItems.map((item) => item.id).join(","),
|
||||
[visibleItems],
|
||||
);
|
||||
useEffect(() => {
|
||||
rowVirtualizer.measure();
|
||||
}, [zoomLevel, itemsPerRow, visibleItems.length, visibleItemsSignature, rowVirtualizer]);
|
||||
}, [
|
||||
zoomLevel,
|
||||
itemsPerRow,
|
||||
visibleItems.length,
|
||||
visibleItemsSignature,
|
||||
rowVirtualizer,
|
||||
]);
|
||||
|
||||
// Cleanup highlight timeout on unmount
|
||||
useEffect(() => {
|
||||
@@ -559,7 +634,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
if (clickedPageId) {
|
||||
// Clicking directly on a page shouldn't initiate box selection
|
||||
// but clear previous box selection if clicking outside current group
|
||||
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
|
||||
if (
|
||||
boxSelectedPageIds.length > 0 &&
|
||||
!boxSelectedPageIds.includes(clickedPageId)
|
||||
) {
|
||||
setBoxSelectedPageIds([]);
|
||||
}
|
||||
return;
|
||||
@@ -600,7 +678,12 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
const pageBottom = pageRect.bottom - rect.top;
|
||||
|
||||
// Check if page intersects with selection box
|
||||
const intersects = !(pageRight < boxLeft || pageLeft > boxRight || pageBottom < boxTop || pageTop > boxBottom);
|
||||
const intersects = !(
|
||||
pageRight < boxLeft ||
|
||||
pageLeft > boxRight ||
|
||||
pageBottom < boxTop ||
|
||||
pageTop > boxBottom
|
||||
);
|
||||
|
||||
if (intersects) {
|
||||
selectedIds.push(pageId);
|
||||
@@ -637,7 +720,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
if (getThumbnail) {
|
||||
const thumbnailData = getThumbnail();
|
||||
if (thumbnailData?.src) {
|
||||
setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation });
|
||||
setDragPreview({
|
||||
src: thumbnailData.src,
|
||||
rotation: thumbnailData.rotation,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -692,7 +778,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
|
||||
// Check if this page is box-selected
|
||||
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
|
||||
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
|
||||
const pagesToDrag =
|
||||
isBoxSelected && boxSelectedPageIds.length > 0
|
||||
? boxSelectedPageIds
|
||||
: undefined;
|
||||
|
||||
// Call reorder with page number and target index
|
||||
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
|
||||
@@ -726,7 +815,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
);
|
||||
|
||||
// Calculate optimal width for centering
|
||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const remToPx = parseFloat(
|
||||
getComputedStyle(document.documentElement).fontSize,
|
||||
);
|
||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||
@@ -814,9 +905,13 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
onMouseUp={handleMouseUp}
|
||||
onWheel={handleWheelWhileDragging}
|
||||
>
|
||||
{selectionBoxStyle && <div className={styles.selectionBox} style={selectionBoxStyle} />}
|
||||
{selectionBoxStyle && (
|
||||
<div className={styles.selectionBox} style={selectionBoxStyle} />
|
||||
)}
|
||||
|
||||
{dropIndicatorStyle && <div className={styles.dropIndicator} style={dropIndicatorStyle} />}
|
||||
{dropIndicatorStyle && (
|
||||
<div className={styles.dropIndicator} style={dropIndicatorStyle} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={styles.virtualRows}
|
||||
@@ -828,7 +923,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const startIndex = virtualRow.index * itemsPerRow;
|
||||
const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length);
|
||||
const endIndex = Math.min(
|
||||
startIndex + itemsPerRow,
|
||||
visibleItems.length,
|
||||
);
|
||||
const rowItems = visibleItems.slice(startIndex, endIndex);
|
||||
|
||||
return (
|
||||
@@ -877,8 +975,12 @@ const DragDropGrid = <T extends DragDropItem>({
|
||||
<DragOverlay>
|
||||
{activeId && (
|
||||
<div className={styles.dragOverlay}>
|
||||
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
|
||||
<div className={styles.dragOverlayBadge} style={{ zIndex: Z_INDEX_DRAG_BADGE }}>
|
||||
{boxSelectedPageIds.includes(activeId) &&
|
||||
boxSelectedPageIds.length > 1 && (
|
||||
<div
|
||||
className={styles.dragOverlayBadge}
|
||||
style={{ zIndex: Z_INDEX_DRAG_BADGE }}
|
||||
>
|
||||
{boxSelectedPageIds.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||
import React, {
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { ActionIcon, CheckboxIndicator } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
@@ -6,7 +12,10 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
import {
|
||||
draggable,
|
||||
dropTargetForElements,
|
||||
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||
|
||||
import styles from "@app/components/pageEditor/PageEditor.module.css";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
@@ -35,7 +44,11 @@ interface FileThumbnailProps {
|
||||
onDeleteFile: (fileId: FileId) => void;
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onReorderFiles?: (
|
||||
sourceFileId: FileId,
|
||||
targetFileId: FileId,
|
||||
selectedFileIds: FileId[],
|
||||
) => void;
|
||||
onDownloadFile?: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
@@ -61,7 +74,9 @@ const FileThumbnail = ({
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
|
||||
const [actionsWidth, setActionsWidth] = useState<number | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
// Resolve the actual File object for pin/unpin operations
|
||||
@@ -80,7 +95,10 @@ const FileThumbnail = ({
|
||||
// Fallback: attempt to download using the File object if provided
|
||||
const maybeFile = (file as unknown as { file?: File }).file;
|
||||
if (maybeFile instanceof File) {
|
||||
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || "download" });
|
||||
void downloadFile({
|
||||
data: maybeFile,
|
||||
filename: maybeFile.name || file.name || "download",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,7 +164,8 @@ const FileThumbnail = ({
|
||||
// Update dropdown width on resize
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
||||
if (dragElementRef.current)
|
||||
setActionsWidth(dragElementRef.current.offsetWidth);
|
||||
};
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
@@ -177,7 +196,9 @@ const FileThumbnail = ({
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||
document.addEventListener("touchstart", handleTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("touchstart", handleTouchStart);
|
||||
@@ -208,7 +229,9 @@ const FileThumbnail = ({
|
||||
onClick={handleCardClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}>
|
||||
<div
|
||||
className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}
|
||||
>
|
||||
{/* Logo/checkbox area */}
|
||||
<div className={styles.logoMark}>
|
||||
{isSupported ? (
|
||||
@@ -225,7 +248,10 @@ const FileThumbnail = ({
|
||||
</div>
|
||||
|
||||
{/* Centered index */}
|
||||
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
||||
<div
|
||||
className={styles.headerIndex}
|
||||
aria-label={`Position ${index + 1}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
@@ -245,7 +271,11 @@ const FileThumbnail = ({
|
||||
|
||||
{/* Actions overlay */}
|
||||
{showActions && (
|
||||
<div className={styles.actionsOverlay} style={{ width: actionsWidth }} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
className={styles.actionsOverlay}
|
||||
style={{ width: actionsWidth }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => {
|
||||
@@ -261,7 +291,11 @@ const FileThumbnail = ({
|
||||
setShowActions(false);
|
||||
}}
|
||||
>
|
||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
||||
{isPinned ? (
|
||||
<PushPinIcon fontSize="small" />
|
||||
) : (
|
||||
<PushPinOutlinedIcon fontSize="small" />
|
||||
)}
|
||||
<span>{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}</span>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { useNavigationGuard, useNavigationState } from "@app/contexts/NavigationContext";
|
||||
import {
|
||||
useNavigationGuard,
|
||||
useNavigationState,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { usePageEditor } from "@app/contexts/PageEditorContext";
|
||||
import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
|
||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||
@@ -35,8 +38,11 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
const { actions } = useFileActions();
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } =
|
||||
useNavigationGuard();
|
||||
const {
|
||||
setHasUnsavedChanges,
|
||||
registerNavigationWarningHandlers,
|
||||
unregisterNavigationWarningHandlers,
|
||||
} = useNavigationGuard();
|
||||
const navigationState = useNavigationState();
|
||||
|
||||
// Get PageEditor coordination functions
|
||||
@@ -56,7 +62,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
const handleVisibleItemsChange = useCallback((items: PDFPage[]) => {
|
||||
setVisiblePageIds((prev) => {
|
||||
const ids = items.map((item) => item.id);
|
||||
if (prev.length === ids.length && prev.every((id, index) => id === ids[index])) {
|
||||
if (
|
||||
prev.length === ids.length &&
|
||||
prev.every((id, index) => id === ids[index])
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return ids;
|
||||
@@ -97,7 +106,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
const filesSignature = selectors.getFilesSignature();
|
||||
|
||||
const fileObjectsRef = useRef(new Map<FileId, any>());
|
||||
const gridItemRefsRef = useRef<React.MutableRefObject<Map<string, HTMLDivElement>> | null>(null);
|
||||
const gridItemRefsRef = useRef<React.MutableRefObject<
|
||||
Map<string, HTMLDivElement>
|
||||
> | null>(null);
|
||||
|
||||
const pageEditorFiles = useMemo(() => {
|
||||
const cache = fileObjectsRef.current;
|
||||
@@ -163,7 +174,8 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
const initialDocument = useInitialPageDocument();
|
||||
const { document: mergedPdfDocument } = usePageDocument();
|
||||
|
||||
const { setEditedDocument, displayDocument, getEditedDocument } = useEditedDocumentState({
|
||||
const { setEditedDocument, displayDocument, getEditedDocument } =
|
||||
useEditedDocumentState({
|
||||
initialDocument,
|
||||
mergedPdfDocument,
|
||||
reorderedPages,
|
||||
@@ -184,7 +196,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
|
||||
const loadedCount = doc.pages.filter((p) => p.thumbnail).length;
|
||||
const pending = thumbnailRequestsRef.current.size;
|
||||
const MAX_CONCURRENT_THUMBNAILS = loadedCount < 8 ? 1 : doc.totalPages < 20 ? 3 : doc.totalPages < 50 ? 5 : 8;
|
||||
const MAX_CONCURRENT_THUMBNAILS =
|
||||
loadedCount < 8
|
||||
? 1
|
||||
: doc.totalPages < 20
|
||||
? 3
|
||||
: doc.totalPages < 50
|
||||
? 5
|
||||
: 8;
|
||||
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
|
||||
if (available === 0) return;
|
||||
|
||||
@@ -214,7 +233,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
if (pageIndex === -1) return prev;
|
||||
|
||||
const updated = [...prev.pages];
|
||||
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache };
|
||||
updated[pageIndex] = {
|
||||
...prev.pages[pageIndex],
|
||||
thumbnail: cache,
|
||||
};
|
||||
return { ...prev, pages: updated };
|
||||
});
|
||||
})
|
||||
@@ -230,7 +252,11 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
if (!file) return;
|
||||
|
||||
thumbnailRequestsRef.current.add(pageId);
|
||||
requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber)
|
||||
requestThumbnail(
|
||||
pageId,
|
||||
file,
|
||||
page.originalPageNumber || page.pageNumber,
|
||||
)
|
||||
.then((thumbnail) => {
|
||||
if (thumbnail) {
|
||||
setEditedDocument((prev) => {
|
||||
@@ -277,11 +303,17 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
}
|
||||
|
||||
const INITIAL_VISIBLE_PAGE_COUNT = 8;
|
||||
const initialIds = displayDocument.pages.slice(0, INITIAL_VISIBLE_PAGE_COUNT).map((page) => page.id);
|
||||
const initialIds = displayDocument.pages
|
||||
.slice(0, INITIAL_VISIBLE_PAGE_COUNT)
|
||||
.map((page) => page.id);
|
||||
|
||||
queueThumbnailRequestsForPages(initialIds);
|
||||
lastInitialDocumentSignatureRef.current = signature;
|
||||
}, [displayDocumentId, displayDocumentLength, queueThumbnailRequestsForPages]);
|
||||
}, [
|
||||
displayDocumentId,
|
||||
displayDocumentLength,
|
||||
queueThumbnailRequestsForPages,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisiblePageIds([]);
|
||||
@@ -340,7 +372,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
// Grid container ref for positioning split indicators
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { canUndo, canRedo, executeCommandWithTracking, handleUndo, handleRedo, clearUndoHistory } = useUndoManagerState({
|
||||
const {
|
||||
canUndo,
|
||||
canRedo,
|
||||
executeCommandWithTracking,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
clearUndoHistory,
|
||||
} = useUndoManagerState({
|
||||
setHasUnsavedChanges,
|
||||
});
|
||||
|
||||
@@ -402,7 +441,12 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
},
|
||||
});
|
||||
return () => unregisterNavigationWarningHandlers();
|
||||
}, [applyChanges, onExportAll, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers]);
|
||||
}, [
|
||||
applyChanges,
|
||||
onExportAll,
|
||||
registerNavigationWarningHandlers,
|
||||
unregisterNavigationWarningHandlers,
|
||||
]);
|
||||
|
||||
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
|
||||
const selectedPageCount = selectedPageIds.length;
|
||||
@@ -556,7 +600,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
zoomLevelParam?: number,
|
||||
) => {
|
||||
gridItemRefsRef.current = refs;
|
||||
const fileColorIndex = page.originalFileId ? (fileColorIndexMap.get(page.originalFileId) ?? 0) : 0;
|
||||
const fileColorIndex = page.originalFileId
|
||||
? (fileColorIndexMap.get(page.originalFileId) ?? 0)
|
||||
: 0;
|
||||
const isBoxSelected = boxSelectedIds.includes(page.id);
|
||||
return (
|
||||
<PageThumbnail
|
||||
@@ -631,7 +677,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
>
|
||||
<LoadingOverlay visible={globalProcessing && !initialDocument} />
|
||||
|
||||
{!initialDocument && !globalProcessing && selectedFileIds.length === 0 && (
|
||||
{!initialDocument &&
|
||||
!globalProcessing &&
|
||||
selectedFileIds.length === 0 && (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="md">
|
||||
<Text size="lg" c="dimmed">
|
||||
@@ -653,7 +701,13 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
)}
|
||||
|
||||
{displayDocument && (
|
||||
<Box ref={gridContainerRef} p={0} pt="2rem" pb="4rem" style={{ position: "relative" }}>
|
||||
<Box
|
||||
ref={gridContainerRef}
|
||||
p={0}
|
||||
pt="2rem"
|
||||
pb="4rem"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
{/* Split Lines Overlay */}
|
||||
<div
|
||||
style={{
|
||||
@@ -674,7 +728,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
}
|
||||
|
||||
const containerRect = containerEl.getBoundingClientRect();
|
||||
const splitIndexes = convertSplitPageIdsToIndexes(displayDocument, splitPositions);
|
||||
const splitIndexes = convertSplitPageIdsToIndexes(
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
);
|
||||
|
||||
return Array.from(splitIndexes).map((position) => {
|
||||
const currentPage = displayedPages[position];
|
||||
@@ -695,7 +752,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
||||
const nextEl = refsMap.get(nextPage.id);
|
||||
if (nextEl) {
|
||||
const nextRect = nextEl.getBoundingClientRect();
|
||||
const sameRow = Math.abs(nextRect.top - currentRect.top) < currentRect.height / 2;
|
||||
const sameRow =
|
||||
Math.abs(nextRect.top - currentRect.top) <
|
||||
currentRect.height / 2;
|
||||
if (sameRow) {
|
||||
lineLeft = (currentRect.right + nextRect.left) / 2;
|
||||
} else {
|
||||
|
||||
@@ -60,22 +60,31 @@ const PageEditorControls = ({
|
||||
|
||||
const totalPages = displayDocument.pages.length;
|
||||
const selectedValidPageIds = displayDocument.pages
|
||||
.filter((page, index) => selectedPageIds.includes(page.id) && index < totalPages - 1)
|
||||
.filter(
|
||||
(page, index) =>
|
||||
selectedPageIds.includes(page.id) && index < totalPages - 1,
|
||||
)
|
||||
.map((page) => page.id);
|
||||
|
||||
if (selectedValidPageIds.length === 0) {
|
||||
return "Split Selected";
|
||||
}
|
||||
|
||||
const existingSplitsCount = selectedValidPageIds.filter((id) => splitPositions.has(id)).length;
|
||||
const existingSplitsCount = selectedValidPageIds.filter((id) =>
|
||||
splitPositions.has(id),
|
||||
).length;
|
||||
const noSplitsCount = selectedValidPageIds.length - existingSplitsCount;
|
||||
|
||||
const willRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
if (willRemoveSplits) {
|
||||
return existingSplitsCount === selectedValidPageIds.length ? "Remove All Selected Splits" : "Remove Selected Splits";
|
||||
return existingSplitsCount === selectedValidPageIds.length
|
||||
? "Remove All Selected Splits"
|
||||
: "Remove Selected Splits";
|
||||
} else {
|
||||
return existingSplitsCount === 0 ? "Split Selected" : "Complete Selected Splits";
|
||||
return existingSplitsCount === 0
|
||||
? "Split Selected"
|
||||
: "Complete Selected Splits";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -128,7 +137,11 @@ const PageEditorControls = ({
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
variant="subtle"
|
||||
style={{ color: canUndo ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||
style={{
|
||||
color: canUndo
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -140,7 +153,11 @@ const PageEditorControls = ({
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
variant="subtle"
|
||||
style={{ color: canRedo ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||
style={{
|
||||
color: canRedo
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -148,7 +165,14 @@ const PageEditorControls = ({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: "var(--mantine-color-gray-3)", margin: "0 8px" }} />
|
||||
<div
|
||||
style={{
|
||||
width: 1,
|
||||
height: 28,
|
||||
backgroundColor: "var(--mantine-color-gray-3)",
|
||||
margin: "0 8px",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Page Operations */}
|
||||
<Tooltip label="Rotate Selected Left">
|
||||
@@ -156,7 +180,12 @@ const PageEditorControls = ({
|
||||
onClick={() => onRotate("left")}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -168,7 +197,12 @@ const PageEditorControls = ({
|
||||
onClick={() => onRotate("right")}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -180,7 +214,12 @@ const PageEditorControls = ({
|
||||
onClick={onDelete}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -192,7 +231,12 @@ const PageEditorControls = ({
|
||||
onClick={onSplit}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
@@ -204,7 +248,12 @@ const PageEditorControls = ({
|
||||
onClick={onPageBreak}
|
||||
disabled={selectedPageIds.length === 0}
|
||||
variant="subtle"
|
||||
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
||||
style={{
|
||||
color:
|
||||
selectedPageIds.length > 0
|
||||
? "var(--right-rail-icon)"
|
||||
: "var(--right-rail-icon-disabled)",
|
||||
}}
|
||||
radius="md"
|
||||
size="lg"
|
||||
>
|
||||
|
||||
@@ -25,7 +25,13 @@ export default function PageSelectByNumberButton({
|
||||
updatePagesFromCSV,
|
||||
}: PageSelectByNumberButtonProps) {
|
||||
return (
|
||||
<Tooltip content={label} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Tooltip
|
||||
content={label}
|
||||
position="left"
|
||||
offset={12}
|
||||
arrow
|
||||
portalTarget={document.body}
|
||||
>
|
||||
<div className={`right-rail-fade enter`}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { Text, Checkbox } from "@mantine/core";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||
@@ -12,7 +18,9 @@ import { PDFPage, PDFDocument } from "@app/types/pageEditor";
|
||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||
import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors";
|
||||
import styles from "@app/components/pageEditor/PageEditor.module.css";
|
||||
import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu";
|
||||
import HoverActionMenu, {
|
||||
HoverAction,
|
||||
} from "@app/components/shared/HoverActionMenu";
|
||||
import { StirlingFileStub } from "@app/types/fileContext";
|
||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||
|
||||
@@ -31,20 +39,34 @@ interface PageThumbnailProps {
|
||||
justMoved?: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
dragHandleProps?: any;
|
||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
||||
onReorderPages: (
|
||||
sourcePageNumber: number,
|
||||
targetIndex: number,
|
||||
selectedPageIds?: string[],
|
||||
) => void;
|
||||
onTogglePage: (pageId: string) => void;
|
||||
onAnimateReorder: () => void;
|
||||
onExecuteCommand: (command: { execute: () => void }) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onSetMovingPage: (page: number | null) => void;
|
||||
onDeletePage: (pageNumber: number) => void;
|
||||
createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void };
|
||||
createRotateCommand: (
|
||||
pageIds: string[],
|
||||
rotation: number,
|
||||
) => { execute: () => void };
|
||||
createDeleteCommand: (pageIds: string[]) => { execute: () => void };
|
||||
createSplitCommand: (pageId: string, pageNumber: number) => { execute: () => void };
|
||||
createSplitCommand: (
|
||||
pageId: string,
|
||||
pageNumber: number,
|
||||
) => { execute: () => void };
|
||||
pdfDocument: PDFDocument;
|
||||
setPdfDocument: (doc: PDFDocument) => void;
|
||||
splitPositions: Set<string>;
|
||||
onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void;
|
||||
onInsertFiles?: (
|
||||
files: File[] | StirlingFileStub[],
|
||||
insertAfterPage: number,
|
||||
isFromStorage?: boolean,
|
||||
) => void;
|
||||
zoomLevel?: number;
|
||||
}
|
||||
|
||||
@@ -77,15 +99,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
justMoved = false,
|
||||
}: PageThumbnailProps) => {
|
||||
const pageIndex = page.pageNumber - 1;
|
||||
const isSelected = Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false;
|
||||
const isSelected = Array.isArray(selectedPageIds)
|
||||
? selectedPageIds.includes(page.id)
|
||||
: false;
|
||||
|
||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [mouseStartPos, setMouseStartPos] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const lastClickTimeRef = useRef<number>(0);
|
||||
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(
|
||||
page.thumbnail,
|
||||
);
|
||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
|
||||
@@ -95,7 +124,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
// Calculate document aspect ratio from first non-blank page
|
||||
const getDocumentAspectRatio = useCallback(() => {
|
||||
// Find first non-blank page with a thumbnail to get aspect ratio
|
||||
const firstRealPage = pdfDocument.pages.find((p) => !p.isBlankPage && p.thumbnail);
|
||||
const firstRealPage = pdfDocument.pages.find(
|
||||
(p) => !p.isBlankPage && p.thumbnail,
|
||||
);
|
||||
if (firstRealPage?.thumbnail) {
|
||||
// Try to get aspect ratio from an actual thumbnail image
|
||||
// For now, default to A4 but could be enhanced to measure image dimensions
|
||||
@@ -139,7 +170,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
||||
},
|
||||
[page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand],
|
||||
[
|
||||
page.id,
|
||||
page.pageNumber,
|
||||
onExecuteCommand,
|
||||
onSetStatus,
|
||||
createRotateCommand,
|
||||
],
|
||||
);
|
||||
|
||||
const handleRotateRight = useCallback(
|
||||
@@ -150,7 +187,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
||||
},
|
||||
[page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand],
|
||||
[
|
||||
page.id,
|
||||
page.pageNumber,
|
||||
onExecuteCommand,
|
||||
onSetStatus,
|
||||
createRotateCommand,
|
||||
],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
@@ -174,7 +217,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
const action = hasSplit ? "removed" : "added";
|
||||
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
|
||||
},
|
||||
[pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand],
|
||||
[
|
||||
pageIndex,
|
||||
splitPositions,
|
||||
onExecuteCommand,
|
||||
onSetStatus,
|
||||
createSplitCommand,
|
||||
],
|
||||
);
|
||||
|
||||
const handleInsertFileAfter = useCallback(
|
||||
@@ -185,7 +234,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
// Open file manager modal with custom handler for page insertion
|
||||
openFilesModal({
|
||||
insertAfterPage: page.pageNumber,
|
||||
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
|
||||
customHandler: (
|
||||
files: File[] | StirlingFileStub[],
|
||||
insertAfterPage?: number,
|
||||
isFromStorage?: boolean,
|
||||
) => {
|
||||
if (insertAfterPage !== undefined) {
|
||||
onInsertFiles(files, insertAfterPage, isFromStorage);
|
||||
}
|
||||
@@ -242,7 +295,15 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
setIsMouseDown(false);
|
||||
setMouseStartPos(null);
|
||||
},
|
||||
[isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage],
|
||||
[
|
||||
isMouseDown,
|
||||
mouseStartPos,
|
||||
isDragging,
|
||||
page.id,
|
||||
isBoxSelected,
|
||||
clearBoxSelection,
|
||||
onTogglePage,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
@@ -251,7 +312,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
setIsHovered(false);
|
||||
}, []);
|
||||
|
||||
const fileColorBorder = page.isBlankPage ? "transparent" : getFileColorWithOpacity(fileColorIndex, 0.3);
|
||||
const fileColorBorder = page.isBlankPage
|
||||
? "transparent"
|
||||
: getFileColorWithOpacity(fileColorIndex, 0.3);
|
||||
|
||||
// Spread dragHandleProps but use our merged ref
|
||||
const { ref: _, ...restDragProps } = dragHandleProps || {};
|
||||
@@ -420,7 +483,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
height: "100%",
|
||||
backgroundColor: "var(--mantine-color-gray-1)",
|
||||
borderRadius: 6,
|
||||
boxShadow: page.isBlankPage ? "none" : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
|
||||
boxShadow: page.isBlankPage
|
||||
? "none"
|
||||
: `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
|
||||
padding: 4,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
|
||||
+20
-5
@@ -98,7 +98,10 @@ const AdvancedSelectionPanel = ({
|
||||
<Flex direction="row" mb="xs" wrap="wrap">
|
||||
<SelectPages
|
||||
title={t("bulkSelection.firstNPages.title", "First N Pages")}
|
||||
placeholder={t("bulkSelection.firstNPages.placeholder", "Number of pages")}
|
||||
placeholder={t(
|
||||
"bulkSelection.firstNPages.placeholder",
|
||||
"Number of pages",
|
||||
)}
|
||||
onApply={handleFirstNApply}
|
||||
maxPages={maxPages}
|
||||
validationFn={validatePositiveNumber}
|
||||
@@ -113,12 +116,18 @@ const AdvancedSelectionPanel = ({
|
||||
isRange={true}
|
||||
rangeEndValue={rangeEnd}
|
||||
onRangeEndChange={handleRangeEndChange}
|
||||
rangeEndPlaceholder={t("bulkSelection.range.toPlaceholder", "To")}
|
||||
rangeEndPlaceholder={t(
|
||||
"bulkSelection.range.toPlaceholder",
|
||||
"To",
|
||||
)}
|
||||
/>
|
||||
|
||||
<SelectPages
|
||||
title={t("bulkSelection.lastNPages.title", "Last N Pages")}
|
||||
placeholder={t("bulkSelection.lastNPages.placeholder", "Number of pages")}
|
||||
placeholder={t(
|
||||
"bulkSelection.lastNPages.placeholder",
|
||||
"Number of pages",
|
||||
)}
|
||||
onApply={handleLastNApply}
|
||||
maxPages={maxPages}
|
||||
validationFn={validatePositiveNumber}
|
||||
@@ -126,14 +135,20 @@ const AdvancedSelectionPanel = ({
|
||||
|
||||
<SelectPages
|
||||
title={t("bulkSelection.everyNthPage.title", "Every Nth Page")}
|
||||
placeholder={t("bulkSelection.everyNthPage.placeholder", "Step size")}
|
||||
placeholder={t(
|
||||
"bulkSelection.everyNthPage.placeholder",
|
||||
"Step size",
|
||||
)}
|
||||
onApply={handleEveryNthApply}
|
||||
maxPages={maxPages}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
{/* Operators row at bottom */}
|
||||
<OperatorsSection csvInput={csvInput} onInsertOperator={insertOperator} />
|
||||
<OperatorsSection
|
||||
csvInput={csvInput}
|
||||
onInsertOperator={insertOperator}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -8,7 +8,10 @@ interface OperatorsSectionProps {
|
||||
onInsertOperator: (op: LogicalOperator) => void;
|
||||
}
|
||||
|
||||
const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => {
|
||||
const OperatorsSection = ({
|
||||
csvInput,
|
||||
onInsertOperator,
|
||||
}: OperatorsSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
|
||||
@@ -93,7 +93,12 @@ const SelectPages = ({
|
||||
error={Boolean(error)}
|
||||
/>
|
||||
)}
|
||||
<Button size="sm" className={classes.applyButton} onClick={handleApply} disabled={isDisabled}>
|
||||
<Button
|
||||
size="sm"
|
||||
className={classes.applyButton}
|
||||
onClick={handleApply}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -7,7 +7,11 @@ interface SelectedPagesDisplayProps {
|
||||
syntaxError: string | null;
|
||||
}
|
||||
|
||||
const SelectedPagesDisplay = ({ selectedPageIds, displayDocument, syntaxError }: SelectedPagesDisplayProps) => {
|
||||
const SelectedPagesDisplay = ({
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
syntaxError,
|
||||
}: SelectedPagesDisplayProps) => {
|
||||
if (selectedPageIds.length === 0 && !syntaxError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -35,11 +35,15 @@ export class RotatePageCommand extends DOMCommand {
|
||||
|
||||
this.setDocument({
|
||||
...currentDoc,
|
||||
pages: currentDoc.pages.map((p) => (p.id === this.pageId ? { ...p, rotation: newRotation } : p)),
|
||||
pages: currentDoc.pages.map((p) =>
|
||||
p.id === this.pageId ? { ...p, rotation: newRotation } : p,
|
||||
),
|
||||
});
|
||||
|
||||
// Also update DOM immediately for CSS animation on currently-mounted pages
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
const pageElement = document.querySelector(
|
||||
`[data-page-id="${this.pageId}"]`,
|
||||
);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector("img");
|
||||
if (img) img.style.transform = `rotate(${newRotation}deg)`;
|
||||
@@ -54,10 +58,14 @@ export class RotatePageCommand extends DOMCommand {
|
||||
|
||||
this.setDocument({
|
||||
...currentDoc,
|
||||
pages: currentDoc.pages.map((p) => (p.id === this.pageId ? { ...p, rotation: this.originalRotation! } : p)),
|
||||
pages: currentDoc.pages.map((p) =>
|
||||
p.id === this.pageId ? { ...p, rotation: this.originalRotation! } : p,
|
||||
),
|
||||
});
|
||||
|
||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
||||
const pageElement = document.querySelector(
|
||||
`[data-page-id="${this.pageId}"]`,
|
||||
);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector("img");
|
||||
if (img) img.style.transform = `rotate(${this.originalRotation}deg)`;
|
||||
@@ -118,12 +126,17 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
const selectedPageNumbersBefore = this.getSelectedPages();
|
||||
const selectedIdSet = new Set(
|
||||
selectedPageNumbersBefore
|
||||
.map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum)?.id)
|
||||
.map(
|
||||
(pageNum) =>
|
||||
currentDoc.pages.find((p) => p.pageNumber === pageNum)?.id,
|
||||
)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
);
|
||||
|
||||
// Filter out deleted pages by ID (stable across undo/redo)
|
||||
const remainingPages = currentDoc.pages.filter((page) => !this.pageIdsToDelete.includes(page.id));
|
||||
const remainingPages = currentDoc.pages.filter(
|
||||
(page) => !this.pageIdsToDelete.includes(page.id),
|
||||
);
|
||||
|
||||
if (remainingPages.length === 0) {
|
||||
// If all pages would be deleted, clear selection/splits and close PDF
|
||||
@@ -162,7 +175,9 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
// Apply changes
|
||||
this.setDocument(updatedDocument);
|
||||
|
||||
const remainingSelectedPageIds = remainingPages.filter((page) => selectedIdSet.has(page.id)).map((page) => page.id);
|
||||
const remainingSelectedPageIds = remainingPages
|
||||
.filter((page) => selectedIdSet.has(page.id))
|
||||
.map((page) => page.id);
|
||||
this.setSelectedPageIds(remainingSelectedPageIds);
|
||||
|
||||
this.setSplitPositions(newPositions);
|
||||
@@ -175,7 +190,12 @@ export class DeletePagesCommand extends DOMCommand {
|
||||
this.setDocument(this.originalDocument);
|
||||
this.setSplitPositions(this.originalSplitPositions);
|
||||
const restoredIds = this.originalSelectedPages
|
||||
.map((pageNum) => this.originalDocument!.pages.find((page) => page.pageNumber === pageNum)?.id || "")
|
||||
.map(
|
||||
(pageNum) =>
|
||||
this.originalDocument!.pages.find(
|
||||
(page) => page.pageNumber === pageNum,
|
||||
)?.id || "",
|
||||
)
|
||||
.filter((id) => id !== "");
|
||||
this.setSelectedPageIds(restoredIds);
|
||||
}
|
||||
@@ -207,18 +227,28 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
this.originalPages = currentDoc.pages.map((page) => ({ ...page }));
|
||||
|
||||
// Perform the reorder
|
||||
const sourceIndex = currentDoc.pages.findIndex((p) => p.pageNumber === this.sourcePageNumber);
|
||||
const sourceIndex = currentDoc.pages.findIndex(
|
||||
(p) => p.pageNumber === this.sourcePageNumber,
|
||||
);
|
||||
if (sourceIndex === -1) return;
|
||||
|
||||
const newPages = [...currentDoc.pages];
|
||||
|
||||
if (this.selectedPages && this.selectedPages.length > 1 && this.selectedPages.includes(this.sourcePageNumber)) {
|
||||
if (
|
||||
this.selectedPages &&
|
||||
this.selectedPages.length > 1 &&
|
||||
this.selectedPages.includes(this.sourcePageNumber)
|
||||
) {
|
||||
// Multi-page reorder
|
||||
const selectedPageObjects = this.selectedPages
|
||||
.map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum))
|
||||
.map((pageNum) =>
|
||||
currentDoc.pages.find((p) => p.pageNumber === pageNum),
|
||||
)
|
||||
.filter((page) => page !== undefined) as PDFPage[];
|
||||
|
||||
const remainingPages = newPages.filter((page) => !this.selectedPages!.includes(page.pageNumber));
|
||||
const remainingPages = newPages.filter(
|
||||
(page) => !this.selectedPages!.includes(page.pageNumber),
|
||||
);
|
||||
remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects);
|
||||
|
||||
remainingPages.forEach((page, index) => {
|
||||
@@ -231,7 +261,10 @@ export class ReorderPagesCommand extends DOMCommand {
|
||||
const [movedPage] = newPages.splice(sourceIndex, 1);
|
||||
|
||||
// Adjust target index if moving forward (after removal, indices shift)
|
||||
const adjustedTargetIndex = sourceIndex < this.targetIndex ? this.targetIndex - 1 : this.targetIndex;
|
||||
const adjustedTargetIndex =
|
||||
sourceIndex < this.targetIndex
|
||||
? this.targetIndex - 1
|
||||
: this.targetIndex;
|
||||
|
||||
newPages.splice(adjustedTargetIndex, 0, movedPage);
|
||||
|
||||
@@ -346,7 +379,8 @@ export class BulkRotateCommand extends DOMCommand {
|
||||
const newRotationById = new Map<string, number>();
|
||||
const updatedPages = currentDoc.pages.map((page) => {
|
||||
if (pageIdSet.has(page.id)) {
|
||||
const newRotation = (((page.rotation + this.degrees) % 360) + 360) % 360;
|
||||
const newRotation =
|
||||
(((page.rotation + this.degrees) % 360) + 360) % 360;
|
||||
newRotationById.set(page.id, newRotation);
|
||||
return { ...page, rotation: newRotation };
|
||||
}
|
||||
@@ -360,7 +394,8 @@ export class BulkRotateCommand extends DOMCommand {
|
||||
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
|
||||
if (pageElement) {
|
||||
const img = pageElement.querySelector("img");
|
||||
if (img) img.style.transform = `rotate(${newRotationById.get(pageId)}deg)`;
|
||||
if (img)
|
||||
img.style.transform = `rotate(${newRotationById.get(pageId)}deg)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -371,7 +406,9 @@ export class BulkRotateCommand extends DOMCommand {
|
||||
|
||||
// Restore original rotations in state
|
||||
const updatedPages = currentDoc.pages.map((page) =>
|
||||
this.originalRotations.has(page.id) ? { ...page, rotation: this.originalRotations.get(page.id)! } : page,
|
||||
this.originalRotations.has(page.id)
|
||||
? { ...page, rotation: this.originalRotations.get(page.id)! }
|
||||
: page,
|
||||
);
|
||||
|
||||
this.setDocument({ ...currentDoc, pages: updatedPages });
|
||||
@@ -455,7 +492,9 @@ export class SplitAllCommand extends DOMCommand {
|
||||
|
||||
// Check if all splits are already active
|
||||
const currentSplits = this.getSplitPositions();
|
||||
const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) => currentSplits.has(pos));
|
||||
const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) =>
|
||||
currentSplits.has(pos),
|
||||
);
|
||||
|
||||
if (hasAllSplits) {
|
||||
// Remove all splits
|
||||
@@ -473,7 +512,9 @@ export class SplitAllCommand extends DOMCommand {
|
||||
|
||||
get description(): string {
|
||||
const currentSplits = this.getSplitPositions();
|
||||
const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) => currentSplits.has(pos));
|
||||
const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) =>
|
||||
currentSplits.has(pos),
|
||||
);
|
||||
return hasAllSplits ? "Remove all splits" : "Split all pages";
|
||||
}
|
||||
}
|
||||
@@ -622,7 +663,9 @@ export class BulkPageBreakCommand extends DOMCommand {
|
||||
// Find the original page by matching the page ID from the original document
|
||||
const originalPage = this.originalDocument?.pages[originalPageNum - 1];
|
||||
if (originalPage) {
|
||||
const foundPage = newPages.find((page) => page.id === originalPage.id && !page.isBlankPage);
|
||||
const foundPage = newPages.find(
|
||||
(page) => page.id === originalPage.id && !page.isBlankPage,
|
||||
);
|
||||
if (foundPage) {
|
||||
updatedSelection.push(foundPage.pageNumber);
|
||||
}
|
||||
@@ -655,7 +698,10 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
private setDocument: (doc: PDFDocument) => void,
|
||||
private setSelectedPages: (pages: number[]) => void,
|
||||
private getSelectedPages: () => number[],
|
||||
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<FileId, File>) => void,
|
||||
private updateFileContext?: (
|
||||
updatedDocument: PDFDocument,
|
||||
insertedFiles?: Map<FileId, File>,
|
||||
) => void,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -677,7 +723,8 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
// Process all files and wait for their completion
|
||||
const baseTimestamp = Date.now();
|
||||
const extractionPromises = this.files.map(async (file, index) => {
|
||||
const fileId = `inserted-${file.name}-${baseTimestamp + index}` as FileId;
|
||||
const fileId =
|
||||
`inserted-${file.name}-${baseTimestamp + index}` as FileId;
|
||||
// Store inserted file for export
|
||||
this.insertedFileMap.set(fileId, file);
|
||||
// Use base timestamp + index to ensure unique but predictable file IDs
|
||||
@@ -702,7 +749,10 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
|
||||
// Add pages before insertion point
|
||||
for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) {
|
||||
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
|
||||
const page = {
|
||||
...currentDoc.pages[i],
|
||||
pageNumber: pageNumberCounter++,
|
||||
};
|
||||
newPages.push(page);
|
||||
}
|
||||
|
||||
@@ -720,7 +770,10 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
|
||||
// Add remaining pages after insertion point
|
||||
for (let i = insertIndex; i < currentDoc.pages.length; i++) {
|
||||
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
|
||||
const page = {
|
||||
...currentDoc.pages[i],
|
||||
pageNumber: pageNumberCounter++,
|
||||
};
|
||||
newPages.push(page);
|
||||
}
|
||||
|
||||
@@ -765,9 +818,12 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise<void> {
|
||||
private async generateThumbnailsForInsertedPages(
|
||||
updatedDocument: PDFDocument,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { thumbnailGenerationService } = await import("@app/services/thumbnailGenerationService");
|
||||
const { thumbnailGenerationService } =
|
||||
await import("@app/services/thumbnailGenerationService");
|
||||
|
||||
// Group pages by file ID to generate thumbnails efficiently
|
||||
const pagesByFileId = new Map<FileId, PDFPage[]>();
|
||||
@@ -789,7 +845,10 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
|
||||
console.log("Generating thumbnails for file:", fileId);
|
||||
console.log("Pages:", pages.length);
|
||||
console.log("ArrayBuffer size:", arrayBuffer?.byteLength || "undefined");
|
||||
console.log(
|
||||
"ArrayBuffer size:",
|
||||
arrayBuffer?.byteLength || "undefined",
|
||||
);
|
||||
|
||||
try {
|
||||
if (arrayBuffer && arrayBuffer.byteLength > 0) {
|
||||
@@ -802,12 +861,21 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
console.log("Generating thumbnails for page numbers:", pageNumbers);
|
||||
|
||||
// Generate thumbnails for all pages from this file at once
|
||||
const results = await thumbnailGenerationService.generateThumbnails(fileId, arrayBuffer, pageNumbers, {
|
||||
const results = await thumbnailGenerationService.generateThumbnails(
|
||||
fileId,
|
||||
arrayBuffer,
|
||||
pageNumbers,
|
||||
{
|
||||
scale: 0.2,
|
||||
quality: 0.8,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
console.log("Thumbnail generation results:", results.length, "thumbnails generated");
|
||||
console.log(
|
||||
"Thumbnail generation results:",
|
||||
results.length,
|
||||
"thumbnails generated",
|
||||
);
|
||||
|
||||
// Update pages with generated thumbnails
|
||||
for (let i = 0; i < results.length && i < pages.length; i++) {
|
||||
@@ -815,7 +883,9 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
const page = pages[i];
|
||||
|
||||
if (result.success) {
|
||||
const pageIndex = updatedDocument.pages.findIndex((p) => p.id === page.id);
|
||||
const pageIndex = updatedDocument.pages.findIndex(
|
||||
(p) => p.id === page.id,
|
||||
);
|
||||
if (pageIndex >= 0) {
|
||||
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
|
||||
console.log("Updated thumbnail for page:", page.id);
|
||||
@@ -829,7 +899,11 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
console.error("No valid ArrayBuffer found for file ID:", fileId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to generate thumbnails for file:", fileId, error);
|
||||
console.error(
|
||||
"Failed to generate thumbnails for file:",
|
||||
fileId,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
this.fileDataMap.delete(fileId);
|
||||
}
|
||||
@@ -839,13 +913,19 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
}
|
||||
}
|
||||
|
||||
private async extractPagesFromFile(file: File, baseTimestamp: number): Promise<PDFPage[]> {
|
||||
private async extractPagesFromFile(
|
||||
file: File,
|
||||
baseTimestamp: number,
|
||||
): Promise<PDFPage[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||||
console.log("File reader onload - arrayBuffer size:", arrayBuffer?.byteLength || "undefined");
|
||||
console.log(
|
||||
"File reader onload - arrayBuffer size:",
|
||||
arrayBuffer?.byteLength || "undefined",
|
||||
);
|
||||
|
||||
if (!arrayBuffer) {
|
||||
reject(new Error("Failed to read file"));
|
||||
@@ -856,7 +936,8 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
const clonedArrayBuffer = arrayBuffer.slice(0);
|
||||
|
||||
// Use PDF.js via the worker manager to extract pages
|
||||
const { pdfWorkerManager } = await import("@app/services/pdfWorkerManager");
|
||||
const { pdfWorkerManager } =
|
||||
await import("@app/services/pdfWorkerManager");
|
||||
const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
|
||||
|
||||
const pageCount = pdf.numPages;
|
||||
@@ -864,13 +945,24 @@ export class InsertFilesCommand extends DOMCommand {
|
||||
const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId;
|
||||
|
||||
console.log("Original ArrayBuffer size:", arrayBuffer.byteLength);
|
||||
console.log("Storing ArrayBuffer for fileId:", fileId, "size:", arrayBuffer.byteLength);
|
||||
console.log(
|
||||
"Storing ArrayBuffer for fileId:",
|
||||
fileId,
|
||||
"size:",
|
||||
arrayBuffer.byteLength,
|
||||
);
|
||||
|
||||
// Store the original ArrayBuffer for thumbnail generation
|
||||
this.fileDataMap.set(fileId, arrayBuffer);
|
||||
|
||||
console.log("After storing - fileDataMap size:", this.fileDataMap.size);
|
||||
console.log("Stored value size:", this.fileDataMap.get(fileId)?.byteLength || "undefined");
|
||||
console.log(
|
||||
"After storing - fileDataMap size:",
|
||||
this.fileDataMap.size,
|
||||
);
|
||||
console.log(
|
||||
"Stored value size:",
|
||||
this.fileDataMap.get(fileId)?.byteLength || "undefined",
|
||||
);
|
||||
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
const pageId = `${fileId}-page-${i}`;
|
||||
|
||||
@@ -54,7 +54,10 @@ export function getFileColor(index: number): string {
|
||||
* @param opacity - Opacity value (0-1), defaults to 0.3
|
||||
* @returns RGBA color string
|
||||
*/
|
||||
export function getFileColorWithOpacity(index: number, opacity: number = 0.2): string {
|
||||
export function getFileColorWithOpacity(
|
||||
index: number,
|
||||
opacity: number = 0.2,
|
||||
): string {
|
||||
const rgb = getFileColor(index);
|
||||
// Convert rgb(r, g, b) to rgba(r, g, b, a)
|
||||
return rgb.replace("rgb(", "rgba(").replace(")", `, ${opacity})`);
|
||||
|
||||
@@ -20,7 +20,9 @@ export const useEditedDocumentState = ({
|
||||
fileOrder,
|
||||
updateCurrentPages,
|
||||
}: UseEditedDocumentStateParams) => {
|
||||
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
|
||||
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(
|
||||
null,
|
||||
);
|
||||
const editedDocumentRef = useRef<PDFDocument | null>(null);
|
||||
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
|
||||
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
|
||||
@@ -89,7 +91,8 @@ export const useEditedDocumentState = ({
|
||||
const currentEditedDocument = editedDocumentRef.current;
|
||||
if (!mergedPdfDocument || !currentEditedDocument) return;
|
||||
|
||||
const signatureChanged = mergedDocSignature !== lastSyncedSignatureRef.current;
|
||||
const signatureChanged =
|
||||
mergedDocSignature !== lastSyncedSignatureRef.current;
|
||||
const metadataChanged =
|
||||
currentEditedDocument.id !== mergedPdfDocument.id ||
|
||||
currentEditedDocument.file !== mergedPdfDocument.file ||
|
||||
@@ -117,7 +120,8 @@ export const useEditedDocumentState = ({
|
||||
}
|
||||
|
||||
const hasAdditions = newPages.length > 0;
|
||||
const isEphemeralPage = (page: PDFPage) => Boolean(page.isBlankPage || page.isPlaceholder);
|
||||
const isEphemeralPage = (page: PDFPage) =>
|
||||
Boolean(page.isBlankPage || page.isPlaceholder);
|
||||
|
||||
let hasRemovals = false;
|
||||
for (const page of prev.pages) {
|
||||
@@ -142,12 +146,16 @@ export const useEditedDocumentState = ({
|
||||
const nextInsertIndexByFile = new Map(placeholderPositions);
|
||||
|
||||
if (hasRemovals) {
|
||||
pages = pages.filter((page) => sourceIds.has(page.id) || isEphemeralPage(page));
|
||||
pages = pages.filter(
|
||||
(page) => sourceIds.has(page.id) || isEphemeralPage(page),
|
||||
);
|
||||
}
|
||||
|
||||
if (hasAdditions) {
|
||||
const mergedIndexMap = new Map<string, number>();
|
||||
sourcePages.forEach((page, index) => mergedIndexMap.set(page.id, index));
|
||||
sourcePages.forEach((page, index) =>
|
||||
mergedIndexMap.set(page.id, index),
|
||||
);
|
||||
|
||||
const additions = newPages
|
||||
.map((page) => ({
|
||||
@@ -163,14 +171,18 @@ export const useEditedDocumentState = ({
|
||||
return a.mergedIndex - b.mergedIndex;
|
||||
});
|
||||
|
||||
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => {
|
||||
additions.forEach(
|
||||
({ page, neighborId, cachedIndex, mergedIndex }) => {
|
||||
if (pages.some((existing) => existing.id === page.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let insertIndex: number;
|
||||
const originalFileId = page.originalFileId;
|
||||
const placeholderIndex = originalFileId !== undefined ? nextInsertIndexByFile.get(originalFileId) : undefined;
|
||||
const placeholderIndex =
|
||||
originalFileId !== undefined
|
||||
? nextInsertIndexByFile.get(originalFileId)
|
||||
: undefined;
|
||||
|
||||
if (originalFileId && placeholderIndex !== undefined) {
|
||||
insertIndex = Math.min(placeholderIndex, pages.length);
|
||||
@@ -178,21 +190,26 @@ export const useEditedDocumentState = ({
|
||||
} else if (neighborId === null) {
|
||||
insertIndex = 0;
|
||||
} else if (neighborId) {
|
||||
const neighborIndex = pages.findIndex((p) => p.id === neighborId);
|
||||
const neighborIndex = pages.findIndex(
|
||||
(p) => p.id === neighborId,
|
||||
);
|
||||
if (neighborIndex !== -1) {
|
||||
insertIndex = neighborIndex + 1;
|
||||
} else {
|
||||
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
|
||||
const fallbackIndex =
|
||||
cachedIndex ?? mergedIndex ?? pages.length;
|
||||
insertIndex = Math.min(fallbackIndex, pages.length);
|
||||
}
|
||||
} else {
|
||||
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
|
||||
const fallbackIndex =
|
||||
cachedIndex ?? mergedIndex ?? pages.length;
|
||||
insertIndex = Math.min(fallbackIndex, pages.length);
|
||||
}
|
||||
|
||||
const clonedPage = { ...page };
|
||||
pages.splice(insertIndex, 0, clonedPage);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,4 +261,6 @@ export const useEditedDocumentState = ({
|
||||
};
|
||||
};
|
||||
|
||||
export type UseEditedDocumentStateReturn = ReturnType<typeof useEditedDocumentState>;
|
||||
export type UseEditedDocumentStateReturn = ReturnType<
|
||||
typeof useEditedDocumentState
|
||||
>;
|
||||
|
||||
@@ -63,7 +63,12 @@ export const usePageEditorCommands = ({
|
||||
|
||||
const handleRotatePages = useCallback(
|
||||
(pageIds: string[], rotation: number) => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation, getEditedDocument, setEditedDocument);
|
||||
const bulkRotateCommand = new BulkRotateCommand(
|
||||
pageIds,
|
||||
rotation,
|
||||
getEditedDocument,
|
||||
setEditedDocument,
|
||||
);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
},
|
||||
[executeCommandWithTracking, getEditedDocument, setEditedDocument],
|
||||
@@ -72,7 +77,12 @@ export const usePageEditorCommands = ({
|
||||
const createRotateCommand = useCallback(
|
||||
(pageIds: string[], rotation: number) => ({
|
||||
execute: () => {
|
||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation, getEditedDocument, setEditedDocument);
|
||||
const bulkRotateCommand = new BulkRotateCommand(
|
||||
pageIds,
|
||||
rotation,
|
||||
getEditedDocument,
|
||||
setEditedDocument,
|
||||
);
|
||||
executeCommandWithTracking(bulkRotateCommand);
|
||||
},
|
||||
}),
|
||||
@@ -123,7 +133,12 @@ export const usePageEditorCommands = ({
|
||||
const createSplitCommand = useCallback(
|
||||
(pageId: string, pageNumber: number) => ({
|
||||
execute: () => {
|
||||
const splitCommand = new SplitCommand(pageId, pageNumber, () => splitPositionsRef.current, setSplitPositions);
|
||||
const splitCommand = new SplitCommand(
|
||||
pageId,
|
||||
pageNumber,
|
||||
() => splitPositionsRef.current,
|
||||
setSplitPositions,
|
||||
);
|
||||
executeCommandWithTracking(splitCommand);
|
||||
},
|
||||
}),
|
||||
@@ -208,12 +223,18 @@ export const usePageEditorCommands = ({
|
||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||
|
||||
const selectedSplitPageIds = displayDocument.pages
|
||||
.filter((page, index) => selectedPageIds.includes(page.id) && index < displayDocument.pages.length - 1)
|
||||
.filter(
|
||||
(page, index) =>
|
||||
selectedPageIds.includes(page.id) &&
|
||||
index < displayDocument.pages.length - 1,
|
||||
)
|
||||
.map((page) => page.id);
|
||||
|
||||
if (selectedSplitPageIds.length === 0) return;
|
||||
|
||||
const existingSplitsCount = selectedSplitPageIds.filter((id) => splitPositions.has(id)).length;
|
||||
const existingSplitsCount = selectedSplitPageIds.filter((id) =>
|
||||
splitPositions.has(id),
|
||||
).length;
|
||||
const noSplitsCount = selectedSplitPageIds.length - existingSplitsCount;
|
||||
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||
|
||||
@@ -234,7 +255,14 @@ export const usePageEditorCommands = ({
|
||||
};
|
||||
|
||||
executeCommandWithTracking(smartSplitCommand);
|
||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
||||
}, [
|
||||
selectedPageIds,
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
setSplitPositions,
|
||||
getPageNumbersFromIds,
|
||||
executeCommandWithTracking,
|
||||
]);
|
||||
|
||||
const handleSplitAll = handleSplit;
|
||||
|
||||
@@ -243,7 +271,11 @@ export const usePageEditorCommands = ({
|
||||
|
||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||
|
||||
const pageBreakCommand = new PageBreakCommand(selectedPageNumbers, getEditedDocument, setEditedDocument);
|
||||
const pageBreakCommand = new PageBreakCommand(
|
||||
selectedPageNumbers,
|
||||
getEditedDocument,
|
||||
setEditedDocument,
|
||||
);
|
||||
executeCommandWithTracking(pageBreakCommand);
|
||||
}, [
|
||||
displayDocument,
|
||||
@@ -257,7 +289,11 @@ export const usePageEditorCommands = ({
|
||||
const handlePageBreakAll = handlePageBreak;
|
||||
|
||||
const handleInsertFiles = useCallback(
|
||||
async (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => {
|
||||
async (
|
||||
files: File[] | StirlingFileStub[],
|
||||
insertAfterPage: number,
|
||||
isFromStorage?: boolean,
|
||||
) => {
|
||||
console.log("[PageEditor] handleInsertFiles called:", {
|
||||
fileCount: files.length,
|
||||
insertAfterPage,
|
||||
@@ -274,7 +310,9 @@ export const usePageEditorCommands = ({
|
||||
}
|
||||
|
||||
try {
|
||||
const targetPage = workingDocument.pages.find((p) => p.pageNumber === insertAfterPage);
|
||||
const targetPage = workingDocument.pages.find(
|
||||
(p) => p.pageNumber === insertAfterPage,
|
||||
);
|
||||
if (!targetPage) {
|
||||
console.log("[PageEditor] Target page not found:", insertAfterPage);
|
||||
return;
|
||||
@@ -317,7 +355,8 @@ export const usePageEditorCommands = ({
|
||||
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
|
||||
pageNumber: page.pageNumber ?? idx + 1,
|
||||
originalFileId: fileId,
|
||||
originalPageNumber: page.originalPageNumber ?? page.pageNumber ?? idx + 1,
|
||||
originalPageNumber:
|
||||
page.originalPageNumber ?? page.pageNumber ?? idx + 1,
|
||||
rotation: page.rotation ?? 0,
|
||||
thumbnail: page.thumbnail ?? null,
|
||||
selected: false,
|
||||
@@ -328,7 +367,9 @@ export const usePageEditorCommands = ({
|
||||
}
|
||||
|
||||
if (newPages.length > 0) {
|
||||
const targetIndex = workingDocument.pages.findIndex((p) => p.id === targetPage.id);
|
||||
const targetIndex = workingDocument.pages.findIndex(
|
||||
(p) => p.id === targetPage.id,
|
||||
);
|
||||
|
||||
if (targetIndex >= 0) {
|
||||
const updatedPages = [...workingDocument.pages];
|
||||
@@ -350,14 +391,26 @@ export const usePageEditorCommands = ({
|
||||
console.error("Failed to insert files:", error);
|
||||
}
|
||||
},
|
||||
[getEditedDocument, actions, selectors, updateFileOrderFromPages, setEditedDocument],
|
||||
[
|
||||
getEditedDocument,
|
||||
actions,
|
||||
selectors,
|
||||
updateFileOrderFromPages,
|
||||
setEditedDocument,
|
||||
],
|
||||
);
|
||||
|
||||
const handleReorderPages = useCallback(
|
||||
(sourcePageNumber: number, targetIndex: number, draggedPageIds?: string[]) => {
|
||||
(
|
||||
sourcePageNumber: number,
|
||||
targetIndex: number,
|
||||
draggedPageIds?: string[],
|
||||
) => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
const selectedPages = draggedPageIds ? getPageNumbersFromIds(draggedPageIds) : undefined;
|
||||
const selectedPages = draggedPageIds
|
||||
? getPageNumbersFromIds(draggedPageIds)
|
||||
: undefined;
|
||||
|
||||
const reorderCommand = new ReorderPagesCommand(
|
||||
sourcePageNumber,
|
||||
@@ -397,4 +450,6 @@ export const usePageEditorCommands = ({
|
||||
};
|
||||
};
|
||||
|
||||
export type UsePageEditorCommandsReturn = ReturnType<typeof usePageEditorCommands>;
|
||||
export type UsePageEditorCommandsReturn = ReturnType<
|
||||
typeof usePageEditorCommands
|
||||
>;
|
||||
|
||||
@@ -8,7 +8,9 @@ import { PDFDocument } from "@app/types/pageEditor";
|
||||
*/
|
||||
export function useInitialPageDocument(): PDFDocument | null {
|
||||
const { document: liveDocument } = usePageDocument();
|
||||
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(null);
|
||||
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(
|
||||
null,
|
||||
);
|
||||
const lastDocumentIdRef = useRef<string | null>(null);
|
||||
const liveDocumentId = liveDocument?.id ?? null;
|
||||
|
||||
@@ -30,7 +32,11 @@ export function useInitialPageDocument(): PDFDocument | null {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📄 useInitialPageDocument: Captured initial document with", liveDocument.pages.length, "pages");
|
||||
console.log(
|
||||
"📄 useInitialPageDocument: Captured initial document with",
|
||||
liveDocument.pages.length,
|
||||
"pages",
|
||||
);
|
||||
setInitialDocument(liveDocument);
|
||||
}, [liveDocument, initialDocument]);
|
||||
|
||||
|
||||
@@ -17,7 +17,12 @@ export interface PageDocumentHook {
|
||||
*/
|
||||
export function usePageDocument(): PageDocumentHook {
|
||||
const { state, selectors } = useFileState();
|
||||
const { fileOrder, currentPages, persistedDocument, persistedDocumentSignature } = usePageEditor();
|
||||
const {
|
||||
fileOrder,
|
||||
currentPages,
|
||||
persistedDocument,
|
||||
persistedDocumentSignature,
|
||||
} = usePageEditor();
|
||||
|
||||
// Use PageEditorContext's fileOrder instead of FileContext's global order
|
||||
// This ensures the page editor respects its own workspace ordering
|
||||
@@ -55,9 +60,12 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const globalProcessing = state.ui.isProcessing;
|
||||
|
||||
// Get primary file record outside useMemo to track processedFile changes
|
||||
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
|
||||
const primaryStirlingFileStub = primaryFileId
|
||||
? selectors.getStirlingFileStub(primaryFileId)
|
||||
: null;
|
||||
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
|
||||
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
||||
const processedFileTotalPages =
|
||||
primaryStirlingFileStub?.processedFile?.totalPages;
|
||||
|
||||
const placeholderDocumentRef = useRef<PDFDocument | null>(null);
|
||||
const [placeholderVersion, setPlaceholderVersion] = useState(0);
|
||||
@@ -89,7 +97,9 @@ export function usePageDocument(): PageDocumentHook {
|
||||
const analysis = await FileAnalyzer.quickPDFAnalysis(file);
|
||||
if (canceled) return;
|
||||
const totalPages = Math.max(1, analysis.pageCount || 1);
|
||||
const pages: PDFPage[] = Array.from({ length: totalPages }, (_, index) => ({
|
||||
const pages: PDFPage[] = Array.from(
|
||||
{ length: totalPages },
|
||||
(_, index) => ({
|
||||
id: `placeholder-${primaryFileId}-page-${index + 1}`,
|
||||
pageNumber: index + 1,
|
||||
thumbnail: null,
|
||||
@@ -97,12 +107,14 @@ export function usePageDocument(): PageDocumentHook {
|
||||
selected: false,
|
||||
originalFileId: primaryFileId,
|
||||
originalPageNumber: index + 1,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
if (!canceled) {
|
||||
placeholderDocumentRef.current = {
|
||||
id: `placeholder-${primaryFileId}`,
|
||||
name: selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name,
|
||||
name:
|
||||
selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name,
|
||||
file,
|
||||
pages,
|
||||
totalPages,
|
||||
@@ -147,7 +159,13 @@ export function usePageDocument(): PageDocumentHook {
|
||||
// Check if persisted document is still valid
|
||||
// Must match signature AND have the same number of source files
|
||||
const persistedFileIds = persistedDocument
|
||||
? Array.from(new Set(persistedDocument.pages.map((p) => p.originalFileId).filter(Boolean)))
|
||||
? Array.from(
|
||||
new Set(
|
||||
persistedDocument.pages
|
||||
.map((p) => p.originalFileId)
|
||||
.filter(Boolean),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const persistedIsValid =
|
||||
persistedDocument &&
|
||||
@@ -160,14 +178,20 @@ export function usePageDocument(): PageDocumentHook {
|
||||
console.log("[usePageDocument] Using persisted document");
|
||||
return persistedDocument;
|
||||
} else if (persistedDocument) {
|
||||
console.log("[usePageDocument] Persisted document invalid - rebuilding:", {
|
||||
console.log(
|
||||
"[usePageDocument] Persisted document invalid - rebuilding:",
|
||||
{
|
||||
sigMatch: persistedDocumentSignature === currentPagesSignature,
|
||||
persistedFiles: persistedFileIds.length,
|
||||
activeFiles: activeFileIds.length,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!primaryStirlingFileStub?.processedFile && placeholderDocumentRef.current) {
|
||||
if (
|
||||
!primaryStirlingFileStub?.processedFile &&
|
||||
placeholderDocumentRef.current
|
||||
) {
|
||||
return placeholderDocumentRef.current;
|
||||
}
|
||||
|
||||
@@ -175,18 +199,29 @@ export function usePageDocument(): PageDocumentHook {
|
||||
|
||||
// If we have file IDs but no file record, something is wrong - return null to show loading
|
||||
if (!primaryStirlingFileStub) {
|
||||
console.log("🎬 PageEditor: No primary file record found, showing loading");
|
||||
console.log(
|
||||
"🎬 PageEditor: No primary file record found, showing loading",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const namingFileIds = selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds;
|
||||
const namingFileIds =
|
||||
selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds;
|
||||
|
||||
const name =
|
||||
namingFileIds.length <= 1
|
||||
? namingFileIds[0]
|
||||
? (selectors.getStirlingFileStub(namingFileIds[0])?.name ?? "document.pdf")
|
||||
? (selectors.getStirlingFileStub(namingFileIds[0])?.name ??
|
||||
"document.pdf")
|
||||
: "document.pdf"
|
||||
: namingFileIds.map((id) => (selectors.getStirlingFileStub(id)?.name ?? "file").replace(/\.pdf$/i, "")).join(" + ");
|
||||
: namingFileIds
|
||||
.map((id) =>
|
||||
(selectors.getStirlingFileStub(id)?.name ?? "file").replace(
|
||||
/\.pdf$/i,
|
||||
"",
|
||||
),
|
||||
)
|
||||
.join(" + ");
|
||||
|
||||
// Build page insertion map from files with insertion positions
|
||||
const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds
|
||||
@@ -218,7 +253,11 @@ export function usePageDocument(): PageDocumentHook {
|
||||
let pages: PDFPage[];
|
||||
|
||||
// Helper function to create pages from a file (or placeholder if deselected)
|
||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number, isSelected: boolean): PDFPage[] => {
|
||||
const createPagesFromFile = (
|
||||
fileId: FileId,
|
||||
startPageNumber: number,
|
||||
isSelected: boolean,
|
||||
): PDFPage[] => {
|
||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||
if (!stirlingFileStub) {
|
||||
return [];
|
||||
@@ -262,7 +301,9 @@ export function usePageDocument(): PageDocumentHook {
|
||||
|
||||
if (processedFile?.totalPages) {
|
||||
// Fallback: create pages without thumbnails but with correct count
|
||||
return Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({
|
||||
return Array.from(
|
||||
{ length: processedFile.totalPages },
|
||||
(_, pageIndex) => ({
|
||||
id: `${fileId}-${pageIndex + 1}`,
|
||||
pageNumber: startPageNumber + pageIndex,
|
||||
originalPageNumber: pageIndex + 1,
|
||||
@@ -272,7 +313,8 @@ export function usePageDocument(): PageDocumentHook {
|
||||
selected: false,
|
||||
splitAfter: false,
|
||||
isPlaceholder: false,
|
||||
}));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// No processedFile yet - create a single loading placeholder
|
||||
@@ -320,7 +362,9 @@ export function usePageDocument(): PageDocumentHook {
|
||||
|
||||
// Process each insertion by finding the page ID and inserting after it
|
||||
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) {
|
||||
const targetPageIndex = pages.findIndex((p) => p.id === insertAfterPageId);
|
||||
const targetPageIndex = pages.findIndex(
|
||||
(p) => p.id === insertAfterPageId,
|
||||
);
|
||||
|
||||
if (targetPageIndex === -1) continue;
|
||||
|
||||
@@ -352,14 +396,22 @@ export function usePageDocument(): PageDocumentHook {
|
||||
pageNumber: index + 1,
|
||||
}));
|
||||
|
||||
const currentPagesSet = currentPages ? new Set(currentPages.map((page) => page.id)) : null;
|
||||
if (currentPagesSet && currentPages && currentPagesSet.size === pages.length) {
|
||||
const currentPagesSet = currentPages
|
||||
? new Set(currentPages.map((page) => page.id))
|
||||
: null;
|
||||
if (
|
||||
currentPagesSet &&
|
||||
currentPages &&
|
||||
currentPagesSet.size === pages.length
|
||||
) {
|
||||
const sameIds = pages.every((page) => currentPagesSet.has(page.id));
|
||||
if (sameIds) {
|
||||
const mergedById = new Map(pages.map((page) => [page.id, page]));
|
||||
pages = currentPages.map((currentPage, index) => {
|
||||
const source = mergedById.get(currentPage.id);
|
||||
const mergedPage = source ? { ...source, ...currentPage } : currentPage;
|
||||
const mergedPage = source
|
||||
? { ...source, ...currentPage }
|
||||
: currentPage;
|
||||
return {
|
||||
...mergedPage,
|
||||
pageNumber: index + 1,
|
||||
|
||||
@@ -20,7 +20,8 @@ export interface PageEditorDropdownState {
|
||||
fileColorMap: Map<FileId, number>;
|
||||
}
|
||||
|
||||
const isPdf = (name?: string | null) => typeof name === "string" && name.toLowerCase().endsWith(".pdf");
|
||||
const isPdf = (name?: string | null) =>
|
||||
typeof name === "string" && name.toLowerCase().endsWith(".pdf");
|
||||
|
||||
export function usePageEditorDropdownState(): PageEditorDropdownState {
|
||||
const { state, selectors } = useFileState();
|
||||
@@ -42,9 +43,14 @@ export function usePageEditorDropdownState(): PageEditorDropdownState {
|
||||
.filter((file): file is PageEditorDropdownFile => file !== null);
|
||||
}, [fileOrder, selectors, state.ui.selectedFileIds]);
|
||||
|
||||
const fileColorMap = useFileColorMap(pageEditorFiles.map((file) => file.fileId));
|
||||
const fileColorMap = useFileColorMap(
|
||||
pageEditorFiles.map((file) => file.fileId),
|
||||
);
|
||||
|
||||
const selectedCount = useMemo(() => pageEditorFiles.filter((file) => file.isSelected).length, [pageEditorFiles]);
|
||||
const selectedCount = useMemo(
|
||||
() => pageEditorFiles.filter((file) => file.isSelected).length,
|
||||
[pageEditorFiles],
|
||||
);
|
||||
|
||||
return useMemo<PageEditorDropdownState>(
|
||||
() => ({
|
||||
@@ -55,6 +61,12 @@ export function usePageEditorDropdownState(): PageEditorDropdownState {
|
||||
onReorder: reorderFiles,
|
||||
fileColorMap,
|
||||
}),
|
||||
[pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap],
|
||||
[
|
||||
pageEditorFiles,
|
||||
selectedCount,
|
||||
toggleFileSelection,
|
||||
reorderFiles,
|
||||
fileColorMap,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,9 +43,13 @@ const removePlaceholderPages = (document: PDFDocument): PDFDocument => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeProcessedDocuments = (processed: PDFDocument | PDFDocument[]): PDFDocument | PDFDocument[] => {
|
||||
const normalizeProcessedDocuments = (
|
||||
processed: PDFDocument | PDFDocument[],
|
||||
): PDFDocument | PDFDocument[] => {
|
||||
if (Array.isArray(processed)) {
|
||||
const normalized = processed.map(removePlaceholderPages).filter((doc) => doc.pages.length > 0);
|
||||
const normalized = processed
|
||||
.map(removePlaceholderPages)
|
||||
.filter((doc) => doc.pages.length > 0);
|
||||
return normalized;
|
||||
}
|
||||
return removePlaceholderPages(processed);
|
||||
@@ -104,17 +108,23 @@ export const usePageEditorExport = ({
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
const processedDocuments =
|
||||
documentManipulationService.applyDOMChangesToDocument(
|
||||
displayDocument,
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
);
|
||||
|
||||
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
||||
const documentWithDOMState = Array.isArray(normalizedDocuments) ? normalizedDocuments[0] : normalizedDocuments;
|
||||
const normalizedDocuments =
|
||||
normalizeProcessedDocuments(processedDocuments);
|
||||
const documentWithDOMState = Array.isArray(normalizedDocuments)
|
||||
? normalizedDocuments[0]
|
||||
: normalizedDocuments;
|
||||
|
||||
if (!documentWithDOMState || documentWithDOMState.pages.length === 0) {
|
||||
console.warn("Export skipped: no concrete pages available after filtering placeholders.");
|
||||
console.warn(
|
||||
"Export skipped: no concrete pages available after filtering placeholders.",
|
||||
);
|
||||
setExportLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -126,14 +136,23 @@ export const usePageEditorExport = ({
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const result = sourceFiles
|
||||
? await pdfExportService.exportPDFMultiFile(documentWithDOMState, sourceFiles, validSelectedPageIds, {
|
||||
? await pdfExportService.exportPDFMultiFile(
|
||||
documentWithDOMState,
|
||||
sourceFiles,
|
||||
validSelectedPageIds,
|
||||
{
|
||||
selectedOnly: true,
|
||||
filename: exportFilename,
|
||||
})
|
||||
: await pdfExportService.exportPDF(documentWithDOMState, validSelectedPageIds, {
|
||||
},
|
||||
)
|
||||
: await pdfExportService.exportPDF(
|
||||
documentWithDOMState,
|
||||
validSelectedPageIds,
|
||||
{
|
||||
selectedOnly: true,
|
||||
filename: exportFilename,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setHasUnsavedChanges(false);
|
||||
@@ -158,26 +177,36 @@ export const usePageEditorExport = ({
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
const processedDocuments =
|
||||
documentManipulationService.applyDOMChangesToDocument(
|
||||
displayDocument,
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
);
|
||||
|
||||
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
||||
const normalizedDocuments =
|
||||
normalizeProcessedDocuments(processedDocuments);
|
||||
|
||||
if (
|
||||
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) ||
|
||||
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0)
|
||||
(Array.isArray(normalizedDocuments) &&
|
||||
normalizedDocuments.length === 0) ||
|
||||
(!Array.isArray(normalizedDocuments) &&
|
||||
normalizedDocuments.pages.length === 0)
|
||||
) {
|
||||
console.warn("Export skipped: no concrete pages available after filtering placeholders.");
|
||||
console.warn(
|
||||
"Export skipped: no concrete pages available after filtering placeholders.",
|
||||
);
|
||||
setExportLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(normalizedDocuments, sourceFiles, exportFilename);
|
||||
const files = await exportProcessedDocumentsToFiles(
|
||||
normalizedDocuments,
|
||||
sourceFiles,
|
||||
exportFilename,
|
||||
);
|
||||
|
||||
if (files.length > 1) {
|
||||
const JSZip = await import("jszip");
|
||||
@@ -203,33 +232,50 @@ export const usePageEditorExport = ({
|
||||
console.error("Export failed:", error);
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [displayDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges, setExportLoading]);
|
||||
}, [
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
getSourceFiles,
|
||||
getExportFilename,
|
||||
setHasUnsavedChanges,
|
||||
setExportLoading,
|
||||
]);
|
||||
|
||||
const applyChanges = useCallback(async () => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
||||
const processedDocuments =
|
||||
documentManipulationService.applyDOMChangesToDocument(
|
||||
displayDocument,
|
||||
displayDocument,
|
||||
splitPositions,
|
||||
);
|
||||
|
||||
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
||||
const normalizedDocuments =
|
||||
normalizeProcessedDocuments(processedDocuments);
|
||||
|
||||
if (
|
||||
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) ||
|
||||
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0)
|
||||
(Array.isArray(normalizedDocuments) &&
|
||||
normalizedDocuments.length === 0) ||
|
||||
(!Array.isArray(normalizedDocuments) &&
|
||||
normalizedDocuments.pages.length === 0)
|
||||
) {
|
||||
console.warn("Apply changes skipped: no concrete pages available after filtering placeholders.");
|
||||
console.warn(
|
||||
"Apply changes skipped: no concrete pages available after filtering placeholders.",
|
||||
);
|
||||
setExportLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceFiles = getSourceFiles();
|
||||
const exportFilename = getExportFilename();
|
||||
const files = await exportProcessedDocumentsToFiles(normalizedDocuments, sourceFiles, exportFilename);
|
||||
const files = await exportProcessedDocumentsToFiles(
|
||||
normalizedDocuments,
|
||||
sourceFiles,
|
||||
exportFilename,
|
||||
);
|
||||
|
||||
// Add "_multitool" suffix to filenames
|
||||
const renamedFiles = files.map((file) => {
|
||||
|
||||
@@ -52,7 +52,9 @@ export function usePageEditorState(): PageEditorState {
|
||||
// Helper functions
|
||||
const togglePage = useCallback((pageId: string) => {
|
||||
setSelectedPageIds((prev) => {
|
||||
const newSelection = prev.includes(pageId) ? prev.filter((id) => id !== pageId) : [...prev, pageId];
|
||||
const newSelection = prev.includes(pageId)
|
||||
? prev.filter((id) => id !== pageId)
|
||||
: [...prev, pageId];
|
||||
return newSelection;
|
||||
});
|
||||
}, []); // Empty deps - uses updater function so always has latest state
|
||||
@@ -60,7 +62,9 @@ export function usePageEditorState(): PageEditorState {
|
||||
const toggleSelectAll = useCallback((allPageIds: string[]) => {
|
||||
if (!allPageIds.length) return;
|
||||
|
||||
setSelectedPageIds((prev) => (prev.length === allPageIds.length ? [] : allPageIds));
|
||||
setSelectedPageIds((prev) =>
|
||||
prev.length === allPageIds.length ? [] : allPageIds,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const animateReorder = useCallback(() => {
|
||||
|
||||
@@ -53,7 +53,11 @@ export const usePageSelectionManager = ({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (displayDocument && displayDocument.pages.length > 0 && !hasInitializedSelection.current) {
|
||||
if (
|
||||
displayDocument &&
|
||||
displayDocument.pages.length > 0 &&
|
||||
!hasInitializedSelection.current
|
||||
) {
|
||||
const allPageIds = displayDocument.pages.map((page) => page.id);
|
||||
setSelectedPageIds(allPageIds);
|
||||
setSelectionMode(true);
|
||||
@@ -128,4 +132,6 @@ export const usePageSelectionManager = ({
|
||||
};
|
||||
};
|
||||
|
||||
export type UsePageSelectionManagerReturn = ReturnType<typeof usePageSelectionManager>;
|
||||
export type UsePageSelectionManagerReturn = ReturnType<
|
||||
typeof usePageSelectionManager
|
||||
>;
|
||||
|
||||
@@ -6,7 +6,9 @@ interface UseUndoManagerStateParams {
|
||||
setHasUnsavedChanges: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUndoManagerState = ({ setHasUnsavedChanges }: UseUndoManagerStateParams) => {
|
||||
export const useUndoManagerState = ({
|
||||
setHasUnsavedChanges,
|
||||
}: UseUndoManagerStateParams) => {
|
||||
const undoManagerRef = useRef(new UndoManager());
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [canRedo, setCanRedo] = useState(false);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons";
|
||||
import {
|
||||
useRightRailButtons,
|
||||
RightRailButtonWithAction,
|
||||
} from "@app/hooks/useRightRailButtons";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
|
||||
|
||||
@@ -22,7 +25,9 @@ interface PageEditorRightRailButtonsParams {
|
||||
closePdf: () => void;
|
||||
}
|
||||
|
||||
export function usePageEditorRightRailButtons(params: PageEditorRightRailButtonsParams) {
|
||||
export function usePageEditorRightRailButtons(
|
||||
params: PageEditorRightRailButtonsParams,
|
||||
) {
|
||||
const {
|
||||
totalPages,
|
||||
selectedPageCount,
|
||||
@@ -46,9 +51,18 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
// Lift i18n labels out of memo for clarity
|
||||
const selectAllLabel = t("rightRail.selectAll", "Select All");
|
||||
const deselectAllLabel = t("rightRail.deselectAll", "Deselect All");
|
||||
const selectByNumberLabel = t("rightRail.selectByNumber", "Select by Page Numbers");
|
||||
const deleteSelectedLabel = t("rightRail.deleteSelected", "Delete Selected Pages");
|
||||
const exportSelectedLabel = t("rightRail.exportSelected", "Export Selected Pages");
|
||||
const selectByNumberLabel = t(
|
||||
"rightRail.selectByNumber",
|
||||
"Select by Page Numbers",
|
||||
);
|
||||
const deleteSelectedLabel = t(
|
||||
"rightRail.deleteSelected",
|
||||
"Delete Selected Pages",
|
||||
);
|
||||
const exportSelectedLabel = t(
|
||||
"rightRail.exportSelected",
|
||||
"Export Selected Pages",
|
||||
);
|
||||
const saveChangesLabel = t("rightRail.saveChanges", "Save Changes");
|
||||
const closePdfLabel = t("rightRail.closePdf", "Close PDF");
|
||||
|
||||
@@ -67,7 +81,13 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
},
|
||||
{
|
||||
id: "page-deselect-all",
|
||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="crop-square-outline"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
tooltip: deselectAllLabel,
|
||||
ariaLabel: deselectAllLabel,
|
||||
section: "top" as const,
|
||||
@@ -99,7 +119,13 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
||||
},
|
||||
{
|
||||
id: "page-delete-selected",
|
||||
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: (
|
||||
<LocalIcon
|
||||
icon="delete-outline-rounded"
|
||||
width="1.5rem"
|
||||
height="1.5rem"
|
||||
/>
|
||||
),
|
||||
tooltip: deleteSelectedLabel,
|
||||
ariaLabel: deleteSelectedLabel,
|
||||
section: "top" as const,
|
||||
|
||||
@@ -3,7 +3,9 @@ import { PDFDocument } from "@app/types/pageEditor";
|
||||
/**
|
||||
* Build a map from page ID to its index in the provided document.
|
||||
*/
|
||||
export function buildPageIdIndexMap(document: PDFDocument | null): Map<string, number> {
|
||||
export function buildPageIdIndexMap(
|
||||
document: PDFDocument | null,
|
||||
): Map<string, number> {
|
||||
const map = new Map<string, number>();
|
||||
if (!document) return map;
|
||||
document.pages.forEach((page, index) => {
|
||||
@@ -16,7 +18,10 @@ export function buildPageIdIndexMap(document: PDFDocument | null): Map<string, n
|
||||
* Convert a set of split page IDs (the page preceding each split) into
|
||||
* the current index positions inside the document.
|
||||
*/
|
||||
export function convertSplitPageIdsToIndexes(document: PDFDocument | null, splitPageIds: Set<string>): Set<number> {
|
||||
export function convertSplitPageIdsToIndexes(
|
||||
document: PDFDocument | null,
|
||||
splitPageIds: Set<string>,
|
||||
): Set<number> {
|
||||
const indexes = new Set<number>();
|
||||
if (!document || !splitPageIds || splitPageIds.size === 0) {
|
||||
return indexes;
|
||||
|
||||
@@ -7,6 +7,8 @@ interface QuickAccessBarFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuickAccessBarFooterExtensions(_props: QuickAccessBarFooterExtensionsProps) {
|
||||
export function QuickAccessBarFooterExtensions(
|
||||
_props: QuickAccessBarFooterExtensionsProps,
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
export function RightRailFooterExtensions(
|
||||
_props: RightRailFooterExtensionsProps,
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import AppsIcon from "@mui/icons-material/AppsRounded";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext";
|
||||
import {
|
||||
useNavigationState,
|
||||
useNavigationActions,
|
||||
} from "@app/contexts/NavigationContext";
|
||||
import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation";
|
||||
import { handleUnlessSpecialClick } from "@app/utils/clickHandlers";
|
||||
import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton";
|
||||
@@ -12,9 +15,17 @@ interface AllToolsNavButtonProps {
|
||||
setActiveButton: (id: string) => void;
|
||||
}
|
||||
|
||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
||||
activeButton,
|
||||
setActiveButton,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||
const {
|
||||
handleReaderToggle,
|
||||
handleBackToTools,
|
||||
selectedToolKey,
|
||||
leftPanelView,
|
||||
} = useToolWorkflow();
|
||||
const { hasUnsavedChanges } = useNavigationState();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const { getHomeNavigation } = useSidebarNavigation();
|
||||
@@ -35,7 +46,10 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
||||
};
|
||||
|
||||
// Do not highlight All Tools when a specific tool is open (indicator is shown)
|
||||
const isActive = activeButton === "tools" && !selectedToolKey && leftPanelView === "toolPicker";
|
||||
const isActive =
|
||||
activeButton === "tools" &&
|
||||
!selectedToolKey &&
|
||||
leftPanelView === "toolPicker";
|
||||
|
||||
const navProps = getHomeNavigation();
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import React, { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||
import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
@@ -7,9 +13,15 @@ import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import "@app/components/shared/AppConfigModal.css";
|
||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||
import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
||||
import {
|
||||
Z_INDEX_CONFIG_MODAL,
|
||||
Z_INDEX_OVER_CONFIG_MODAL,
|
||||
} from "@app/styles/zIndex";
|
||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||
import { UnsavedChangesProvider, useUnsavedChanges } from "@app/contexts/UnsavedChangesContext";
|
||||
import {
|
||||
UnsavedChangesProvider,
|
||||
useUnsavedChanges,
|
||||
} from "@app/contexts/UnsavedChangesContext";
|
||||
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
|
||||
|
||||
interface AppConfigModalProps {
|
||||
@@ -17,7 +29,10 @@ interface AppConfigModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
}) => {
|
||||
const [active, setActive] = useState<NavKey>("general");
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
@@ -42,7 +57,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
const section = getSectionFromPath(location.pathname);
|
||||
if (opened && section) {
|
||||
setActive(section);
|
||||
} else if (opened && location.pathname.startsWith("/settings") && !section) {
|
||||
} else if (
|
||||
opened &&
|
||||
location.pathname.startsWith("/settings") &&
|
||||
!section
|
||||
) {
|
||||
// If at /settings without a section, redirect to general
|
||||
navigate("/settings/general", { replace: true });
|
||||
}
|
||||
@@ -64,7 +83,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
}
|
||||
};
|
||||
window.addEventListener("appConfig:navigate", handler as EventListener);
|
||||
return () => window.removeEventListener("appConfig:navigate", handler as EventListener);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"appConfig:navigate",
|
||||
handler as EventListener,
|
||||
);
|
||||
}, [navigate]);
|
||||
|
||||
const colors = useMemo(
|
||||
@@ -86,7 +109,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
const loginEnabled = config?.enableLogin ?? false;
|
||||
|
||||
// Left navigation structure and icons
|
||||
const configNavSections = useConfigNavSections(isAdmin, runningEE, loginEnabled);
|
||||
const configNavSections = useConfigNavSections(
|
||||
isAdmin,
|
||||
runningEE,
|
||||
loginEnabled,
|
||||
);
|
||||
|
||||
const activeLabel = useMemo(() => {
|
||||
for (const section of configNavSections) {
|
||||
@@ -151,7 +178,12 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
{configNavSections.map((section) => (
|
||||
<div key={section.title} className="modal-nav-section">
|
||||
{!isMobile && (
|
||||
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: "uppercase", letterSpacing: 0.4 }}>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={600}
|
||||
c={colors.sectionTitle}
|
||||
style={{ textTransform: "uppercase", letterSpacing: 0.4 }}
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
)}
|
||||
@@ -159,10 +191,14 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
{section.items.map((item) => {
|
||||
const isActive = active === item.key;
|
||||
const isDisabled = item.disabled ?? false;
|
||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
||||
const color = isActive
|
||||
? colors.navItemActive
|
||||
: colors.navItem;
|
||||
const iconSize = isMobile ? 28 : 18;
|
||||
const showPlanWarning =
|
||||
item.key === "adminPlan" && licenseAlert.active && licenseAlert.audience === "admin";
|
||||
item.key === "adminPlan" &&
|
||||
licenseAlert.active &&
|
||||
licenseAlert.audience === "admin";
|
||||
|
||||
const navItemContent = (
|
||||
<div
|
||||
@@ -174,20 +210,32 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
}}
|
||||
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : "transparent",
|
||||
background: isActive
|
||||
? colors.navItemActiveBg
|
||||
: "transparent",
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
data-tour={`admin-${item.key}-nav`}
|
||||
>
|
||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
||||
<LocalIcon
|
||||
icon={item.icon}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
style={{ color }}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<Group gap={4} align="center" wrap="nowrap">
|
||||
<Text size="sm" fw={500} style={{ color }}>
|
||||
{item.label}
|
||||
</Text>
|
||||
{item.badge && (
|
||||
<Badge size="xs" variant="light" color={item.badgeColor ?? "orange"} style={{ flexShrink: 0 }}>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={item.badgeColor ?? "orange"}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -196,7 +244,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
icon="warning-rounded"
|
||||
width={14}
|
||||
height={14}
|
||||
style={{ color: "var(--mantine-color-orange-7)" }}
|
||||
style={{
|
||||
color: "var(--mantine-color-orange-7)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
@@ -215,7 +265,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
{navItemContent}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<React.Fragment key={item.key}>{navItemContent}</React.Fragment>
|
||||
<React.Fragment key={item.key}>
|
||||
{navItemContent}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -239,8 +291,18 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
||||
{activeLabel}
|
||||
</Text>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<SettingsSearchBar configNavSections={configNavSections} onNavigate={handleNavigation} isMobile={isMobile} />
|
||||
<ActionIcon ref={closeButtonRef} variant="subtle" onClick={handleClose} aria-label="Close" data-autofocus>
|
||||
<SettingsSearchBar
|
||||
configNavSections={configNavSections}
|
||||
onNavigate={handleNavigation}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
<ActionIcon
|
||||
ref={closeButtonRef}
|
||||
variant="subtle"
|
||||
onClick={handleClose}
|
||||
aria-label="Close"
|
||||
data-autofocus
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user