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/pdfjs*/
|
||||||
public/js/thirdParty/
|
public/js/thirdParty/
|
||||||
public/css/cookieconsent.css
|
public/css/cookieconsent.css
|
||||||
|
src-tauri/target/
|
||||||
*.min.*
|
*.min.*
|
||||||
*.md
|
*.md
|
||||||
*.wxs
|
*.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 = [
|
const baseRestrictedImportPatterns = [
|
||||||
{ regex: "^\\.", message: "Use @app/* imports instead of relative imports." },
|
{ regex: "^\\.", message: "Use @app/* imports instead of relative imports." },
|
||||||
{ regex: "^src/", message: "Use @app/* imports instead of absolute src/ imports." },
|
{
|
||||||
|
regex: "^src/",
|
||||||
|
message: "Use @app/* imports instead of absolute src/ imports.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
@@ -64,7 +67,8 @@ export default defineConfig(
|
|||||||
...baseRestrictedImportPatterns,
|
...baseRestrictedImportPatterns,
|
||||||
{
|
{
|
||||||
regex: "^@tauri-apps/",
|
regex: "^@tauri-apps/",
|
||||||
message: "Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.",
|
message:
|
||||||
|
"Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-1
@@ -6,7 +6,10 @@
|
|||||||
<link rel="icon" href="modern-logo/favicon.ico" />
|
<link rel="icon" href="modern-logo/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta name="description" content="The Free Adobe Acrobat alternative (10M+ Downloads)" />
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="The Free Adobe Acrobat alternative (10M+ Downloads)"
|
||||||
|
/>
|
||||||
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
|
<link rel="apple-touch-icon" href="modern-logo/logo192.png" />
|
||||||
<link rel="manifest" href="manifest.json" />
|
<link rel="manifest" href="manifest.json" />
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,19 @@ const frontendDir = process.cwd();
|
|||||||
const tauriDir = resolve(frontendDir, "src-tauri");
|
const tauriDir = resolve(frontendDir, "src-tauri");
|
||||||
const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml");
|
const provisionerManifest = join(tauriDir, "provisioner", "Cargo.toml");
|
||||||
|
|
||||||
execFileSync("cargo", ["build", "--release", "--manifest-path", provisionerManifest], { stdio: "inherit" });
|
execFileSync(
|
||||||
|
"cargo",
|
||||||
|
["build", "--release", "--manifest-path", provisionerManifest],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
|
||||||
const provisionerExe = join(tauriDir, "provisioner", "target", "release", "stirling-provisioner.exe");
|
const provisionerExe = join(
|
||||||
|
tauriDir,
|
||||||
|
"provisioner",
|
||||||
|
"target",
|
||||||
|
"release",
|
||||||
|
"stirling-provisioner.exe",
|
||||||
|
);
|
||||||
if (!existsSync(provisionerExe)) {
|
if (!existsSync(provisionerExe)) {
|
||||||
throw new Error(`Provisioner binary not found at ${provisionerExe}`);
|
throw new Error(`Provisioner binary not found at ${provisionerExe}`);
|
||||||
}
|
}
|
||||||
@@ -26,9 +36,19 @@ copyFileSync(provisionerExe, destExe);
|
|||||||
// --- Thumbnail handler DLL ---
|
// --- Thumbnail handler DLL ---
|
||||||
const thumbManifest = join(tauriDir, "thumbnail-handler", "Cargo.toml");
|
const thumbManifest = join(tauriDir, "thumbnail-handler", "Cargo.toml");
|
||||||
|
|
||||||
execFileSync("cargo", ["build", "--release", "--manifest-path", thumbManifest], { stdio: "inherit" });
|
execFileSync(
|
||||||
|
"cargo",
|
||||||
|
["build", "--release", "--manifest-path", thumbManifest],
|
||||||
|
{ stdio: "inherit" },
|
||||||
|
);
|
||||||
|
|
||||||
const thumbDll = join(tauriDir, "thumbnail-handler", "target", "release", "stirling_thumbnail_handler.dll");
|
const thumbDll = join(
|
||||||
|
tauriDir,
|
||||||
|
"thumbnail-handler",
|
||||||
|
"target",
|
||||||
|
"release",
|
||||||
|
"stirling_thumbnail_handler.dll",
|
||||||
|
);
|
||||||
if (!existsSync(thumbDll)) {
|
if (!existsSync(thumbDll)) {
|
||||||
throw new Error(`Thumbnail handler DLL not found at ${thumbDll}`);
|
throw new Error(`Thumbnail handler DLL not found at ${thumbDll}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
// Check for verbose flag
|
// Check for verbose flag
|
||||||
const isVerbose = process.argv.includes("--verbose") || process.argv.includes("-v");
|
const isVerbose =
|
||||||
|
process.argv.includes("--verbose") || process.argv.includes("-v");
|
||||||
|
|
||||||
// Logging functions
|
// Logging functions
|
||||||
const info = (message) => console.log(message);
|
const info = (message) => console.log(message);
|
||||||
@@ -41,62 +42,82 @@ function scanForUsedIcons() {
|
|||||||
const content = fs.readFileSync(filePath, "utf8");
|
const content = fs.readFileSync(filePath, "utf8");
|
||||||
|
|
||||||
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
|
// Match LocalIcon usage: <LocalIcon icon="icon-name" ...>
|
||||||
const localIconMatches = content.match(/<LocalIcon\s+[^>]*icon="([^"]+)"/g);
|
const localIconMatches = content.match(
|
||||||
|
/<LocalIcon\s+[^>]*icon="([^"]+)"/g,
|
||||||
|
);
|
||||||
if (localIconMatches) {
|
if (localIconMatches) {
|
||||||
localIconMatches.forEach((match) => {
|
localIconMatches.forEach((match) => {
|
||||||
const iconMatch = match.match(/icon="([^"]+)"/);
|
const iconMatch = match.match(/icon="([^"]+)"/);
|
||||||
if (iconMatch) {
|
if (iconMatch) {
|
||||||
usedIcons.add(iconMatch[1]);
|
usedIcons.add(iconMatch[1]);
|
||||||
debug(` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
|
debug(
|
||||||
|
` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match LocalIcon usage: <LocalIcon icon='icon-name' ...>
|
// Match LocalIcon usage: <LocalIcon icon='icon-name' ...>
|
||||||
const localIconSingleQuoteMatches = content.match(/<LocalIcon\s+[^>]*icon='([^']+)'/g);
|
const localIconSingleQuoteMatches = content.match(
|
||||||
|
/<LocalIcon\s+[^>]*icon='([^']+)'/g,
|
||||||
|
);
|
||||||
if (localIconSingleQuoteMatches) {
|
if (localIconSingleQuoteMatches) {
|
||||||
localIconSingleQuoteMatches.forEach((match) => {
|
localIconSingleQuoteMatches.forEach((match) => {
|
||||||
const iconMatch = match.match(/icon='([^']+)'/);
|
const iconMatch = match.match(/icon='([^']+)'/);
|
||||||
if (iconMatch) {
|
if (iconMatch) {
|
||||||
usedIcons.add(iconMatch[1]);
|
usedIcons.add(iconMatch[1]);
|
||||||
debug(` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
|
debug(
|
||||||
|
` Found: ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
|
// Match old material-symbols-rounded spans: <span className="material-symbols-rounded">icon-name</span>
|
||||||
const spanMatches = content.match(/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g);
|
const spanMatches = content.match(
|
||||||
|
/<span[^>]*className="[^"]*material-symbols-rounded[^"]*"[^>]*>([^<]+)<\/span>/g,
|
||||||
|
);
|
||||||
if (spanMatches) {
|
if (spanMatches) {
|
||||||
spanMatches.forEach((match) => {
|
spanMatches.forEach((match) => {
|
||||||
const iconMatch = match.match(/>([^<]+)<\/span>/);
|
const iconMatch = match.match(/>([^<]+)<\/span>/);
|
||||||
if (iconMatch && iconMatch[1].trim()) {
|
if (iconMatch && iconMatch[1].trim()) {
|
||||||
const iconName = iconMatch[1].trim();
|
const iconName = iconMatch[1].trim();
|
||||||
usedIcons.add(iconName);
|
usedIcons.add(iconName);
|
||||||
debug(` Found (legacy): ${iconName} in ${path.relative(srcDir, filePath)}`);
|
debug(
|
||||||
|
` Found (legacy): ${iconName} in ${path.relative(srcDir, filePath)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
|
// Match Icon component usage: <Icon icon="material-symbols:icon-name" ...>
|
||||||
const iconMatches = content.match(/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g);
|
const iconMatches = content.match(
|
||||||
|
/<Icon\s+[^>]*icon="material-symbols:([^"]+)"/g,
|
||||||
|
);
|
||||||
if (iconMatches) {
|
if (iconMatches) {
|
||||||
iconMatches.forEach((match) => {
|
iconMatches.forEach((match) => {
|
||||||
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
|
const iconMatch = match.match(/icon="material-symbols:([^"]+)"/);
|
||||||
if (iconMatch) {
|
if (iconMatch) {
|
||||||
usedIcons.add(iconMatch[1]);
|
usedIcons.add(iconMatch[1]);
|
||||||
debug(` Found (Icon): ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`);
|
debug(
|
||||||
|
` Found (Icon): ${iconMatch[1]} in ${path.relative(srcDir, filePath)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match icon config usage: icon: 'icon-name' or icon: "icon-name"
|
// Match icon config usage: icon: 'icon-name' or icon: "icon-name"
|
||||||
const iconPropertyMatches = content.match(/icon:\s*(['"])([a-z0-9-]+)\1/g);
|
const iconPropertyMatches = content.match(
|
||||||
|
/icon:\s*(['"])([a-z0-9-]+)\1/g,
|
||||||
|
);
|
||||||
if (iconPropertyMatches) {
|
if (iconPropertyMatches) {
|
||||||
iconPropertyMatches.forEach((match) => {
|
iconPropertyMatches.forEach((match) => {
|
||||||
const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/);
|
const iconMatch = match.match(/icon:\s*(['"])([a-z0-9-]+)\1/);
|
||||||
if (iconMatch) {
|
if (iconMatch) {
|
||||||
usedIcons.add(iconMatch[2]);
|
usedIcons.add(iconMatch[2]);
|
||||||
debug(` Found (config): ${iconMatch[2]} in ${path.relative(srcDir, filePath)}`);
|
debug(
|
||||||
|
` Found (config): ${iconMatch[2]} in ${path.relative(srcDir, filePath)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -118,7 +139,13 @@ async function main() {
|
|||||||
const usedIcons = scanForUsedIcons();
|
const usedIcons = scanForUsedIcons();
|
||||||
|
|
||||||
// Check if we need to regenerate (compare with existing)
|
// Check if we need to regenerate (compare with existing)
|
||||||
const outputPath = path.join(__dirname, "..", "src", "assets", "material-symbols-icons.json");
|
const outputPath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"src",
|
||||||
|
"assets",
|
||||||
|
"material-symbols-icons.json",
|
||||||
|
);
|
||||||
let needsRegeneration = true;
|
let needsRegeneration = true;
|
||||||
|
|
||||||
if (fs.existsSync(outputPath)) {
|
if (fs.existsSync(outputPath)) {
|
||||||
@@ -159,11 +186,17 @@ async function main() {
|
|||||||
|
|
||||||
// Check for missing icons
|
// Check for missing icons
|
||||||
const extractedIconNames = Object.keys(extractedIcons.icons || {});
|
const extractedIconNames = Object.keys(extractedIcons.icons || {});
|
||||||
const missingIcons = usedIcons.filter((icon) => !extractedIconNames.includes(icon));
|
const missingIcons = usedIcons.filter(
|
||||||
|
(icon) => !extractedIconNames.includes(icon),
|
||||||
|
);
|
||||||
|
|
||||||
if (missingIcons.length > 0) {
|
if (missingIcons.length > 0) {
|
||||||
info(`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`);
|
info(
|
||||||
info("💡 These icons don't exist in Material Symbols. Please use available alternatives.");
|
`⚠️ Missing icons (${missingIcons.length}): ${missingIcons.join(", ")}`,
|
||||||
|
);
|
||||||
|
info(
|
||||||
|
"💡 These icons don't exist in Material Symbols. Please use available alternatives.",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output directory
|
// Create output directory
|
||||||
@@ -175,8 +208,12 @@ async function main() {
|
|||||||
// Write the extracted icon set to a file (outputPath already defined above)
|
// Write the extracted icon set to a file (outputPath already defined above)
|
||||||
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
|
fs.writeFileSync(outputPath, JSON.stringify(extractedIcons, null, 2));
|
||||||
|
|
||||||
info(`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`);
|
info(
|
||||||
info(`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`);
|
`✅ Successfully extracted ${Object.keys(extractedIcons.icons || {}).length} icons`,
|
||||||
|
);
|
||||||
|
info(
|
||||||
|
`📦 Bundle size: ${Math.round(JSON.stringify(extractedIcons).length / 1024)}KB`,
|
||||||
|
);
|
||||||
info(`💾 Saved to: ${outputPath}`);
|
info(`💾 Saved to: ${outputPath}`);
|
||||||
|
|
||||||
// Generate TypeScript types
|
// Generate TypeScript types
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
const { execSync } = require("node:child_process");
|
const { execSync } = require("node:child_process");
|
||||||
const { existsSync, mkdirSync, writeFileSync, readFileSync } = require("node:fs");
|
const {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
writeFileSync,
|
||||||
|
readFileSync,
|
||||||
|
} = require("node:fs");
|
||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
|
||||||
const { argv } = require("node:process");
|
const { argv } = require("node:process");
|
||||||
@@ -16,7 +21,13 @@ const POSTPROCESS_ONLY = !!INPUT_FILE;
|
|||||||
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
|
* This script creates a JSON file similar to the Java backend's 3rdPartyLicenses.json
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const OUTPUT_FILE = path.join(__dirname, "..", "src", "assets", "3rdPartyLicenses.json");
|
const OUTPUT_FILE = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"src",
|
||||||
|
"assets",
|
||||||
|
"3rdPartyLicenses.json",
|
||||||
|
);
|
||||||
const PACKAGE_JSON = path.join(__dirname, "..", "package.json");
|
const PACKAGE_JSON = path.join(__dirname, "..", "package.json");
|
||||||
|
|
||||||
// Ensure the output directory exists
|
// Ensure the output directory exists
|
||||||
@@ -30,7 +41,9 @@ console.log("🔍 Generating frontend license report...");
|
|||||||
try {
|
try {
|
||||||
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
|
// Safety guard: don't run this script on fork PRs (workflow setzt PR_IS_FORK)
|
||||||
if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) {
|
if (process.env.PR_IS_FORK === "true" && !POSTPROCESS_ONLY) {
|
||||||
console.error("Fork PR detected: only --input (postprocess-only) mode is allowed.");
|
console.error(
|
||||||
|
"Fork PR detected: only --input (postprocess-only) mode is allowed.",
|
||||||
|
);
|
||||||
process.exit(2);
|
process.exit(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,13 +103,21 @@ try {
|
|||||||
licenseType = "Unknown";
|
licenseType = "Unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("posthog-js" === dep.name && licenseType.startsWith("SEE LICENSE IN LICENSE")) {
|
if (
|
||||||
licenseType = "SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
|
"posthog-js" === dep.name &&
|
||||||
|
licenseType.startsWith("SEE LICENSE IN LICENSE")
|
||||||
|
) {
|
||||||
|
licenseType =
|
||||||
|
"SEE LICENSE IN LICENSE https://github.com/PostHog/posthog-js/blob/main/LICENSE";
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: dep.name,
|
name: dep.name,
|
||||||
version: dep.installedVersion || dep.definedVersion || dep.remoteVersion || "unknown",
|
version:
|
||||||
|
dep.installedVersion ||
|
||||||
|
dep.definedVersion ||
|
||||||
|
dep.remoteVersion ||
|
||||||
|
"unknown",
|
||||||
licenseType: licenseType,
|
licenseType: licenseType,
|
||||||
repository: dep.link,
|
repository: dep.link,
|
||||||
url: dep.link,
|
url: dep.link,
|
||||||
@@ -107,12 +128,17 @@ try {
|
|||||||
// Transform to match Java backend format
|
// Transform to match Java backend format
|
||||||
const transformedData = {
|
const transformedData = {
|
||||||
dependencies: licenseArray.map((dep) => {
|
dependencies: licenseArray.map((dep) => {
|
||||||
const licenseType = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown";
|
const licenseType = Array.isArray(dep.licenseType)
|
||||||
|
? dep.licenseType.join(", ")
|
||||||
|
: dep.licenseType || "Unknown";
|
||||||
const licenseUrl = dep.link || getLicenseUrl(licenseType);
|
const licenseUrl = dep.link || getLicenseUrl(licenseType);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
moduleName: dep.name,
|
moduleName: dep.name,
|
||||||
moduleUrl: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
moduleUrl:
|
||||||
|
dep.repository ||
|
||||||
|
dep.url ||
|
||||||
|
`https://www.npmjs.com/package/${dep.name}`,
|
||||||
moduleVersion: dep.version,
|
moduleVersion: dep.version,
|
||||||
moduleLicense: licenseType,
|
moduleLicense: licenseType,
|
||||||
moduleLicenseUrl: licenseUrl,
|
moduleLicenseUrl: licenseUrl,
|
||||||
@@ -122,7 +148,9 @@ try {
|
|||||||
|
|
||||||
// Log summary of license types found
|
// Log summary of license types found
|
||||||
const licenseSummary = licenseArray.reduce((acc, dep) => {
|
const licenseSummary = licenseArray.reduce((acc, dep) => {
|
||||||
const license = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType || "Unknown";
|
const license = Array.isArray(dep.licenseType)
|
||||||
|
? dep.licenseType.join(", ")
|
||||||
|
: dep.licenseType || "Unknown";
|
||||||
acc[license] = (acc[license] || 0) + 1;
|
acc[license] = (acc[license] || 0) + 1;
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
@@ -150,7 +178,10 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for potentially problematic licenses
|
// Check for potentially problematic licenses
|
||||||
const problematicLicenses = checkLicenseCompatibility(licenseSummary, licenseArray);
|
const problematicLicenses = checkLicenseCompatibility(
|
||||||
|
licenseSummary,
|
||||||
|
licenseArray,
|
||||||
|
);
|
||||||
if (problematicLicenses.length > 0) {
|
if (problematicLicenses.length > 0) {
|
||||||
console.log("\n⚠️ License compatibility warnings:");
|
console.log("\n⚠️ License compatibility warnings:");
|
||||||
problematicLicenses.forEach((warning) => {
|
problematicLicenses.forEach((warning) => {
|
||||||
@@ -158,7 +189,13 @@ try {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Write license warnings to a separate file for CI/CD
|
// Write license warnings to a separate file for CI/CD
|
||||||
const warningsFile = path.join(__dirname, "..", "src", "assets", "license-warnings.json");
|
const warningsFile = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"src",
|
||||||
|
"assets",
|
||||||
|
"license-warnings.json",
|
||||||
|
);
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
warningsFile,
|
warningsFile,
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
@@ -257,15 +294,21 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
|||||||
// Copyleft licenses
|
// Copyleft licenses
|
||||||
"GPL-2.0": "Strong copyleft license - requires derivative works to be GPL",
|
"GPL-2.0": "Strong copyleft license - requires derivative works to be GPL",
|
||||||
"GPL-3.0": "Strong copyleft license - requires derivative works to be GPL",
|
"GPL-3.0": "Strong copyleft license - requires derivative works to be GPL",
|
||||||
"LGPL-2.1": "Weak copyleft license - may require source disclosure for modifications",
|
"LGPL-2.1":
|
||||||
"LGPL-3.0": "Weak copyleft license - may require source disclosure for modifications",
|
"Weak copyleft license - may require source disclosure for modifications",
|
||||||
"AGPL-3.0": "Network copyleft license - requires source disclosure for network use",
|
"LGPL-3.0":
|
||||||
"AGPL-1.0": "Network copyleft license - requires source disclosure for network use",
|
"Weak copyleft license - may require source disclosure for modifications",
|
||||||
|
"AGPL-3.0":
|
||||||
|
"Network copyleft license - requires source disclosure for network use",
|
||||||
|
"AGPL-1.0":
|
||||||
|
"Network copyleft license - requires source disclosure for network use",
|
||||||
|
|
||||||
// Other potentially problematic licenses
|
// Other potentially problematic licenses
|
||||||
WTFPL: "Potentially problematic license - legal uncertainty",
|
WTFPL: "Potentially problematic license - legal uncertainty",
|
||||||
"CC-BY-SA-4.0": "ShareAlike license - requires derivative works to use same license",
|
"CC-BY-SA-4.0":
|
||||||
"CC-BY-SA-3.0": "ShareAlike license - requires derivative works to use same license",
|
"ShareAlike license - requires derivative works to use same license",
|
||||||
|
"CC-BY-SA-3.0":
|
||||||
|
"ShareAlike license - requires derivative works to use same license",
|
||||||
"CC-BY-NC-4.0": "Non-commercial license - prohibits commercial use",
|
"CC-BY-NC-4.0": "Non-commercial license - prohibits commercial use",
|
||||||
"CC-BY-NC-3.0": "Non-commercial license - prohibits commercial use",
|
"CC-BY-NC-3.0": "Non-commercial license - prohibits commercial use",
|
||||||
"OSL-3.0": "Copyleft license - requires derivative works to be OSL",
|
"OSL-3.0": "Copyleft license - requires derivative works to be OSL",
|
||||||
@@ -323,7 +366,9 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
|||||||
|
|
||||||
// Check if this license only affects our own packages
|
// Check if this license only affects our own packages
|
||||||
const affectedPackages = licenseArray.filter((dep) => {
|
const affectedPackages = licenseArray.filter((dep) => {
|
||||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
const depLicense = Array.isArray(dep.licenseType)
|
||||||
|
? dep.licenseType.join(", ")
|
||||||
|
: dep.licenseType;
|
||||||
return depLicense === license;
|
return depLicense === license;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,7 +380,10 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
|||||||
dep.name.toLowerCase().includes("stirlingpdf"),
|
dep.name.toLowerCase().includes("stirlingpdf"),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isOnlyOurPackages && (license === "UNLICENSED" || license.startsWith("SEE LICENSE IN"))) {
|
if (
|
||||||
|
isOnlyOurPackages &&
|
||||||
|
(license === "UNLICENSED" || license.startsWith("SEE LICENSE IN"))
|
||||||
|
) {
|
||||||
return; // Skip warnings for our own Stirling-PDF packages
|
return; // Skip warnings for our own Stirling-PDF packages
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,18 +409,25 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For AND licenses or OR licenses with no good options, check for problematic components
|
// For AND licenses or OR licenses with no good options, check for problematic components
|
||||||
const hasProblematicComponent = Object.keys(problematicLicenses).some((problematic) => license.includes(problematic));
|
const hasProblematicComponent = Object.keys(problematicLicenses).some(
|
||||||
|
(problematic) => license.includes(problematic),
|
||||||
|
);
|
||||||
|
|
||||||
if (hasProblematicComponent) {
|
if (hasProblematicComponent) {
|
||||||
const affectedPackages = licenseArray
|
const affectedPackages = licenseArray
|
||||||
.filter((dep) => {
|
.filter((dep) => {
|
||||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
const depLicense = Array.isArray(dep.licenseType)
|
||||||
|
? dep.licenseType.join(", ")
|
||||||
|
: dep.licenseType;
|
||||||
return depLicense === license;
|
return depLicense === license;
|
||||||
})
|
})
|
||||||
.map((dep) => ({
|
.map((dep) => ({
|
||||||
name: dep.name,
|
name: dep.name,
|
||||||
version: dep.version,
|
version: dep.version,
|
||||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
url:
|
||||||
|
dep.repository ||
|
||||||
|
dep.url ||
|
||||||
|
`https://www.npmjs.com/package/${dep.name}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const licenseType = license.includes("AND") ? "AND" : "OR";
|
const licenseType = license.includes("AND") ? "AND" : "OR";
|
||||||
@@ -397,21 +452,30 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
|||||||
if (problematicLicenses[license]) {
|
if (problematicLicenses[license]) {
|
||||||
const affectedPackages = licenseArray
|
const affectedPackages = licenseArray
|
||||||
.filter((dep) => {
|
.filter((dep) => {
|
||||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
const depLicense = Array.isArray(dep.licenseType)
|
||||||
|
? dep.licenseType.join(", ")
|
||||||
|
: dep.licenseType;
|
||||||
return depLicense === license;
|
return depLicense === license;
|
||||||
})
|
})
|
||||||
.map((dep) => ({
|
.map((dep) => ({
|
||||||
name: dep.name,
|
name: dep.name,
|
||||||
version: dep.version,
|
version: dep.version,
|
||||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
url:
|
||||||
|
dep.repository ||
|
||||||
|
dep.url ||
|
||||||
|
`https://www.npmjs.com/package/${dep.name}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const packageList =
|
const packageList =
|
||||||
affectedPackages
|
affectedPackages
|
||||||
.map((pkg) => pkg.name)
|
.map((pkg) => pkg.name)
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.join(", ") + (affectedPackages.length > 5 ? `, and ${affectedPackages.length - 5} more` : "");
|
.join(", ") +
|
||||||
const licenseUrl = getLicenseUrl(license) || "https://opensource.org/licenses";
|
(affectedPackages.length > 5
|
||||||
|
? `, and ${affectedPackages.length - 5} more`
|
||||||
|
: "");
|
||||||
|
const licenseUrl =
|
||||||
|
getLicenseUrl(license) || "https://opensource.org/licenses";
|
||||||
|
|
||||||
warnings.push({
|
warnings.push({
|
||||||
message: `⚠️ This PR contains ${count} package${count > 1 ? "s" : ""} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
|
message: `⚠️ This PR contains ${count} package${count > 1 ? "s" : ""} with license type [${license}](${licenseUrl}) - ${problematicLicenses[license]}. Affected packages: ${packageList}`,
|
||||||
@@ -425,13 +489,18 @@ function checkLicenseCompatibility(licenseSummary, licenseArray) {
|
|||||||
// Unknown license type - flag for manual review
|
// Unknown license type - flag for manual review
|
||||||
const affectedPackages = licenseArray
|
const affectedPackages = licenseArray
|
||||||
.filter((dep) => {
|
.filter((dep) => {
|
||||||
const depLicense = Array.isArray(dep.licenseType) ? dep.licenseType.join(", ") : dep.licenseType;
|
const depLicense = Array.isArray(dep.licenseType)
|
||||||
|
? dep.licenseType.join(", ")
|
||||||
|
: dep.licenseType;
|
||||||
return depLicense === license;
|
return depLicense === license;
|
||||||
})
|
})
|
||||||
.map((dep) => ({
|
.map((dep) => ({
|
||||||
name: dep.name,
|
name: dep.name,
|
||||||
version: dep.version,
|
version: dep.version,
|
||||||
url: dep.repository || dep.url || `https://www.npmjs.com/package/${dep.name}`,
|
url:
|
||||||
|
dep.repository ||
|
||||||
|
dep.url ||
|
||||||
|
`https://www.npmjs.com/package/${dep.name}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
warnings.push({
|
warnings.push({
|
||||||
|
|||||||
@@ -21,8 +21,8 @@
|
|||||||
|
|
||||||
/* Font Stack */
|
/* Font Stack */
|
||||||
--font-family:
|
--font-family:
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans",
|
-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu",
|
||||||
"Helvetica Neue", sans-serif;
|
"Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
|
|||||||
@@ -10,15 +10,39 @@
|
|||||||
<!-- Page 1: Hero / Cover Page -->
|
<!-- Page 1: Hero / Cover Page -->
|
||||||
<div class="page page-1">
|
<div class="page page-1">
|
||||||
<div class="decorative-shapes">
|
<div class="decorative-shapes">
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-1" alt="" />
|
<img
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-2" alt="" />
|
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-3" alt="" />
|
class="shape shape-1"
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg" class="shape shape-4" alt="" />
|
alt=""
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg" class="shape shape-5" alt="" />
|
/>
|
||||||
|
<img
|
||||||
|
src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg"
|
||||||
|
class="shape shape-2"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
|
||||||
|
class="shape shape-3"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="../../public/modern-logo/StirlingPDFLogoNoTextDark.svg"
|
||||||
|
class="shape shape-4"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="../../public/modern-logo/StirlingPDFLogoNoTextLight.svg"
|
||||||
|
class="shape shape-5"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-content">
|
<div class="hero-content">
|
||||||
<div class="logo-container">
|
<div class="logo-container">
|
||||||
<img src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg" alt="Stirling PDF" class="hero-logo" />
|
<img
|
||||||
|
src="../../public/modern-logo/StirlingPDFLogoWhiteText.svg"
|
||||||
|
alt="Stirling PDF"
|
||||||
|
class="hero-logo"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
|
<h1 class="hero-tagline">The Free Adobe Acrobat Alternative</h1>
|
||||||
<div class="hero-stats">
|
<div class="hero-stats">
|
||||||
@@ -40,21 +64,31 @@
|
|||||||
<div class="content-wrapper">
|
<div class="content-wrapper">
|
||||||
<h2 class="page-title">What is Stirling PDF?</h2>
|
<h2 class="page-title">What is Stirling PDF?</h2>
|
||||||
<p class="intro-text">
|
<p class="intro-text">
|
||||||
Stirling PDF is a robust, web-based PDF manipulation tool. It enables you to carry out various operations on PDF
|
Stirling PDF is a robust, web-based PDF manipulation tool. It enables
|
||||||
files, including splitting, merging, converting, rearranging, adding images, rotating, compressing, and more.
|
you to carry out various operations on PDF files, including splitting,
|
||||||
|
merging, converting, rearranging, adding images, rotating,
|
||||||
|
compressing, and more.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="value-props">
|
<div class="value-props">
|
||||||
<div class="value-prop">
|
<div class="value-prop">
|
||||||
<div class="value-icon">
|
<div class="value-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>50+ PDF Operations</h3>
|
<h3>50+ PDF Operations</h3>
|
||||||
<p>Comprehensive toolkit covering all your PDF needs. From basic operations to advanced processing.</p>
|
<p>
|
||||||
|
Comprehensive toolkit covering all your PDF needs. From basic
|
||||||
|
operations to advanced processing.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="value-prop">
|
<div class="value-prop">
|
||||||
@@ -66,24 +100,42 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>Workflow Automation</h3>
|
<h3>Workflow Automation</h3>
|
||||||
<p>Chain multiple operations together and save them as reusable workflows. Perfect for recurring tasks.</p>
|
<p>
|
||||||
|
Chain multiple operations together and save them as reusable
|
||||||
|
workflows. Perfect for recurring tasks.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="value-prop">
|
<div class="value-prop">
|
||||||
<div class="value-icon">
|
<div class="value-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<line x1="2" y1="12" x2="22" y2="12" />
|
<line x1="2" y1="12" x2="22" y2="12" />
|
||||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
<path
|
||||||
|
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>Multi-Language Support</h3>
|
<h3>Multi-Language Support</h3>
|
||||||
<p>Available in over 30 languages with community-contributed translations. Accessible to users worldwide.</p>
|
<p>
|
||||||
|
Available in over 30 languages with community-contributed
|
||||||
|
translations. Accessible to users worldwide.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="value-prop">
|
<div class="value-prop">
|
||||||
<div class="value-icon">
|
<div class="value-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<rect x="5" y="11" width="14" height="10" rx="2" />
|
<rect x="5" y="11" width="14" height="10" rx="2" />
|
||||||
<circle cx="12" cy="16" r="1" />
|
<circle cx="12" cy="16" r="1" />
|
||||||
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
<path d="M8 11V7a4 4 0 0 1 8 0v4" />
|
||||||
@@ -91,30 +143,47 @@
|
|||||||
</div>
|
</div>
|
||||||
<h3>Privacy First</h3>
|
<h3>Privacy First</h3>
|
||||||
<p>
|
<p>
|
||||||
Self-hosted solution means your data stays on your infrastructure. You have full control over your documents.
|
Self-hosted solution means your data stays on your infrastructure.
|
||||||
|
You have full control over your documents.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="value-prop">
|
<div class="value-prop">
|
||||||
<div class="value-icon">
|
<div class="value-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<circle cx="12" cy="12" r="10" />
|
<circle cx="12" cy="12" r="10" />
|
||||||
<path d="m9 12 2 2 4-4" />
|
<path d="m9 12 2 2 4-4" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>Open Source</h3>
|
<h3>Open Source</h3>
|
||||||
<p>Transparent, community-driven development. Inspect the code, contribute features, and adapt as needed.</p>
|
<p>
|
||||||
|
Transparent, community-driven development. Inspect the code,
|
||||||
|
contribute features, and adapt as needed.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="value-prop">
|
<div class="value-prop">
|
||||||
<div class="value-icon">
|
<div class="value-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<polyline points="16 18 22 12 16 6" />
|
<polyline points="16 18 22 12 16 6" />
|
||||||
<polyline points="8 6 2 12 8 18" />
|
<polyline points="8 6 2 12 8 18" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3>API Access</h3>
|
<h3>API Access</h3>
|
||||||
<p>RESTful API for integration with external tools and scripts. Automate PDF operations programmatically.</p>
|
<p>
|
||||||
|
RESTful API for integration with external tools and scripts.
|
||||||
|
Automate PDF operations programmatically.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,8 +198,15 @@
|
|||||||
<div class="feature-card" data-category="general">
|
<div class="feature-card" data-category="general">
|
||||||
<div class="feature-header">
|
<div class="feature-header">
|
||||||
<div class="feature-icon-large">
|
<div class="feature-icon-large">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"
|
||||||
|
/>
|
||||||
<polyline points="14 2 14 8 20 8" />
|
<polyline points="14 2 14 8 20 8" />
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
<line x1="16" y1="13" x2="8" y2="13" />
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
<line x1="16" y1="17" x2="8" y2="17" />
|
||||||
@@ -151,7 +227,12 @@
|
|||||||
<div class="feature-card" data-category="security">
|
<div class="feature-card" data-category="security">
|
||||||
<div class="feature-header">
|
<div class="feature-header">
|
||||||
<div class="feature-icon-large">
|
<div class="feature-icon-large">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
||||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ const args = process.argv.slice(2);
|
|||||||
const isDesktop = args.includes("--desktop");
|
const isDesktop = args.includes("--desktop");
|
||||||
const isSaas = args.includes("--saas");
|
const isSaas = args.includes("--saas");
|
||||||
|
|
||||||
console.log("setup-env: see frontend/README.md#environment-variables for documentation");
|
console.log(
|
||||||
|
"setup-env: see frontend/README.md#environment-variables for documentation",
|
||||||
|
);
|
||||||
|
|
||||||
function getExampleKeys(exampleFile: string): string[] {
|
function getExampleKeys(exampleFile: string): string[] {
|
||||||
const examplePath = join(root, exampleFile);
|
const examplePath = join(root, exampleFile);
|
||||||
@@ -44,7 +46,9 @@ function ensureEnvFile(envFile: string, exampleFile: string): boolean {
|
|||||||
|
|
||||||
config({ path: envPath });
|
config({ path: envPath });
|
||||||
|
|
||||||
const missing = getExampleKeys(exampleFile).filter((k) => !(k in process.env));
|
const missing = getExampleKeys(exampleFile).filter(
|
||||||
|
(k) => !(k in process.env),
|
||||||
|
);
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -62,7 +66,8 @@ let failed = false;
|
|||||||
failed = ensureEnvFile(".env", "config/.env.example") || failed;
|
failed = ensureEnvFile(".env", "config/.env.example") || failed;
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
failed = ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed;
|
failed =
|
||||||
|
ensureEnvFile(".env.desktop", "config/.env.desktop.example") || failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSaas) {
|
if (isSaas) {
|
||||||
@@ -75,7 +80,9 @@ const allExampleKeys = new Set([
|
|||||||
...getExampleKeys("config/.env.desktop.example"),
|
...getExampleKeys("config/.env.desktop.example"),
|
||||||
...getExampleKeys("config/.env.saas.example"),
|
...getExampleKeys("config/.env.saas.example"),
|
||||||
]);
|
]);
|
||||||
const unknownViteVars = Object.keys(process.env).filter((k) => k.startsWith("VITE_") && !allExampleKeys.has(k));
|
const unknownViteVars = Object.keys(process.env).filter(
|
||||||
|
(k) => k.startsWith("VITE_") && !allExampleKeys.has(k),
|
||||||
|
);
|
||||||
if (unknownViteVars.length > 0) {
|
if (unknownViteVars.length > 0) {
|
||||||
console.warn(
|
console.warn(
|
||||||
"setup-env: the following VITE_ vars are set but not listed in any example file:\n" +
|
"setup-env: the following VITE_ vars are set but not listed in any example file:\n" +
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
|
<div
|
||||||
|
style={{ height: "100vh", display: "flex", flexDirection: "column" }}
|
||||||
|
>
|
||||||
{banner}
|
{banner}
|
||||||
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
|
<div style={{ flex: 1, minHeight: 0, height: 0 }}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import { FilesModalProvider } from "@app/contexts/FilesModalContext";
|
|||||||
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
||||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||||
import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext";
|
import {
|
||||||
|
PreferencesProvider,
|
||||||
|
usePreferences,
|
||||||
|
} from "@app/contexts/PreferencesContext";
|
||||||
import {
|
import {
|
||||||
AppConfigProvider,
|
AppConfigProvider,
|
||||||
AppConfigProviderProps,
|
AppConfigProviderProps,
|
||||||
@@ -67,7 +70,10 @@ function BrandingAssetManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Avoid requirement to have props which are required in app providers anyway
|
// Avoid requirement to have props which are required in app providers anyway
|
||||||
type AppConfigProviderOverrides = Omit<AppConfigProviderProps, "children" | "retryOptions">;
|
type AppConfigProviderOverrides = Omit<
|
||||||
|
AppConfigProviderProps,
|
||||||
|
"children" | "retryOptions"
|
||||||
|
>;
|
||||||
|
|
||||||
export interface AppProvidersProps {
|
export interface AppProvidersProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -84,7 +90,8 @@ function ServerDefaultsSync() {
|
|||||||
if (config) {
|
if (config) {
|
||||||
const serverDefaults = {
|
const serverDefaults = {
|
||||||
hideUnavailableTools: config.defaultHideUnavailableTools ?? false,
|
hideUnavailableTools: config.defaultHideUnavailableTools ?? false,
|
||||||
hideUnavailableConversions: config.defaultHideUnavailableConversions ?? false,
|
hideUnavailableConversions:
|
||||||
|
config.defaultHideUnavailableConversions ?? false,
|
||||||
};
|
};
|
||||||
updateServerDefaults(serverDefaults);
|
updateServerDefaults(serverDefaults);
|
||||||
}
|
}
|
||||||
@@ -97,17 +104,27 @@ function ServerDefaultsSync() {
|
|||||||
* Core application providers
|
* Core application providers
|
||||||
* Contains all providers needed for the core
|
* Contains all providers needed for the core
|
||||||
*/
|
*/
|
||||||
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
|
export function AppProviders({
|
||||||
|
children,
|
||||||
|
appConfigRetryOptions,
|
||||||
|
appConfigProviderProps,
|
||||||
|
}: AppProvidersProps) {
|
||||||
return (
|
return (
|
||||||
<PreferencesProvider>
|
<PreferencesProvider>
|
||||||
<RainbowThemeProvider>
|
<RainbowThemeProvider>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<BannerProvider>
|
<BannerProvider>
|
||||||
<AppConfigProvider retryOptions={appConfigRetryOptions} {...appConfigProviderProps}>
|
<AppConfigProvider
|
||||||
|
retryOptions={appConfigRetryOptions}
|
||||||
|
{...appConfigProviderProps}
|
||||||
|
>
|
||||||
<ScarfTrackingInitializer />
|
<ScarfTrackingInitializer />
|
||||||
<AppConfigLoader />
|
<AppConfigLoader />
|
||||||
<ServerDefaultsSync />
|
<ServerDefaultsSync />
|
||||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
<FileContextProvider
|
||||||
|
enableUrlSync={true}
|
||||||
|
enablePersistence={true}
|
||||||
|
>
|
||||||
<AppInitializer />
|
<AppInitializer />
|
||||||
<BrandingAssetManager />
|
<BrandingAssetManager />
|
||||||
<ToolRegistryProvider>
|
<ToolRegistryProvider>
|
||||||
@@ -124,7 +141,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
|||||||
<AnnotationProvider>
|
<AnnotationProvider>
|
||||||
<RightRailProvider>
|
<RightRailProvider>
|
||||||
<TourOrchestrationProvider>
|
<TourOrchestrationProvider>
|
||||||
<AdminTourOrchestrationProvider>{children}</AdminTourOrchestrationProvider>
|
<AdminTourOrchestrationProvider>
|
||||||
|
{children}
|
||||||
|
</AdminTourOrchestrationProvider>
|
||||||
</TourOrchestrationProvider>
|
</TourOrchestrationProvider>
|
||||||
</RightRailProvider>
|
</RightRailProvider>
|
||||||
</AnnotationProvider>
|
</AnnotationProvider>
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import DesktopLayout from "@app/components/fileManager/DesktopLayout";
|
|||||||
import DragOverlay from "@app/components/fileManager/DragOverlay";
|
import DragOverlay from "@app/components/fileManager/DragOverlay";
|
||||||
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
|
import { FileManagerProvider } from "@app/contexts/FileManagerContext";
|
||||||
import { Z_INDEX_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
import { Z_INDEX_FILE_MANAGER_MODAL } from "@app/styles/zIndex";
|
||||||
import { isGoogleDriveConfigured, extractGoogleDriveBackendConfig } from "@app/services/googleDrivePickerService";
|
import {
|
||||||
|
isGoogleDriveConfigured,
|
||||||
|
extractGoogleDriveBackendConfig,
|
||||||
|
} from "@app/services/googleDrivePickerService";
|
||||||
import { loadScript } from "@app/utils/scriptLoader";
|
import { loadScript } from "@app/utils/scriptLoader";
|
||||||
import { useAllFiles } from "@app/contexts/FileContext";
|
import { useAllFiles } from "@app/contexts/FileContext";
|
||||||
|
|
||||||
@@ -20,7 +23,12 @@ interface FileManagerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||||
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
|
const {
|
||||||
|
isFilesModalOpen,
|
||||||
|
closeFilesModal,
|
||||||
|
onFileUpload,
|
||||||
|
onRecentFileSelect,
|
||||||
|
} = useFilesModalContext();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -101,7 +109,9 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
return () => {
|
return () => {
|
||||||
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
|
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
|
||||||
// Blob URLs are managed by FileContext and tool operations
|
// Blob URLs are managed by FileContext and tool operations
|
||||||
console.log("FileManager unmounting - FileContext handles blob URL cleanup");
|
console.log(
|
||||||
|
"FileManager unmounting - FileContext handles blob URL cleanup",
|
||||||
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -109,7 +119,12 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
|||||||
// Use useMemo to only track Google Drive config changes, not all config updates
|
// Use useMemo to only track Google Drive config changes, not all config updates
|
||||||
const googleDriveBackendConfig = useMemo(
|
const googleDriveBackendConfig = useMemo(
|
||||||
() => extractGoogleDriveBackendConfig(config),
|
() => extractGoogleDriveBackendConfig(config),
|
||||||
[config?.googleDriveEnabled, config?.googleDriveClientId, config?.googleDriveApiKey, config?.googleDriveAppId],
|
[
|
||||||
|
config?.googleDriveEnabled,
|
||||||
|
config?.googleDriveClientId,
|
||||||
|
config?.googleDriveApiKey,
|
||||||
|
config?.googleDriveAppId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ interface StorageStatsCardProps {
|
|||||||
onReloadFiles: () => void;
|
onReloadFiles: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, filesCount, onClearAll, onReloadFiles }) => {
|
const StorageStatsCard: React.FC<StorageStatsCardProps> = ({
|
||||||
|
storageStats,
|
||||||
|
filesCount,
|
||||||
|
onClearAll,
|
||||||
|
onReloadFiles,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
if (!storageStats) return null;
|
if (!storageStats) return null;
|
||||||
@@ -27,19 +32,27 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, files
|
|||||||
<StorageIcon />
|
<StorageIcon />
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{t("fileManager.storage", "Storage")}: {formatFileSize(storageStats.used)}
|
{t("fileManager.storage", "Storage")}:{" "}
|
||||||
|
{formatFileSize(storageStats.used)}
|
||||||
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`}
|
{storageStats.quota && ` / ${formatFileSize(storageStats.quota)}`}
|
||||||
</Text>
|
</Text>
|
||||||
{storageStats.quota && (
|
{storageStats.quota && (
|
||||||
<Progress
|
<Progress
|
||||||
value={storageUsagePercent}
|
value={storageUsagePercent}
|
||||||
color={storageUsagePercent > 80 ? "red" : storageUsagePercent > 60 ? "yellow" : "blue"}
|
color={
|
||||||
|
storageUsagePercent > 80
|
||||||
|
? "red"
|
||||||
|
: storageUsagePercent > 60
|
||||||
|
? "yellow"
|
||||||
|
: "blue"
|
||||||
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
mt={4}
|
mt={4}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{storageStats.fileCount} {t("fileManager.filesStored", "files stored")}
|
{storageStats.fileCount}{" "}
|
||||||
|
{t("fileManager.filesStored", "files stored")}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
@@ -54,7 +67,12 @@ const StorageStatsCard: React.FC<StorageStatsCardProps> = ({ storageStats, files
|
|||||||
{t("fileManager.clearAll", "Clear All")}
|
{t("fileManager.clearAll", "Clear All")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="light" color="blue" size="xs" onClick={onReloadFiles}>
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="blue"
|
||||||
|
size="xs"
|
||||||
|
onClick={onReloadFiles}
|
||||||
|
>
|
||||||
Reload Files
|
Reload Files
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ interface PDFAnnotationContextValue {
|
|||||||
setSignatureConfig: (config: any | null) => void;
|
setSignatureConfig: (config: any | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PDFAnnotationContext = createContext<PDFAnnotationContextValue | undefined>(undefined);
|
const PDFAnnotationContext = createContext<
|
||||||
|
PDFAnnotationContextValue | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
interface PDFAnnotationProviderProps {
|
interface PDFAnnotationProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -75,13 +77,19 @@ export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
|
|||||||
setSignatureConfig,
|
setSignatureConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
return <PDFAnnotationContext.Provider value={contextValue}>{children}</PDFAnnotationContext.Provider>;
|
return (
|
||||||
|
<PDFAnnotationContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</PDFAnnotationContext.Provider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
export const usePDFAnnotation = (): PDFAnnotationContextValue => {
|
||||||
const context = useContext(PDFAnnotationContext);
|
const context = useContext(PDFAnnotationContext);
|
||||||
if (context === undefined) {
|
if (context === undefined) {
|
||||||
throw new Error("usePDFAnnotation must be used within a PDFAnnotationProvider");
|
throw new Error(
|
||||||
|
"usePDFAnnotation must be used within a PDFAnnotationProvider",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,7 +34,10 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
|||||||
const [selectedColor, setSelectedColor] = useState("#000000");
|
const [selectedColor, setSelectedColor] = useState("#000000");
|
||||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
const [historyAvailability, setHistoryAvailability] = useState({
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
const historyApiInstance = historyApiRef.current;
|
const historyApiInstance = historyApiRef.current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,7 +80,9 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
|||||||
onRedo={redo}
|
onRedo={redo}
|
||||||
canUndo={historyAvailability.canUndo}
|
canUndo={historyAvailability.canUndo}
|
||||||
canRedo={historyAvailability.canRedo}
|
canRedo={historyAvailability.canRedo}
|
||||||
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
|
onPlaceSignature={
|
||||||
|
config.showPlaceButton ? handlePlaceSignature : undefined
|
||||||
|
}
|
||||||
hasSignatureData={!!signatureData}
|
hasSignatureData={!!signatureData}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
showPlaceButton={config.showPlaceButton}
|
showPlaceButton={config.showPlaceButton}
|
||||||
@@ -94,8 +99,13 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Instructions for placing signature */}
|
{/* Instructions for placing signature */}
|
||||||
<Alert color="blue" title={t("sign.instructions.title", "How to add signature")}>
|
<Alert
|
||||||
<Text size="sm">Click anywhere on the PDF to place your annotation.</Text>
|
color="blue"
|
||||||
|
title={t("sign.instructions.title", "How to add signature")}
|
||||||
|
>
|
||||||
|
<Text size="sm">
|
||||||
|
Click anywhere on the PDF to place your annotation.
|
||||||
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
{/* Color Picker Modal */}
|
{/* Color Picker Modal */}
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker, Group } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
Popover,
|
||||||
|
Stack,
|
||||||
|
ColorSwatch,
|
||||||
|
ColorPicker as MantineColorPicker,
|
||||||
|
Group,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import ColorizeIcon from "@mui/icons-material/Colorize";
|
import ColorizeIcon from "@mui/icons-material/Colorize";
|
||||||
|
|
||||||
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
|
// safari and firefox do not support the eye dropper API, only edge, chrome and opera do.
|
||||||
// the button is hidden in the UI if the API is not supported.
|
// the button is hidden in the UI if the API is not supported.
|
||||||
const supportsEyeDropper = typeof window !== "undefined" && "EyeDropper" in window;
|
const supportsEyeDropper =
|
||||||
|
typeof window !== "undefined" && "EyeDropper" in window;
|
||||||
|
|
||||||
interface EyeDropper {
|
interface EyeDropper {
|
||||||
open(): Promise<{ sRGBHex: string }>;
|
open(): Promise<{ sRGBHex: string }>;
|
||||||
@@ -18,7 +27,12 @@ interface ColorControlProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) {
|
export function ColorControl({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
disabled = false,
|
||||||
|
}: ColorControlProps) {
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
// Buffer the colour locally so the picker stays responsive during drag.
|
// Buffer the colour locally so the picker stays responsive during drag.
|
||||||
// Only propagate to the parent (which triggers expensive annotation updates)
|
// Only propagate to the parent (which triggers expensive annotation updates)
|
||||||
@@ -40,7 +54,13 @@ export function ColorControl({ value, onChange, label, disabled = false }: Color
|
|||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow withinPortal>
|
<Popover
|
||||||
|
opened={opened}
|
||||||
|
onChange={setOpened}
|
||||||
|
position="bottom"
|
||||||
|
withArrow
|
||||||
|
withinPortal
|
||||||
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<Tooltip label={label}>
|
<Tooltip label={label}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch, Slider, Text } from "@mantine/core";
|
import {
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
ColorPicker as MantineColorPicker,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
ColorSwatch,
|
||||||
|
Slider,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
@@ -27,16 +36,30 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const resolvedTitle = title ?? t("colorPicker.title", "Choose colour");
|
const resolvedTitle = title ?? t("colorPicker.title", "Choose colour");
|
||||||
const resolvedOpacityLabel = opacityLabel ?? t("annotation.opacity", "Opacity");
|
const resolvedOpacityLabel =
|
||||||
|
opacityLabel ?? t("annotation.opacity", "Opacity");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal opened={isOpen} onClose={onClose} title={resolvedTitle} size="sm" centered>
|
<Modal
|
||||||
|
opened={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={resolvedTitle}
|
||||||
|
size="sm"
|
||||||
|
centered
|
||||||
|
>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<MantineColorPicker
|
<MantineColorPicker
|
||||||
format="hex"
|
format="hex"
|
||||||
value={selectedColor}
|
value={selectedColor}
|
||||||
onChange={onColorChange}
|
onChange={onColorChange}
|
||||||
swatches={["#000000", "#0066cc", "#cc0000", "#cc6600", "#009900", "#6600cc"]}
|
swatches={[
|
||||||
|
"#000000",
|
||||||
|
"#0066cc",
|
||||||
|
"#cc0000",
|
||||||
|
"#cc6600",
|
||||||
|
"#009900",
|
||||||
|
"#6600cc",
|
||||||
|
]}
|
||||||
swatchesPerRow={6}
|
swatchesPerRow={6}
|
||||||
size="lg"
|
size="lg"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -74,6 +97,18 @@ interface ColorSwatchButtonProps {
|
|||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({ color, onClick, size = 24 }) => {
|
export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
|
||||||
return <ColorSwatch color={color} size={size} radius={0} style={{ cursor: "pointer" }} onClick={onClick} />;
|
color,
|
||||||
|
onClick,
|
||||||
|
size = 24,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<ColorSwatch
|
||||||
|
color={color}
|
||||||
|
size={size}
|
||||||
|
radius={0}
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const padRef = useRef<SignaturePad | null>(null);
|
const padRef = useRef<SignaturePad | null>(null);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(null);
|
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
const initPad = (canvas: HTMLCanvasElement) => {
|
const initPad = (canvas: HTMLCanvasElement) => {
|
||||||
if (!padRef.current) {
|
if (!padRef.current) {
|
||||||
@@ -125,7 +127,17 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
trimmedCanvas.height = trimHeight;
|
trimmedCanvas.height = trimHeight;
|
||||||
const trimmedCtx = trimmedCanvas.getContext("2d");
|
const trimmedCtx = trimmedCanvas.getContext("2d");
|
||||||
if (trimmedCtx) {
|
if (trimmedCtx) {
|
||||||
trimmedCtx.drawImage(canvas, minX, minY, trimWidth, trimHeight, 0, 0, trimWidth, trimHeight);
|
trimmedCtx.drawImage(
|
||||||
|
canvas,
|
||||||
|
minX,
|
||||||
|
minY,
|
||||||
|
trimWidth,
|
||||||
|
trimHeight,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
trimWidth,
|
||||||
|
trimHeight,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return trimmedCanvas.toDataURL("image/png");
|
return trimmedCanvas.toDataURL("image/png");
|
||||||
@@ -140,7 +152,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
|
const scale = Math.min(
|
||||||
|
canvas.width / img.width,
|
||||||
|
canvas.height / img.height,
|
||||||
|
);
|
||||||
const scaledWidth = img.width * scale;
|
const scaledWidth = img.width * scale;
|
||||||
const scaledHeight = img.height * scale;
|
const scaledHeight = img.height * scale;
|
||||||
const x = (canvas.width - scaledWidth) / 2;
|
const x = (canvas.width - scaledWidth) / 2;
|
||||||
@@ -181,7 +196,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
if (previewCanvasRef.current) {
|
if (previewCanvasRef.current) {
|
||||||
const ctx = previewCanvasRef.current.getContext("2d");
|
const ctx = previewCanvasRef.current.getContext("2d");
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
|
ctx.clearRect(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
previewCanvasRef.current.width,
|
||||||
|
previewCanvasRef.current.height,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setSavedSignatureData(null); // Clear saved signature
|
setSavedSignatureData(null); // Clear saved signature
|
||||||
@@ -230,7 +250,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
<Paper withBorder p="md">
|
<Paper withBorder p="md">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
<Text fw={500}>{t("sign.canvas.heading", "Draw your signature")}</Text>
|
<Text fw={500}>
|
||||||
|
{t("sign.canvas.heading", "Draw your signature")}
|
||||||
|
</Text>
|
||||||
<canvas
|
<canvas
|
||||||
ref={previewCanvasRef}
|
ref={previewCanvasRef}
|
||||||
width={width}
|
width={width}
|
||||||
@@ -264,7 +286,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{t("sign.canvas.colorLabel", "Colour")}
|
{t("sign.canvas.colorLabel", "Colour")}
|
||||||
</Text>
|
</Text>
|
||||||
<ColorSwatchButton color={selectedColor} onClick={onColorSwatchClick} />
|
<ColorSwatchButton
|
||||||
|
color={selectedColor}
|
||||||
|
onClick={onColorSwatchClick}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
<Stack gap={4} style={{ minWidth: 120 }}>
|
<Stack gap={4} style={{ minWidth: 120 }}>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
|
|||||||
@@ -44,7 +44,12 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
|||||||
disabled={undoDisabled}
|
disabled={undoDisabled}
|
||||||
color={undoDisabled ? "gray" : "blue"}
|
color={undoDisabled ? "gray" : "blue"}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="undo" width={20} height={20} style={{ color: "currentColor" }} />
|
<LocalIcon
|
||||||
|
icon="undo"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
style={{ color: "currentColor" }}
|
||||||
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -58,7 +63,12 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
|||||||
disabled={redoDisabled}
|
disabled={redoDisabled}
|
||||||
color={redoDisabled ? "gray" : "blue"}
|
color={redoDisabled ? "gray" : "blue"}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="redo" width={20} height={20} style={{ color: "currentColor" }} />
|
<LocalIcon
|
||||||
|
icon="redo"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
style={{ color: "currentColor" }}
|
||||||
|
/>
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -67,7 +77,13 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
|
|||||||
|
|
||||||
{/* Place Signature Button */}
|
{/* Place Signature Button */}
|
||||||
{showPlaceButton && onPlaceSignature && (
|
{showPlaceButton && onPlaceSignature && (
|
||||||
<Button variant="filled" color="blue" onClick={onPlaceSignature} disabled={disabled || !hasSignatureData} ml="auto">
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="blue"
|
||||||
|
onClick={onPlaceSignature}
|
||||||
|
disabled={disabled || !hasSignatureData}
|
||||||
|
ml="auto"
|
||||||
|
>
|
||||||
{placeButtonText}
|
{placeButtonText}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -27,22 +27,33 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [removeBackground, setRemoveBackground] = useState(false);
|
const [removeBackground, setRemoveBackground] = useState(false);
|
||||||
const [currentFile, setCurrentFile] = useState<File | null>(null);
|
const [currentFile, setCurrentFile] = useState<File | null>(null);
|
||||||
const [originalImageData, setOriginalImageData] = useState<string | null>(null);
|
const [originalImageData, setOriginalImageData] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [isProcessing, setIsProcessing] = useState(false);
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
|
||||||
const processImage = async (imageSource: File | string, shouldRemoveBackground: boolean): Promise<void> => {
|
const processImage = async (
|
||||||
|
imageSource: File | string,
|
||||||
|
shouldRemoveBackground: boolean,
|
||||||
|
): Promise<void> => {
|
||||||
if (shouldRemoveBackground && allowBackgroundRemoval) {
|
if (shouldRemoveBackground && allowBackgroundRemoval) {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
const transparentImageDataUrl = await removeWhiteBackground(imageSource, {
|
const transparentImageDataUrl = await removeWhiteBackground(
|
||||||
autoDetectCorner: true,
|
imageSource,
|
||||||
tolerance: 15,
|
{
|
||||||
});
|
autoDetectCorner: true,
|
||||||
|
tolerance: 15,
|
||||||
|
},
|
||||||
|
);
|
||||||
onProcessedImageData?.(transparentImageDataUrl);
|
onProcessedImageData?.(transparentImageDataUrl);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error removing background:", error);
|
console.error("Error removing background:", error);
|
||||||
alert({
|
alert({
|
||||||
title: t("sign.image.backgroundRemovalFailedTitle", "Background removal failed"),
|
title: t(
|
||||||
|
"sign.image.backgroundRemovalFailedTitle",
|
||||||
|
"Background removal failed",
|
||||||
|
),
|
||||||
body: t(
|
body: t(
|
||||||
"sign.image.backgroundRemovalFailedMessage",
|
"sign.image.backgroundRemovalFailedMessage",
|
||||||
"Could not remove the background from the image. Using original image instead.",
|
"Could not remove the background from the image. Using original image instead.",
|
||||||
@@ -72,7 +83,10 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
if (file && !disabled) {
|
if (file && !disabled) {
|
||||||
try {
|
try {
|
||||||
// Validate that it's actually an image file or SVG
|
// Validate that it's actually an image file or SVG
|
||||||
if (!file.type.startsWith("image/") && !file.name.toLowerCase().endsWith(".svg")) {
|
if (
|
||||||
|
!file.type.startsWith("image/") &&
|
||||||
|
!file.name.toLowerCase().endsWith(".svg")
|
||||||
|
) {
|
||||||
console.error("Selected file is not an image or SVG");
|
console.error("Selected file is not an image or SVG");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -83,7 +97,9 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
let dataUrlToProcess: string;
|
let dataUrlToProcess: string;
|
||||||
|
|
||||||
// Check if file is SVG
|
// Check if file is SVG
|
||||||
const isSvg = file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
|
const isSvg =
|
||||||
|
file.type === "image/svg+xml" ||
|
||||||
|
file.name.toLowerCase().endsWith(".svg");
|
||||||
|
|
||||||
if (isSvg) {
|
if (isSvg) {
|
||||||
// For SVG, convert to PNG so it can be embedded in PDF
|
// For SVG, convert to PNG so it can be embedded in PDF
|
||||||
@@ -129,7 +145,10 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
let width = 800; // Default width
|
let width = 800; // Default width
|
||||||
let height = 600; // Default height
|
let height = 600; // Default height
|
||||||
|
|
||||||
if (svgElement.hasAttribute("width") && svgElement.hasAttribute("height")) {
|
if (
|
||||||
|
svgElement.hasAttribute("width") &&
|
||||||
|
svgElement.hasAttribute("height")
|
||||||
|
) {
|
||||||
width = parseFloat(svgElement.getAttribute("width") || "800");
|
width = parseFloat(svgElement.getAttribute("width") || "800");
|
||||||
height = parseFloat(svgElement.getAttribute("height") || "600");
|
height = parseFloat(svgElement.getAttribute("height") || "600");
|
||||||
} else if (svgElement.hasAttribute("viewBox")) {
|
} else if (svgElement.hasAttribute("viewBox")) {
|
||||||
@@ -141,7 +160,12 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ensure reasonable dimensions
|
// Ensure reasonable dimensions
|
||||||
if (width === 0 || height === 0 || !isFinite(width) || !isFinite(height)) {
|
if (
|
||||||
|
width === 0 ||
|
||||||
|
height === 0 ||
|
||||||
|
!isFinite(width) ||
|
||||||
|
!isFinite(height)
|
||||||
|
) {
|
||||||
width = 800;
|
width = 800;
|
||||||
height = 600;
|
height = 600;
|
||||||
}
|
}
|
||||||
@@ -158,7 +182,9 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
|
|
||||||
// Create an image element to render SVG
|
// Create an image element to render SVG
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
const blob = new Blob([svgText], { type: "image/svg+xml;charset=utf-8" });
|
const blob = new Blob([svgText], {
|
||||||
|
type: "image/svg+xml;charset=utf-8",
|
||||||
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
@@ -239,7 +265,9 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
<PrivateContent>
|
<PrivateContent>
|
||||||
<FileInput
|
<FileInput
|
||||||
label={label}
|
label={label}
|
||||||
placeholder={placeholder || t("sign.image.placeholder", "Select image file")}
|
placeholder={
|
||||||
|
placeholder || t("sign.image.placeholder", "Select image file")
|
||||||
|
}
|
||||||
accept="image/*,.svg"
|
accept="image/*,.svg"
|
||||||
onChange={handleImageChange}
|
onChange={handleImageChange}
|
||||||
disabled={disabled || isProcessing}
|
disabled={disabled || isProcessing}
|
||||||
@@ -247,9 +275,14 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({
|
|||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
{allowBackgroundRemoval && (
|
{allowBackgroundRemoval && (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label={t("sign.image.removeBackground", "Remove white background (make transparent)")}
|
label={t(
|
||||||
|
"sign.image.removeBackground",
|
||||||
|
"Remove white background (make transparent)",
|
||||||
|
)}
|
||||||
checked={removeBackground}
|
checked={removeBackground}
|
||||||
onChange={(event) => handleBackgroundRemovalChange(event.currentTarget.checked)}
|
onChange={(event) =>
|
||||||
|
handleBackgroundRemovalChange(event.currentTarget.checked)
|
||||||
|
}
|
||||||
disabled={disabled || !currentFile || isProcessing}
|
disabled={disabled || !currentFile || isProcessing}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
Popover,
|
||||||
|
Stack,
|
||||||
|
Slider,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import OpacityIcon from "@mui/icons-material/Opacity";
|
import OpacityIcon from "@mui/icons-material/Opacity";
|
||||||
@@ -9,7 +16,11 @@ interface OpacityControlProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) {
|
export function OpacityControl({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: OpacityControlProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
|
|
||||||
@@ -46,7 +57,13 @@ export function OpacityControl({ value, onChange, disabled = false }: OpacityCon
|
|||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
{t("annotation.opacity", "Opacity")}
|
{t("annotation.opacity", "Opacity")}
|
||||||
</Text>
|
</Text>
|
||||||
<Slider value={value} onChange={onChange} min={10} max={100} label={(val) => `${val}%`} />
|
<Slider
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
label={(val) => `${val}%`}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
Popover,
|
||||||
|
Stack,
|
||||||
|
Slider,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Button,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
|
import type { TrackedAnnotation } from "@embedpdf/plugin-annotation";
|
||||||
@@ -18,7 +27,12 @@ interface PropertiesPopoverProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PropertiesPopover({ annotationType, annotation, onUpdate, disabled = false }: PropertiesPopoverProps) {
|
export function PropertiesPopover({
|
||||||
|
annotationType,
|
||||||
|
annotation,
|
||||||
|
onUpdate,
|
||||||
|
disabled = false,
|
||||||
|
}: PropertiesPopoverProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
|
|
||||||
@@ -30,7 +44,9 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
|
|||||||
strokeWidth?: number;
|
strokeWidth?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const obj = annotation?.object as (PdfAnnotationObject & AnnotationObjectProps) | undefined;
|
const obj = annotation?.object as
|
||||||
|
| (PdfAnnotationObject & AnnotationObjectProps)
|
||||||
|
| undefined;
|
||||||
|
|
||||||
// Get current values
|
// Get current values
|
||||||
const fontSize = obj?.fontSize ?? 14;
|
const fontSize = obj?.fontSize ?? 14;
|
||||||
@@ -171,7 +187,9 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{borderVisible ? t("annotation.borderOn", "Border: On") : t("annotation.borderOff", "Border: Off")}
|
{borderVisible
|
||||||
|
? t("annotation.borderOn", "Border: On")
|
||||||
|
: t("annotation.borderOff", "Border: Off")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +225,8 @@ export function PropertiesPopover({ annotationType, annotation, onUpdate, disabl
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Popover.Target>
|
</Popover.Target>
|
||||||
<Popover.Dropdown>
|
<Popover.Dropdown>
|
||||||
{(annotationType === "text" || annotationType === "note") && renderTextNoteControls()}
|
{(annotationType === "text" || annotationType === "note") &&
|
||||||
|
renderTextNoteControls()}
|
||||||
{annotationType === "shape" && renderShapeControls()}
|
{annotationType === "shape" && renderShapeControls()}
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Stack, TextInput, Select, Combobox, useCombobox, Group, Box, SegmentedControl } from "@mantine/core";
|
import {
|
||||||
|
Stack,
|
||||||
|
TextInput,
|
||||||
|
Select,
|
||||||
|
Combobox,
|
||||||
|
useCombobox,
|
||||||
|
Group,
|
||||||
|
Box,
|
||||||
|
SegmentedControl,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from "@mantine/core";
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
Popover,
|
||||||
|
Stack,
|
||||||
|
Slider,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import LineWeightIcon from "@mui/icons-material/LineWeight";
|
import LineWeightIcon from "@mui/icons-material/LineWeight";
|
||||||
@@ -11,7 +18,13 @@ interface WidthControlProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WidthControl({ value, onChange, min, max, disabled = false }: WidthControlProps) {
|
export function WidthControl({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
disabled = false,
|
||||||
|
}: WidthControlProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
|
|
||||||
@@ -48,7 +61,13 @@ export function WidthControl({ value, onChange, min, max, disabled = false }: Wi
|
|||||||
<Text size="xs" fw={500}>
|
<Text size="xs" fw={500}>
|
||||||
{t("annotation.width", "Width")}
|
{t("annotation.width", "Width")}
|
||||||
</Text>
|
</Text>
|
||||||
<Slider value={value} onChange={onChange} min={min} max={max} label={(val) => `${val}pt`} />
|
<Slider
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
label={(val) => `${val}pt`}
|
||||||
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ interface DrawingToolProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disabled = false }) => {
|
export const DrawingTool: React.FC<DrawingToolProps> = ({
|
||||||
|
onDrawingChange,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
const [selectedColor] = useState("#000000");
|
const [selectedColor] = useState("#000000");
|
||||||
const [penSize, setPenSize] = useState(2);
|
const [penSize, setPenSize] = useState(2);
|
||||||
const [penSizeInput, setPenSizeInput] = useState("2");
|
const [penSizeInput, setPenSizeInput] = useState("2");
|
||||||
@@ -20,7 +23,11 @@ export const DrawingTool: React.FC<DrawingToolProps> = ({ onDrawingChange, disab
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onDrawingChange} disabled={disabled}>
|
<BaseAnnotationTool
|
||||||
|
config={toolConfig}
|
||||||
|
onSignatureDataChange={onDrawingChange}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<DrawingCanvas
|
<DrawingCanvas
|
||||||
selectedColor={selectedColor}
|
selectedColor={selectedColor}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ interface ImageToolProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled = false }) => {
|
export const ImageTool: React.FC<ImageToolProps> = ({
|
||||||
|
onImageChange,
|
||||||
|
disabled = false,
|
||||||
|
}) => {
|
||||||
const [, setImageData] = useState<string | null>(null);
|
const [, setImageData] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleImageUpload = async (file: File | null) => {
|
const handleImageUpload = async (file: File | null) => {
|
||||||
@@ -45,7 +48,11 @@ export const ImageTool: React.FC<ImageToolProps> = ({ onImageChange, disabled =
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseAnnotationTool config={toolConfig} onSignatureDataChange={onImageChange} disabled={disabled}>
|
<BaseAnnotationTool
|
||||||
|
config={toolConfig}
|
||||||
|
onSignatureDataChange={onImageChange}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<ImageUploader
|
<ImageUploader
|
||||||
onImageChange={handleImageUpload}
|
onImageChange={handleImageUpload}
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ interface AddFileCardProps {
|
|||||||
multiple?: boolean;
|
multiple?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps) => {
|
const AddFileCard = ({
|
||||||
|
onFileSelect,
|
||||||
|
accept,
|
||||||
|
multiple = true,
|
||||||
|
}: AddFileCardProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { openFilesModal } = useFilesModalContext();
|
const { openFilesModal } = useFilesModalContext();
|
||||||
@@ -84,7 +88,9 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
|
|||||||
<div className={styles.logoMark}>
|
<div className={styles.logoMark}>
|
||||||
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
|
<AddIcon sx={{ color: "inherit", fontSize: "1.5rem" }} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.headerIndex}>{t("fileEditor.addFiles", "Add Files")}</div>
|
<div className={styles.headerIndex}>
|
||||||
|
{t("fileEditor.addFiles", "Add Files")}
|
||||||
|
</div>
|
||||||
<div className={styles.kebab} />
|
<div className={styles.kebab} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,8 +137,15 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
|
|||||||
onClick={handleOpenFilesModal}
|
onClick={handleOpenFilesModal}
|
||||||
onMouseEnter={() => setIsUploadHover(false)}
|
onMouseEnter={() => setIsUploadHover(false)}
|
||||||
>
|
>
|
||||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
<LocalIcon
|
||||||
{!isUploadHover && <span>{t("landing.addFiles", "Add Files")}</span>}
|
icon="add"
|
||||||
|
width="1.5rem"
|
||||||
|
height="1.5rem"
|
||||||
|
className="text-[var(--accent-interactive)]"
|
||||||
|
/>
|
||||||
|
{!isUploadHover && (
|
||||||
|
<span>{t("landing.addFiles", "Add Files")}</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
aria-label="Upload"
|
aria-label="Upload"
|
||||||
@@ -160,14 +173,22 @@ const AddFileCard = ({ onFileSelect, accept, multiple = true }: AddFileCardProps
|
|||||||
height="1.25rem"
|
height="1.25rem"
|
||||||
style={{ color: "var(--accent-interactive)" }}
|
style={{ color: "var(--accent-interactive)" }}
|
||||||
/>
|
/>
|
||||||
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
|
{isUploadHover && (
|
||||||
|
<span style={{ marginLeft: ".5rem" }}>
|
||||||
|
{terminology.uploadFromComputer}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Instruction Text */}
|
{/* Instruction Text */}
|
||||||
<span
|
<span
|
||||||
className="text-[var(--accent-interactive)]"
|
className="text-[var(--accent-interactive)]"
|
||||||
style={{ fontSize: ".8rem", textAlign: "center", marginTop: "0.5rem" }}
|
style={{
|
||||||
|
fontSize: ".8rem",
|
||||||
|
textAlign: "center",
|
||||||
|
marginTop: "0.5rem",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{terminology.dropFilesHere}
|
{terminology.dropFilesHere}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||||
import { Dropzone } from "@mantine/dropzone";
|
import { Dropzone } from "@mantine/dropzone";
|
||||||
import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from "@app/contexts/FileContext";
|
import {
|
||||||
|
useFileSelection,
|
||||||
|
useFileState,
|
||||||
|
useFileManagement,
|
||||||
|
useFileActions,
|
||||||
|
useFileContext,
|
||||||
|
} from "@app/contexts/FileContext";
|
||||||
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
import { useNavigationActions } from "@app/contexts/NavigationContext";
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { zipFileService } from "@app/services/zipFileService";
|
import { zipFileService } from "@app/services/zipFileService";
|
||||||
@@ -22,7 +28,10 @@ interface FileEditorProps {
|
|||||||
supportedExtensions?: string[];
|
supportedExtensions?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEditorProps) => {
|
const FileEditor = ({
|
||||||
|
toolMode = false,
|
||||||
|
supportedExtensions = ["pdf"],
|
||||||
|
}: FileEditorProps) => {
|
||||||
// Utility function to check if a file extension is supported
|
// Utility function to check if a file extension is supported
|
||||||
const isFileSupported = useCallback(
|
const isFileSupported = useCallback(
|
||||||
(fileName: string): boolean => {
|
(fileName: string): boolean => {
|
||||||
@@ -40,7 +49,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
const { clearAllFileErrors } = fileContextActions;
|
const { clearAllFileErrors } = fileContextActions;
|
||||||
|
|
||||||
// Extract needed values from state (memoized to prevent infinite loops)
|
// Extract needed values from state (memoized to prevent infinite loops)
|
||||||
const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [state.files.byId, state.files.ids]);
|
const activeStirlingFileStubs = useMemo(
|
||||||
|
() => selectors.getStirlingFileStubs(),
|
||||||
|
[state.files.byId, state.files.ids],
|
||||||
|
);
|
||||||
const selectedFileIds = state.ui.selectedFileIds;
|
const selectedFileIds = state.ui.selectedFileIds;
|
||||||
const totalItems = state.files.ids.length;
|
const totalItems = state.files.ids.length;
|
||||||
const selectedCount = selectedFileIds.length;
|
const selectedCount = selectedFileIds.length;
|
||||||
@@ -58,11 +70,27 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
const [_error, _setError] = useState<string | null>(null);
|
const [_error, _setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Toast helpers
|
// Toast helpers
|
||||||
const showStatus = useCallback((message: string, type: "neutral" | "success" | "warning" | "error" = "neutral") => {
|
const showStatus = useCallback(
|
||||||
alert({ alertType: type, title: message, expandable: false, durationMs: 4000 });
|
(
|
||||||
}, []);
|
message: string,
|
||||||
|
type: "neutral" | "success" | "warning" | "error" = "neutral",
|
||||||
|
) => {
|
||||||
|
alert({
|
||||||
|
alertType: type,
|
||||||
|
title: message,
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 4000,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
const showError = useCallback((message: string) => {
|
const showError = useCallback((message: string) => {
|
||||||
alert({ alertType: "error", title: "Error", body: message, expandable: true });
|
alert({
|
||||||
|
alertType: "error",
|
||||||
|
title: "Error",
|
||||||
|
body: message,
|
||||||
|
expandable: true,
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
const [selectionMode, setSelectionMode] = useState(toolMode);
|
const [selectionMode, setSelectionMode] = useState(toolMode);
|
||||||
|
|
||||||
@@ -83,7 +111,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
}, [toolMode]);
|
}, [toolMode]);
|
||||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||||
// Get selected file IDs from context (defensive programming)
|
// Get selected file IDs from context (defensive programming)
|
||||||
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
|
const contextSelectedIds = Array.isArray(selectedFileIds)
|
||||||
|
? selectedFileIds
|
||||||
|
: [];
|
||||||
|
|
||||||
// Create refs for frequently changing values to stabilize callbacks
|
// Create refs for frequently changing values to stabilize callbacks
|
||||||
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
const contextSelectedIdsRef = useRef<FileId[]>([]);
|
||||||
@@ -95,7 +125,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
const handleSelectAllFiles = useCallback(() => {
|
const handleSelectAllFiles = useCallback(() => {
|
||||||
// Respect maxAllowed: if limited, select the last N files
|
// Respect maxAllowed: if limited, select the last N files
|
||||||
const allIds = state.files.ids;
|
const allIds = state.files.ids;
|
||||||
const idsToSelect = Number.isFinite(maxAllowed) ? allIds.slice(-maxAllowed) : allIds;
|
const idsToSelect = Number.isFinite(maxAllowed)
|
||||||
|
? allIds.slice(-maxAllowed)
|
||||||
|
: allIds;
|
||||||
setSelectedFiles(idsToSelect);
|
setSelectedFiles(idsToSelect);
|
||||||
try {
|
try {
|
||||||
clearAllFileErrors();
|
clearAllFileErrors();
|
||||||
@@ -147,7 +179,9 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
await addFiles(uploadedFiles, { selectFiles: true });
|
await addFiles(uploadedFiles, { selectFiles: true });
|
||||||
// After auto-selection, enforce maxAllowed if needed
|
// After auto-selection, enforce maxAllowed if needed
|
||||||
if (Number.isFinite(maxAllowed)) {
|
if (Number.isFinite(maxAllowed)) {
|
||||||
const nowSelectedIds = selectors.getSelectedStirlingFileStubs().map((r) => r.id);
|
const nowSelectedIds = selectors
|
||||||
|
.getSelectedStirlingFileStubs()
|
||||||
|
.map((r) => r.id);
|
||||||
if (nowSelectedIds.length > maxAllowed) {
|
if (nowSelectedIds.length > maxAllowed) {
|
||||||
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
|
setSelectedFiles(nowSelectedIds.slice(-maxAllowed));
|
||||||
}
|
}
|
||||||
@@ -155,7 +189,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
showStatus(`Added ${uploadedFiles.length} file(s)`, "success");
|
showStatus(`Added ${uploadedFiles.length} file(s)`, "success");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : "Failed to process files";
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : "Failed to process files";
|
||||||
showError(errorMessage);
|
showError(errorMessage);
|
||||||
console.error("File processing error:", err);
|
console.error("File processing error:", err);
|
||||||
}
|
}
|
||||||
@@ -182,14 +217,18 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
// Add file to selection
|
// Add file to selection
|
||||||
// Determine max files allowed from the active tool (negative or undefined means unlimited)
|
// Determine max files allowed from the active tool (negative or undefined means unlimited)
|
||||||
const rawMax = selectedTool?.maxFiles;
|
const rawMax = selectedTool?.maxFiles;
|
||||||
const maxAllowed = !toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
const maxAllowed =
|
||||||
|
!toolMode || rawMax == null || rawMax < 0 ? Infinity : rawMax;
|
||||||
|
|
||||||
if (maxAllowed === 1) {
|
if (maxAllowed === 1) {
|
||||||
// Only one file allowed -> replace selection with the new file
|
// Only one file allowed -> replace selection with the new file
|
||||||
newSelection = [contextFileId];
|
newSelection = [contextFileId];
|
||||||
} else {
|
} else {
|
||||||
// If at capacity, drop the oldest selected and append the new one
|
// If at capacity, drop the oldest selected and append the new one
|
||||||
if (Number.isFinite(maxAllowed) && currentSelectedIds.length >= maxAllowed) {
|
if (
|
||||||
|
Number.isFinite(maxAllowed) &&
|
||||||
|
currentSelectedIds.length >= maxAllowed
|
||||||
|
) {
|
||||||
newSelection = [...currentSelectedIds.slice(1), contextFileId];
|
newSelection = [...currentSelectedIds.slice(1), contextFileId];
|
||||||
} else {
|
} else {
|
||||||
newSelection = [...currentSelectedIds, contextFileId];
|
newSelection = [...currentSelectedIds, contextFileId];
|
||||||
@@ -200,7 +239,13 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
// Update context (this automatically updates tool selection since they use the same action)
|
// Update context (this automatically updates tool selection since they use the same action)
|
||||||
setSelectedFiles(newSelection);
|
setSelectedFiles(newSelection);
|
||||||
},
|
},
|
||||||
[setSelectedFiles, toolMode, _setStatus, activeStirlingFileStubs, selectedTool?.maxFiles],
|
[
|
||||||
|
setSelectedFiles,
|
||||||
|
toolMode,
|
||||||
|
_setStatus,
|
||||||
|
activeStirlingFileStubs,
|
||||||
|
selectedTool?.maxFiles,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
|
// Enforce maxAllowed when tool changes or when an external action sets too many selected files
|
||||||
@@ -226,13 +271,17 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
|
|
||||||
// Handle multi-file selection reordering
|
// Handle multi-file selection reordering
|
||||||
const filesToMove =
|
const filesToMove =
|
||||||
selectedFileIds.length > 1 ? selectedFileIds.filter((id) => currentIds.includes(id)) : [sourceFileId];
|
selectedFileIds.length > 1
|
||||||
|
? selectedFileIds.filter((id) => currentIds.includes(id))
|
||||||
|
: [sourceFileId];
|
||||||
|
|
||||||
// Create new order
|
// Create new order
|
||||||
const newOrder = [...currentIds];
|
const newOrder = [...currentIds];
|
||||||
|
|
||||||
// Remove files to move from their current positions (in reverse order to maintain indices)
|
// Remove files to move from their current positions (in reverse order to maintain indices)
|
||||||
const sourceIndices = filesToMove.map((id) => newOrder.findIndex((nId) => nId === id)).sort((a, b) => b - a); // Sort descending
|
const sourceIndices = filesToMove
|
||||||
|
.map((id) => newOrder.findIndex((nId) => nId === id))
|
||||||
|
.sort((a, b) => b - a); // Sort descending
|
||||||
|
|
||||||
sourceIndices.forEach((index) => {
|
sourceIndices.forEach((index) => {
|
||||||
newOrder.splice(index, 1);
|
newOrder.splice(index, 1);
|
||||||
@@ -278,11 +327,19 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
removeFiles([contextFileId], false);
|
removeFiles([contextFileId], false);
|
||||||
|
|
||||||
// Remove from context selections
|
// Remove from context selections
|
||||||
const currentSelected = selectedFileIds.filter((id) => id !== contextFileId);
|
const currentSelected = selectedFileIds.filter(
|
||||||
|
(id) => id !== contextFileId,
|
||||||
|
);
|
||||||
setSelectedFiles(currentSelected);
|
setSelectedFiles(currentSelected);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds],
|
[
|
||||||
|
activeStirlingFileStubs,
|
||||||
|
selectors,
|
||||||
|
removeFiles,
|
||||||
|
setSelectedFiles,
|
||||||
|
selectedFileIds,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDownloadFile = useCallback(
|
const handleDownloadFile = useCallback(
|
||||||
@@ -315,7 +372,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
isDirty: false,
|
isDirty: false,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log("[FileEditor] Skipping clean mark:", { savedPath: result.savedPath, isDirty: record.isDirty });
|
console.log("[FileEditor] Skipping clean mark:", {
|
||||||
|
savedPath: result.savedPath,
|
||||||
|
isDirty: record.isDirty,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -329,7 +389,10 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
if (record && file) {
|
if (record && file) {
|
||||||
try {
|
try {
|
||||||
// Extract and store files using shared service method
|
// Extract and store files using shared service method
|
||||||
const result = await zipFileService.extractAndStoreFilesWithHistory(file, record);
|
const result = await zipFileService.extractAndStoreFilesWithHistory(
|
||||||
|
file,
|
||||||
|
record,
|
||||||
|
);
|
||||||
|
|
||||||
if (result.success && result.extractedStubs.length > 0) {
|
if (result.success && result.extractedStubs.length > 0) {
|
||||||
// Add extracted file stubs to FileContext
|
// Add extracted file stubs to FileContext
|
||||||
@@ -376,7 +439,12 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
navActions.setWorkbench("viewer");
|
navActions.setWorkbench("viewer");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeStirlingFileStubs, setActiveFileId, setActiveFileIndex, navActions.setWorkbench],
|
[
|
||||||
|
activeStirlingFileStubs,
|
||||||
|
setActiveFileId,
|
||||||
|
setActiveFileIndex,
|
||||||
|
navActions.setWorkbench,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
const handleLoadFromStorage = useCallback(async (selectedFiles: File[]) => {
|
||||||
@@ -417,7 +485,8 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
</Text>
|
</Text>
|
||||||
<Text c="dimmed">No files loaded</Text>
|
<Text c="dimmed">No files loaded</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Upload PDF files, ZIP archives, or load from storage to get started
|
Upload PDF files, ZIP archives, or load from storage to get
|
||||||
|
started
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
@@ -432,7 +501,12 @@ const FileEditor = ({ toolMode = false, supportedExtensions = ["pdf"] }: FileEdi
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Add File Card - only show when files exist */}
|
{/* Add File Card - only show when files exist */}
|
||||||
{activeStirlingFileStubs.length > 0 && <AddFileCard key="add-file-card" onFileSelect={handleFileUpload} />}
|
{activeStirlingFileStubs.length > 0 && (
|
||||||
|
<AddFileCard
|
||||||
|
key="add-file-card"
|
||||||
|
onFileSelect={handleFileUpload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeStirlingFileStubs.map((record, index) => {
|
{activeStirlingFileStubs.map((record, index) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ interface FileEditorFileNameProps {
|
|||||||
file: StirlingFileStub;
|
file: StirlingFileStub;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => <PrivateContent>{file.name}</PrivateContent>;
|
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
|
||||||
|
<PrivateContent>{file.name}</PrivateContent>
|
||||||
|
);
|
||||||
|
|
||||||
export default FileEditorFileName;
|
export default FileEditorFileName;
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import React, { useState, useCallback, useRef, useMemo } from "react";
|
import React, { useState, useCallback, useRef, useMemo } from "react";
|
||||||
import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack, Loader } from "@mantine/core";
|
import {
|
||||||
|
Text,
|
||||||
|
ActionIcon,
|
||||||
|
CheckboxIndicator,
|
||||||
|
Tooltip,
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Loader,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import { alert } from "@app/components/toast";
|
import { alert } from "@app/components/toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -14,7 +24,10 @@ import PushPinIcon from "@mui/icons-material/PushPin";
|
|||||||
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
||||||
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
import LockOpenIcon from "@mui/icons-material/LockOpen";
|
||||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
import {
|
||||||
|
draggable,
|
||||||
|
dropTargetForElements,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { zipFileService } from "@app/services/zipFileService";
|
import { zipFileService } from "@app/services/zipFileService";
|
||||||
|
|
||||||
@@ -24,7 +37,9 @@ import { useFileState } from "@app/contexts/file/fileHooks";
|
|||||||
import { FileId } from "@app/types/file";
|
import { FileId } from "@app/types/file";
|
||||||
import { formatFileSize } from "@app/utils/fileUtils";
|
import { formatFileSize } from "@app/utils/fileUtils";
|
||||||
import ToolChain from "@app/components/shared/ToolChain";
|
import ToolChain from "@app/components/shared/ToolChain";
|
||||||
import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu";
|
import HoverActionMenu, {
|
||||||
|
HoverAction,
|
||||||
|
} from "@app/components/shared/HoverActionMenu";
|
||||||
import { downloadFile } from "@app/services/downloadService";
|
import { downloadFile } from "@app/services/downloadService";
|
||||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
import UploadToServerModal from "@app/components/shared/UploadToServerModal";
|
||||||
@@ -42,7 +57,11 @@ interface FileEditorThumbnailProps {
|
|||||||
onCloseFile: (fileId: FileId) => void;
|
onCloseFile: (fileId: FileId) => void;
|
||||||
onViewFile: (fileId: FileId) => void;
|
onViewFile: (fileId: FileId) => void;
|
||||||
_onSetStatus: (status: string) => void;
|
_onSetStatus: (status: string) => void;
|
||||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
onReorderFiles?: (
|
||||||
|
sourceFileId: FileId,
|
||||||
|
targetFileId: FileId,
|
||||||
|
selectedFileIds: FileId[],
|
||||||
|
) => void;
|
||||||
onDownloadFile: (fileId: FileId) => void;
|
onDownloadFile: (fileId: FileId) => void;
|
||||||
onUnzipFile?: (fileId: FileId) => void;
|
onUnzipFile?: (fileId: FileId) => void;
|
||||||
toolMode?: boolean;
|
toolMode?: boolean;
|
||||||
@@ -67,7 +86,14 @@ const FileEditorThumbnail = ({
|
|||||||
const terminology = useFileActionTerminology();
|
const terminology = useFileActionTerminology();
|
||||||
const icons = useFileActionIcons();
|
const icons = useFileActionIcons();
|
||||||
const DownloadOutlinedIcon = icons.download;
|
const DownloadOutlinedIcon = icons.download;
|
||||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions, openEncryptedUnlockPrompt } = useFileContext();
|
const {
|
||||||
|
pinFile,
|
||||||
|
unpinFile,
|
||||||
|
isFilePinned,
|
||||||
|
activeFiles,
|
||||||
|
actions: fileActions,
|
||||||
|
openEncryptedUnlockPrompt,
|
||||||
|
} = useFileContext();
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||||
|
|
||||||
@@ -117,18 +143,29 @@ const FileEditorThumbnail = ({
|
|||||||
const isCBZ = extLower === "cbz";
|
const isCBZ = extLower === "cbz";
|
||||||
const isCBR = extLower === "cbr";
|
const isCBR = extLower === "cbr";
|
||||||
const uploadEnabled = config?.storageEnabled === true;
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
const sharingEnabled =
|
||||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
|
const shareLinksEnabled =
|
||||||
|
sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||||
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
||||||
const isSharedFile = file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink;
|
const isSharedFile =
|
||||||
|
file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink;
|
||||||
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
||||||
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
||||||
const isUploaded = Boolean(file.remoteStorageId);
|
const isUploaded = Boolean(file.remoteStorageId);
|
||||||
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||||
const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate);
|
const canUpload =
|
||||||
|
uploadEnabled &&
|
||||||
|
isOwnedOrLocal &&
|
||||||
|
file.isLeaf &&
|
||||||
|
(!isUploaded || !isUpToDate);
|
||||||
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
|
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
|
||||||
|
|
||||||
const pageLabel = useMemo(() => (pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : ""), [pageCount]);
|
const pageLabel = useMemo(
|
||||||
|
() =>
|
||||||
|
pageCount > 0 ? `${pageCount} ${pageCount === 1 ? "Page" : "Pages"}` : "",
|
||||||
|
[pageCount],
|
||||||
|
);
|
||||||
|
|
||||||
const dateLabel = useMemo(() => {
|
const dateLabel = useMemo(() => {
|
||||||
const d = new Date(file.lastModified);
|
const d = new Date(file.lastModified);
|
||||||
@@ -198,7 +235,12 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
const handleConfirmClose = useCallback(() => {
|
const handleConfirmClose = useCallback(() => {
|
||||||
onCloseFile(file.id);
|
onCloseFile(file.id);
|
||||||
alert({ alertType: "neutral", title: `Closed ${file.name}`, expandable: false, durationMs: 3500 });
|
alert({
|
||||||
|
alertType: "neutral",
|
||||||
|
title: `Closed ${file.name}`,
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 3500,
|
||||||
|
});
|
||||||
setShowCloseModal(false);
|
setShowCloseModal(false);
|
||||||
}, [file.id, file.name, onCloseFile]);
|
}, [file.id, file.name, onCloseFile]);
|
||||||
|
|
||||||
@@ -222,16 +264,33 @@ const FileEditorThumbnail = ({
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to save ${file.name}:`, error);
|
console.error(`Failed to save ${file.name}:`, error);
|
||||||
alert({ alertType: "error", title: "Save failed", body: `Could not save ${file.name}`, expandable: true });
|
alert({
|
||||||
|
alertType: "error",
|
||||||
|
title: "Save failed",
|
||||||
|
body: `Could not save ${file.name}`,
|
||||||
|
expandable: true,
|
||||||
|
});
|
||||||
setShowCloseModal(false);
|
setShowCloseModal(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Then close
|
// Then close
|
||||||
onCloseFile(file.id);
|
onCloseFile(file.id);
|
||||||
alert({ alertType: "success", title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
|
alert({
|
||||||
|
alertType: "success",
|
||||||
|
title: `Saved and closed ${file.name}`,
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 3500,
|
||||||
|
});
|
||||||
setShowCloseModal(false);
|
setShowCloseModal(false);
|
||||||
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
|
}, [
|
||||||
|
file.id,
|
||||||
|
file.name,
|
||||||
|
file.localFilePath,
|
||||||
|
onCloseFile,
|
||||||
|
selectors,
|
||||||
|
fileActions,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleCancelClose = useCallback(() => {
|
const handleCancelClose = useCallback(() => {
|
||||||
setShowCloseModal(false);
|
setShowCloseModal(false);
|
||||||
@@ -298,7 +357,12 @@ const FileEditorThumbnail = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (onUnzipFile) {
|
if (onUnzipFile) {
|
||||||
onUnzipFile(file.id);
|
onUnzipFile(file.id);
|
||||||
alert({ alertType: "success", title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 });
|
alert({
|
||||||
|
alertType: "success",
|
||||||
|
title: `Unzipping ${file.name}`,
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 2500,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
|
hidden: !isZipFile || !onUnzipFile || isCBZ || isCBR,
|
||||||
@@ -381,7 +445,10 @@ const FileEditorThumbnail = ({
|
|||||||
onDoubleClick={handleCardDoubleClick}
|
onDoubleClick={handleCardDoubleClick}
|
||||||
>
|
>
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div className={`${styles.header} ${getHeaderClassName()}`} data-has-error={hasError}>
|
<div
|
||||||
|
className={`${styles.header} ${getHeaderClassName()}`}
|
||||||
|
data-has-error={hasError}
|
||||||
|
>
|
||||||
{/* Logo/checkbox area */}
|
{/* Logo/checkbox area */}
|
||||||
<div className={styles.logoMark}>
|
<div className={styles.logoMark}>
|
||||||
{hasError ? (
|
{hasError ? (
|
||||||
@@ -402,16 +469,27 @@ const FileEditorThumbnail = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Centered index */}
|
{/* Centered index */}
|
||||||
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
<div
|
||||||
|
className={styles.headerIndex}
|
||||||
|
aria-label={`Position ${index + 1}`}
|
||||||
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons group */}
|
{/* Action buttons group */}
|
||||||
<div className={styles.headerActions}>
|
<div className={styles.headerActions}>
|
||||||
{isEncrypted && (
|
{isEncrypted && (
|
||||||
<Tooltip label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}>
|
<Tooltip
|
||||||
|
label={t(
|
||||||
|
"encryptedPdfUnlock.unlockPrompt",
|
||||||
|
"Unlock PDF to continue",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
aria-label={t("encryptedPdfUnlock.unlockPrompt", "Unlock PDF to continue")}
|
aria-label={t(
|
||||||
|
"encryptedPdfUnlock.unlockPrompt",
|
||||||
|
"Unlock PDF to continue",
|
||||||
|
)}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
className={styles.headerIconButton}
|
className={styles.headerIconButton}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -426,7 +504,9 @@ const FileEditorThumbnail = ({
|
|||||||
{/* Pin/Unpin icon */}
|
{/* Pin/Unpin icon */}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={
|
label={
|
||||||
isPinned ? t("unpin", "Unpin File (replace after tool run)") : t("pin", "Pin File (keep active after tool run)")
|
isPinned
|
||||||
|
? t("unpin", "Unpin File (replace after tool run)")
|
||||||
|
: t("pin", "Pin File (keep active after tool run)")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
@@ -443,15 +523,29 @@ const FileEditorThumbnail = ({
|
|||||||
if (actualFile) {
|
if (actualFile) {
|
||||||
if (isPinned) {
|
if (isPinned) {
|
||||||
unpinFile(actualFile);
|
unpinFile(actualFile);
|
||||||
alert({ alertType: "neutral", title: `Unpinned ${file.name}`, expandable: false, durationMs: 3000 });
|
alert({
|
||||||
|
alertType: "neutral",
|
||||||
|
title: `Unpinned ${file.name}`,
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 3000,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
pinFile(actualFile);
|
pinFile(actualFile);
|
||||||
alert({ alertType: "success", title: `Pinned ${file.name}`, expandable: false, durationMs: 3000 });
|
alert({
|
||||||
|
alertType: "success",
|
||||||
|
title: `Pinned ${file.name}`,
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 3000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
{isPinned ? (
|
||||||
|
<PushPinIcon fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<PushPinOutlinedIcon fontSize="small" />
|
||||||
|
)}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,7 +564,13 @@ const FileEditorThumbnail = ({
|
|||||||
<Text size="lg" fw={700} className={styles.title} title={file.name}>
|
<Text size="lg" fw={700} className={styles.title} title={file.name}>
|
||||||
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
<PrivateContent>{truncateCenter(file.name, 40)}</PrivateContent>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" c="dimmed" className={styles.meta} lineClamp={3} title={`${extUpper || "FILE"} • ${prettySize}`}>
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="dimmed"
|
||||||
|
className={styles.meta}
|
||||||
|
lineClamp={3}
|
||||||
|
title={`${extUpper || "FILE"} • ${prettySize}`}
|
||||||
|
>
|
||||||
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
||||||
{`v${file.versionNumber} - `}
|
{`v${file.versionNumber} - `}
|
||||||
{dateLabel}
|
{dateLabel}
|
||||||
@@ -482,7 +582,11 @@ const FileEditorThumbnail = ({
|
|||||||
{/* Preview area */}
|
{/* Preview area */}
|
||||||
<div
|
<div
|
||||||
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}
|
||||||
style={isSupported || hasError ? undefined : { filter: "grayscale(80%)", opacity: 0.6 }}
|
style={
|
||||||
|
isSupported || hasError
|
||||||
|
? undefined
|
||||||
|
: { filter: "grayscale(80%)", opacity: 0.6 }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className={styles.previewPaper}>
|
<div className={styles.previewPaper}>
|
||||||
{file.thumbnailUrl ? (
|
{file.thumbnailUrl ? (
|
||||||
@@ -513,7 +617,12 @@ const FileEditorThumbnail = ({
|
|||||||
/>
|
/>
|
||||||
</PrivateContent>
|
</PrivateContent>
|
||||||
) : file.type?.startsWith("application/pdf") ? (
|
) : file.type?.startsWith("application/pdf") ? (
|
||||||
<Stack align="center" justify="center" gap="xs" style={{ height: "100%" }}>
|
<Stack
|
||||||
|
align="center"
|
||||||
|
justify="center"
|
||||||
|
gap="xs"
|
||||||
|
style={{ height: "100%" }}
|
||||||
|
>
|
||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
Loading thumbnail...
|
Loading thumbnail...
|
||||||
@@ -554,7 +663,11 @@ const FileEditorThumbnail = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover Menu */}
|
{/* Hover Menu */}
|
||||||
<HoverActionMenu show={showHoverMenu || isMobile} actions={hoverActions} position="outside" />
|
<HoverActionMenu
|
||||||
|
show={showHoverMenu || isMobile}
|
||||||
|
actions={hoverActions}
|
||||||
|
position="outside"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Close Confirmation Modal */}
|
{/* Close Confirmation Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -567,7 +680,9 @@ const FileEditorThumbnail = ({
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{file.isDirty && file.localFilePath ? (
|
{file.isDirty && file.localFilePath ? (
|
||||||
<>
|
<>
|
||||||
<Text size="md">{t("confirmCloseUnsaved", "This file has unsaved changes.")}</Text>
|
<Text size="md">
|
||||||
|
{t("confirmCloseUnsaved", "This file has unsaved changes.")}
|
||||||
|
</Text>
|
||||||
<Text size="sm" c="dimmed" fw={500}>
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
{file.name}
|
{file.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -575,7 +690,11 @@ const FileEditorThumbnail = ({
|
|||||||
<Button variant="light" onClick={handleCancelClose}>
|
<Button variant="light" onClick={handleCancelClose}>
|
||||||
{t("confirmCloseCancel", "Cancel")}
|
{t("confirmCloseCancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
onClick={handleConfirmClose}
|
||||||
|
>
|
||||||
{t("confirmCloseDiscard", "Discard changes and close")}
|
{t("confirmCloseDiscard", "Discard changes and close")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="filled" onClick={handleSaveAndClose}>
|
<Button variant="filled" onClick={handleSaveAndClose}>
|
||||||
@@ -585,7 +704,12 @@ const FileEditorThumbnail = ({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text size="md">{t("confirmCloseMessage", "Are you sure you want to close this file?")}</Text>
|
<Text size="md">
|
||||||
|
{t(
|
||||||
|
"confirmCloseMessage",
|
||||||
|
"Are you sure you want to close this file?",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
<Text size="sm" c="dimmed" fw={500}>
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
{file.name}
|
{file.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -593,7 +717,11 @@ const FileEditorThumbnail = ({
|
|||||||
<Button variant="light" onClick={handleCancelClose}>
|
<Button variant="light" onClick={handleCancelClose}>
|
||||||
{t("confirmCloseCancel", "Cancel")}
|
{t("confirmCloseCancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
<Button
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
onClick={handleConfirmClose}
|
||||||
|
>
|
||||||
{t("confirmCloseConfirm", "Close File")}
|
{t("confirmCloseConfirm", "Close File")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -623,8 +751,20 @@ const FileEditorThumbnail = ({
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{canUpload && <UploadToServerModal opened={showUploadModal} onClose={() => setShowUploadModal(false)} file={file} />}
|
{canUpload && (
|
||||||
{canShare && <ShareFileModal opened={showShareModal} onClose={() => setShowShareModal(false)} file={file} />}
|
<UploadToServerModal
|
||||||
|
opened={showUploadModal}
|
||||||
|
onClose={() => setShowUploadModal(false)}
|
||||||
|
file={file}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ShareFileModal
|
||||||
|
opened={showShareModal}
|
||||||
|
onClose={() => setShowShareModal(false)}
|
||||||
|
file={file}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons";
|
import {
|
||||||
|
useRightRailButtons,
|
||||||
|
RightRailButtonWithAction,
|
||||||
|
} from "@app/hooks/useRightRailButtons";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
|
|
||||||
interface FileEditorRightRailButtonsParams {
|
interface FileEditorRightRailButtonsParams {
|
||||||
@@ -26,7 +29,10 @@ export function useFileEditorRightRailButtons({
|
|||||||
id: "file-select-all",
|
id: "file-select-all",
|
||||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||||
tooltip: t("rightRail.selectAll", "Select All"),
|
tooltip: t("rightRail.selectAll", "Select All"),
|
||||||
ariaLabel: typeof t === "function" ? t("rightRail.selectAll", "Select All") : "Select All",
|
ariaLabel:
|
||||||
|
typeof t === "function"
|
||||||
|
? t("rightRail.selectAll", "Select All")
|
||||||
|
: "Select All",
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
order: 10,
|
order: 10,
|
||||||
disabled: totalItems === 0 || selectedCount === totalItems,
|
disabled: totalItems === 0 || selectedCount === totalItems,
|
||||||
@@ -35,9 +41,18 @@ export function useFileEditorRightRailButtons({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "file-deselect-all",
|
id: "file-deselect-all",
|
||||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
icon: (
|
||||||
|
<LocalIcon
|
||||||
|
icon="crop-square-outline"
|
||||||
|
width="1.5rem"
|
||||||
|
height="1.5rem"
|
||||||
|
/>
|
||||||
|
),
|
||||||
tooltip: t("rightRail.deselectAll", "Deselect All"),
|
tooltip: t("rightRail.deselectAll", "Deselect All"),
|
||||||
ariaLabel: typeof t === "function" ? t("rightRail.deselectAll", "Deselect All") : "Deselect All",
|
ariaLabel:
|
||||||
|
typeof t === "function"
|
||||||
|
? t("rightRail.deselectAll", "Deselect All")
|
||||||
|
: "Deselect All",
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
order: 20,
|
order: 20,
|
||||||
disabled: selectedCount === 0,
|
disabled: selectedCount === 0,
|
||||||
@@ -48,7 +63,10 @@ export function useFileEditorRightRailButtons({
|
|||||||
id: "file-close-selected",
|
id: "file-close-selected",
|
||||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
|
tooltip: t("rightRail.closeSelected", "Close Selected Files"),
|
||||||
ariaLabel: typeof t === "function" ? t("rightRail.closeSelected", "Close Selected Files") : "Close Selected Files",
|
ariaLabel:
|
||||||
|
typeof t === "function"
|
||||||
|
? t("rightRail.closeSelected", "Close Selected Files")
|
||||||
|
: "Close Selected Files",
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
order: 30,
|
order: 30,
|
||||||
disabled: selectedCount === 0,
|
disabled: selectedCount === 0,
|
||||||
@@ -56,7 +74,15 @@ export function useFileEditorRightRailButtons({
|
|||||||
onClick: onCloseSelected,
|
onClick: onCloseSelected,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[t, i18n.language, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected],
|
[
|
||||||
|
t,
|
||||||
|
i18n.language,
|
||||||
|
totalItems,
|
||||||
|
selectedCount,
|
||||||
|
onSelectAll,
|
||||||
|
onDeselectAll,
|
||||||
|
onCloseSelected,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
useRightRailButtons(buttons);
|
useRightRailButtons(buttons);
|
||||||
|
|||||||
@@ -35,9 +35,14 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
const hasSelection = selectedFiles.length > 0;
|
const hasSelection = selectedFiles.length > 0;
|
||||||
const hasMultipleFiles = numberOfFiles > 1;
|
const hasMultipleFiles = numberOfFiles > 1;
|
||||||
const showOwner = Boolean(
|
const showOwner = Boolean(
|
||||||
currentFile && (currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink),
|
currentFile &&
|
||||||
|
(currentFile.remoteOwnedByCurrentUser === false ||
|
||||||
|
currentFile.remoteSharedViaLink),
|
||||||
);
|
);
|
||||||
const ownerLabel = currentFile ? currentFile.remoteOwnerUsername || t("fileManager.ownerUnknown", "Unknown") : "";
|
const ownerLabel = currentFile
|
||||||
|
? currentFile.remoteOwnerUsername ||
|
||||||
|
t("fileManager.ownerUnknown", "Unknown")
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs" style={{ height: "100%" }}>
|
<Stack gap="xs" style={{ height: "100%" }}>
|
||||||
@@ -78,7 +83,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PictureAsPdfIcon style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }} />
|
<PictureAsPdfIcon
|
||||||
|
style={{ fontSize: 20, color: "var(--mantine-color-gray-6)" }}
|
||||||
|
/>
|
||||||
</Center>
|
</Center>
|
||||||
) : null}
|
) : null}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -86,7 +93,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
{/* File info */}
|
{/* File info */}
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||||
<Text size="sm" fw={500} truncate>
|
<Text size="sm" fw={500} truncate>
|
||||||
<PrivateContent>{currentFile ? currentFile.name : "No file selected"}</PrivateContent>
|
<PrivateContent>
|
||||||
|
{currentFile ? currentFile.name : "No file selected"}
|
||||||
|
</PrivateContent>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{currentFile ? getFileSize(currentFile) : ""}
|
{currentFile ? getFileSize(currentFile) : ""}
|
||||||
@@ -101,7 +110,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
{/* Compact tool chain for mobile */}
|
{/* Compact tool chain for mobile */}
|
||||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(" → ")}
|
{currentFile.toolHistory
|
||||||
|
.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId))
|
||||||
|
.join(" → ")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{currentFile && showOwner && (
|
{currentFile && showOwner && (
|
||||||
@@ -114,10 +125,20 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
{/* Navigation arrows for multiple files */}
|
{/* Navigation arrows for multiple files */}
|
||||||
{hasMultipleFiles && (
|
{hasMultipleFiles && (
|
||||||
<Box style={{ display: "flex", gap: "0.25rem" }}>
|
<Box style={{ display: "flex", gap: "0.25rem" }}>
|
||||||
<ActionIcon variant="subtle" size="sm" onClick={onPrevious} disabled={isAnimating}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onPrevious}
|
||||||
|
disabled={isAnimating}
|
||||||
|
>
|
||||||
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
<ChevronLeftIcon style={{ fontSize: 16 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<ActionIcon variant="subtle" size="sm" onClick={onNext} disabled={isAnimating}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={isAnimating}
|
||||||
|
>
|
||||||
<ChevronRightIcon style={{ fontSize: 16 }} />
|
<ChevronRightIcon style={{ fontSize: 16 }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -131,7 +152,9 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
|
backgroundColor: hasSelection
|
||||||
|
? "var(--btn-open-file)"
|
||||||
|
: "var(--mantine-color-gray-4)",
|
||||||
color: "white",
|
color: "white",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ const DesktopLayout: React.FC = () => {
|
|||||||
const { activeSource, recentFiles, modalHeight } = useFileManagerContext();
|
const { activeSource, recentFiles, modalHeight } = useFileManagerContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: "nowrap", minWidth: 0 }}>
|
<Grid
|
||||||
|
gutter="xs"
|
||||||
|
h="100%"
|
||||||
|
grow={false}
|
||||||
|
style={{ flexWrap: "nowrap", minWidth: 0 }}
|
||||||
|
>
|
||||||
{/* Column 1: File Sources */}
|
{/* Column 1: File Sources */}
|
||||||
<Grid.Col
|
<Grid.Col
|
||||||
span="content"
|
span="content"
|
||||||
@@ -74,9 +79,16 @@ const DesktopLayout: React.FC = () => {
|
|||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||||
<FileListArea
|
<FileListArea
|
||||||
scrollAreaHeight={activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%"}
|
scrollAreaHeight={
|
||||||
|
activeSource === "recent" && recentFiles.length > 0
|
||||||
|
? `calc(${modalHeight} - 7rem)`
|
||||||
|
: "100%"
|
||||||
|
}
|
||||||
scrollAreaStyle={{
|
scrollAreaStyle={{
|
||||||
height: activeSource === "recent" && recentFiles.length > 0 ? `calc(${modalHeight} - 7rem)` : "100%",
|
height:
|
||||||
|
activeSource === "recent" && recentFiles.length > 0
|
||||||
|
? `calc(${modalHeight} - 7rem)`
|
||||||
|
: "100%",
|
||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ const DragOverlay: React.FC<DragOverlayProps> = ({ isVisible }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack align="center" gap="md">
|
<Stack align="center" gap="md">
|
||||||
<UploadFileIcon style={{ fontSize: "4rem", color: theme.colors.blue[6] }} />
|
<UploadFileIcon
|
||||||
|
style={{ fontSize: "4rem", color: theme.colors.blue[6] }}
|
||||||
|
/>
|
||||||
<Text size="xl" fw={500} c="blue.6">
|
<Text size="xl" fw={500} c="blue.6">
|
||||||
{t("fileManager.dropFilesHere", "Drop files here to upload")}
|
{t("fileManager.dropFilesHere", "Drop files here to upload")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button, Group, Text, Stack, useMantineColorScheme } from "@mantine/core";
|
import {
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Stack,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
import HistoryIcon from "@mui/icons-material/History";
|
import HistoryIcon from "@mui/icons-material/History";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
@@ -49,7 +55,9 @@ const EmptyFilesState: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{/* No Recent Files Message */}
|
{/* No Recent Files Message */}
|
||||||
<Stack align="center" gap="sm">
|
<Stack align="center" gap="sm">
|
||||||
<HistoryIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
|
<HistoryIcon
|
||||||
|
style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }}
|
||||||
|
/>
|
||||||
<Text c="dimmed" ta="center" size="lg">
|
<Text c="dimmed" ta="center" size="lg">
|
||||||
{t("fileManager.noRecentFiles", "No recent files")}
|
{t("fileManager.noRecentFiles", "No recent files")}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -91,7 +99,8 @@ const EmptyFilesState: React.FC = () => {
|
|||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
transition: "width .5s ease, padding .5s ease, border-radius .5s ease",
|
transition:
|
||||||
|
"width .5s ease, padding .5s ease, border-radius .5s ease",
|
||||||
}}
|
}}
|
||||||
onClick={handleUploadClick}
|
onClick={handleUploadClick}
|
||||||
onMouseEnter={() => setIsUploadHover(true)}
|
onMouseEnter={() => setIsUploadHover(true)}
|
||||||
@@ -102,12 +111,19 @@ const EmptyFilesState: React.FC = () => {
|
|||||||
height="1.25rem"
|
height="1.25rem"
|
||||||
style={{ color: "var(--accent-interactive)" }}
|
style={{ color: "var(--accent-interactive)" }}
|
||||||
/>
|
/>
|
||||||
{isUploadHover && <span style={{ marginLeft: ".5rem" }}>{terminology.uploadFromComputer}</span>}
|
{isUploadHover && (
|
||||||
|
<span style={{ marginLeft: ".5rem" }}>
|
||||||
|
{terminology.uploadFromComputer}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Instruction Text */}
|
{/* Instruction Text */}
|
||||||
<span className="text-[var(--accent-interactive)]" style={{ fontSize: ".8rem", textAlign: "center" }}>
|
<span
|
||||||
|
className="text-[var(--accent-interactive)]"
|
||||||
|
style={{ fontSize: ".8rem", textAlign: "center" }}
|
||||||
|
>
|
||||||
{terminology.dropFilesHere}
|
{terminology.dropFilesHere}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import React, { useEffect } from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Group, Text, ActionIcon, Tooltip, SegmentedControl } from "@mantine/core";
|
import {
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
SegmentedControl,
|
||||||
|
} from "@mantine/core";
|
||||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
@@ -33,33 +39,51 @@ const FileActions: React.FC = () => {
|
|||||||
onStorageFilterChange,
|
onStorageFilterChange,
|
||||||
} = useFileManagerContext();
|
} = useFileManagerContext();
|
||||||
const uploadEnabled = config?.storageEnabled === true;
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
const sharingEnabled =
|
||||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
|
const shareLinksEnabled =
|
||||||
|
sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||||
const showStorageFilter = uploadEnabled;
|
const showStorageFilter = uploadEnabled;
|
||||||
const storageFilterOptions = sharingEnabled
|
const storageFilterOptions = sharingEnabled
|
||||||
? [
|
? [
|
||||||
{ value: "all", label: t("fileManager.filterAll", "All") },
|
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||||
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
||||||
{ value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") },
|
{
|
||||||
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") },
|
value: "sharedWithMe",
|
||||||
|
label: t("fileManager.filterSharedWithMe", "Shared with me"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "sharedByMe",
|
||||||
|
label: t("fileManager.filterSharedByMe", "Shared by me"),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
{ value: "all", label: t("fileManager.filterAll", "All") },
|
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||||
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
||||||
];
|
];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) {
|
if (
|
||||||
|
!sharingEnabled &&
|
||||||
|
(storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")
|
||||||
|
) {
|
||||||
onStorageFilterChange("all");
|
onStorageFilterChange("all");
|
||||||
}
|
}
|
||||||
}, [sharingEnabled, storageFilter, onStorageFilterChange]);
|
}, [sharingEnabled, storageFilter, onStorageFilterChange]);
|
||||||
const hasSelection = selectedFileIds.length > 0;
|
const hasSelection = selectedFileIds.length > 0;
|
||||||
const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false);
|
const hasOnlyOwnedSelection = selectedFiles.every(
|
||||||
|
(file) => file.remoteOwnedByCurrentUser !== false,
|
||||||
|
);
|
||||||
const hasDownloadAccess = selectedFiles.every((file) => {
|
const hasDownloadAccess = selectedFiles.every((file) => {
|
||||||
const role = (file.remoteOwnedByCurrentUser !== false ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase();
|
const role = (
|
||||||
|
file.remoteOwnedByCurrentUser !== false
|
||||||
|
? "editor"
|
||||||
|
: (file.remoteAccessRole ?? "viewer")
|
||||||
|
).toLowerCase();
|
||||||
return role === "editor" || role === "commenter" || role === "viewer";
|
return role === "editor" || role === "commenter" || role === "viewer";
|
||||||
});
|
});
|
||||||
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
|
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||||
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
|
const canBulkShare =
|
||||||
|
shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
onSelectAll();
|
onSelectAll();
|
||||||
@@ -82,7 +106,8 @@ const FileActions: React.FC = () => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
const allFilesSelected =
|
||||||
|
filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -99,7 +124,11 @@ const FileActions: React.FC = () => {
|
|||||||
{/* Left: Select All + Filter */}
|
{/* Left: Select All + Filter */}
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
|
label={
|
||||||
|
allFilesSelected
|
||||||
|
? t("fileManager.deselectAll", "Deselect All")
|
||||||
|
: t("fileManager.selectAll", "Select All")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -116,7 +145,11 @@ const FileActions: React.FC = () => {
|
|||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
size="xs"
|
size="xs"
|
||||||
value={storageFilter}
|
value={storageFilter}
|
||||||
onChange={(value) => onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")}
|
onChange={(value) =>
|
||||||
|
onStorageFilterChange(
|
||||||
|
value as "all" | "local" | "sharedWithMe" | "sharedByMe",
|
||||||
|
)
|
||||||
|
}
|
||||||
data={storageFilterOptions}
|
data={storageFilterOptions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -132,7 +165,9 @@ const FileActions: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{hasSelection && (
|
{hasSelection && (
|
||||||
<Text size="sm" c="dimmed" fw={500}>
|
<Text size="sm" c="dimmed" fw={500}>
|
||||||
{t("fileManager.selectedCount", "{{count}} selected", { count: selectedFileIds.length })}
|
{t("fileManager.selectedCount", "{{count}} selected", {
|
||||||
|
count: selectedFileIds.length,
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
|||||||
const [isAnimating, setIsAnimating] = useState(false);
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
// Get the currently displayed file
|
// Get the currently displayed file
|
||||||
const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
const currentFile =
|
||||||
|
selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
|
||||||
const hasSelection = selectedFiles.length > 0;
|
const hasSelection = selectedFiles.length > 0;
|
||||||
|
|
||||||
// Use IndexedDB hook for the current file
|
// Use IndexedDB hook for the current file
|
||||||
@@ -33,7 +34,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
|||||||
if (isAnimating) return;
|
if (isAnimating) return;
|
||||||
setIsAnimating(true);
|
setIsAnimating(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCurrentFileIndex((prev) => (prev > 0 ? prev - 1 : selectedFiles.length - 1));
|
setCurrentFileIndex((prev) =>
|
||||||
|
prev > 0 ? prev - 1 : selectedFiles.length - 1,
|
||||||
|
);
|
||||||
setIsAnimating(false);
|
setIsAnimating(false);
|
||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
@@ -42,7 +45,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
|||||||
if (isAnimating) return;
|
if (isAnimating) return;
|
||||||
setIsAnimating(true);
|
setIsAnimating(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCurrentFileIndex((prev) => (prev < selectedFiles.length - 1 ? prev + 1 : 0));
|
setCurrentFileIndex((prev) =>
|
||||||
|
prev < selectedFiles.length - 1 ? prev + 1 : 0,
|
||||||
|
);
|
||||||
setIsAnimating(false);
|
setIsAnimating(false);
|
||||||
}, 150);
|
}, 150);
|
||||||
};
|
};
|
||||||
@@ -73,7 +78,14 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
|
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`} justify="flex-start">
|
||||||
{/* Section 1: Thumbnail Preview */}
|
{/* Section 1: Thumbnail Preview */}
|
||||||
<Box style={{ width: "100%", height: "min(35vh, 280px)", textAlign: "center", flexShrink: 0 }}>
|
<Box
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
height: "min(35vh, 280px)",
|
||||||
|
textAlign: "center",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<FilePreview
|
<FilePreview
|
||||||
file={currentFile}
|
file={currentFile}
|
||||||
thumbnail={getCurrentThumbnail()}
|
thumbnail={getCurrentThumbnail()}
|
||||||
@@ -95,7 +107,9 @@ const FileDetails: React.FC<FileDetailsProps> = ({ compact = false }) => {
|
|||||||
disabled={!hasSelection}
|
disabled={!hasSelection}
|
||||||
fullWidth
|
fullWidth
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: hasSelection ? "var(--btn-open-file)" : "var(--mantine-color-gray-4)",
|
backgroundColor: hasSelection
|
||||||
|
? "var(--btn-open-file)"
|
||||||
|
: "var(--mantine-color-gray-4)",
|
||||||
color: "white",
|
color: "white",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
|||||||
<Box ml="md" mt="xs" mb="sm">
|
<Box ml="md" mt="xs" mb="sm">
|
||||||
<Group align="center" mb="sm">
|
<Group align="center" mb="sm">
|
||||||
<Text size="xs" fw={600} c="dimmed">
|
<Text size="xs" fw={600} c="dimmed">
|
||||||
{t("fileManager.fileHistory", "File History")} ({sortedHistory.length})
|
{t("fileManager.fileHistory", "File History")} (
|
||||||
|
{sortedHistory.length})
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from "@mantine/core";
|
import {
|
||||||
|
Stack,
|
||||||
|
Card,
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
Badge,
|
||||||
|
Group,
|
||||||
|
Divider,
|
||||||
|
ScrollArea,
|
||||||
|
Button,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { detectFileExtension, getFileSize } from "@app/utils/fileUtils";
|
import { detectFileExtension, getFileSize } from "@app/utils/fileUtils";
|
||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
@@ -14,31 +24,43 @@ interface FileInfoCardProps {
|
|||||||
modalHeight: string;
|
modalHeight: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight }) => {
|
const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||||
|
currentFile,
|
||||||
|
modalHeight,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const { onMakeCopy } = useFileManagerContext();
|
const { onMakeCopy } = useFileManagerContext();
|
||||||
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
||||||
const isSharedWithYou = useMemo(() => {
|
const isSharedWithYou = useMemo(() => {
|
||||||
if (!currentFile) return false;
|
if (!currentFile) return false;
|
||||||
return currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink;
|
return (
|
||||||
|
currentFile.remoteOwnedByCurrentUser === false ||
|
||||||
|
currentFile.remoteSharedViaLink
|
||||||
|
);
|
||||||
}, [currentFile]);
|
}, [currentFile]);
|
||||||
const isOwnedRemote = useMemo(() => {
|
const isOwnedRemote = useMemo(() => {
|
||||||
if (!currentFile) return false;
|
if (!currentFile) return false;
|
||||||
return Boolean(currentFile.remoteStorageId) && currentFile.remoteOwnedByCurrentUser !== false;
|
return (
|
||||||
|
Boolean(currentFile.remoteStorageId) &&
|
||||||
|
currentFile.remoteOwnedByCurrentUser !== false
|
||||||
|
);
|
||||||
}, [currentFile]);
|
}, [currentFile]);
|
||||||
const localUpdatedAt = currentFile?.createdAt ?? currentFile?.lastModified ?? 0;
|
const localUpdatedAt =
|
||||||
|
currentFile?.createdAt ?? currentFile?.lastModified ?? 0;
|
||||||
const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0;
|
const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0;
|
||||||
const isUploaded = Boolean(currentFile?.remoteStorageId);
|
const isUploaded = Boolean(currentFile?.remoteStorageId);
|
||||||
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||||
const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote;
|
const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote;
|
||||||
const isLocalOnly = !currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink;
|
const isLocalOnly =
|
||||||
|
!currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink;
|
||||||
const isSharedByYou = useMemo(() => {
|
const isSharedByYou = useMemo(() => {
|
||||||
if (!currentFile) return false;
|
if (!currentFile) return false;
|
||||||
return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks);
|
return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks);
|
||||||
}, [currentFile, isOwnedRemote]);
|
}, [currentFile, isOwnedRemote]);
|
||||||
const uploadEnabled = config?.storageEnabled === true;
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
const sharingEnabled =
|
||||||
|
uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
const ownerLabel = useMemo(() => {
|
const ownerLabel = useMemo(() => {
|
||||||
if (!currentFile) return "";
|
if (!currentFile) return "";
|
||||||
if (currentFile.remoteOwnerUsername) {
|
if (currentFile.remoteOwnerUsername) {
|
||||||
@@ -56,7 +78,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
|||||||
withBorder
|
withBorder
|
||||||
p={0}
|
p={0}
|
||||||
mah={`calc(${modalHeight} * 0.45)`}
|
mah={`calc(${modalHeight} * 0.45)`}
|
||||||
style={{ overflow: "hidden", flexShrink: 1, display: "flex", flexDirection: "column" }}
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
flexShrink: 1,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
bg="gray.4"
|
bg="gray.4"
|
||||||
@@ -75,9 +102,16 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
|||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
<PrivateContent>{t("fileManager.fileName", "Name")}</PrivateContent>
|
<PrivateContent>
|
||||||
|
{t("fileManager.fileName", "Name")}
|
||||||
|
</PrivateContent>
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500} style={{ maxWidth: "60%", textAlign: "right" }} truncate>
|
<Text
|
||||||
|
size="sm"
|
||||||
|
fw={500}
|
||||||
|
style={{ maxWidth: "60%", textAlign: "right" }}
|
||||||
|
truncate
|
||||||
|
>
|
||||||
{currentFile ? currentFile.name : ""}
|
{currentFile ? currentFile.name : ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -112,7 +146,9 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
|||||||
{t("fileManager.lastModified", "Last modified")}
|
{t("fileManager.lastModified", "Last modified")}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ""}
|
{currentFile
|
||||||
|
? new Date(currentFile.lastModified).toLocaleDateString()
|
||||||
|
: ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -122,7 +158,11 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
|||||||
{t("fileManager.fileVersion", "Version")}
|
{t("fileManager.fileVersion", "Version")}
|
||||||
</Text>
|
</Text>
|
||||||
{currentFile && (
|
{currentFile && (
|
||||||
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? "blue" : "gray"}>
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color={currentFile?.versionNumber ? "blue" : "gray"}
|
||||||
|
>
|
||||||
v{currentFile ? currentFile.versionNumber || 1 : ""}
|
v{currentFile ? currentFile.versionNumber || 1 : ""}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -154,7 +194,11 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
|||||||
<Text size="xs" c="dimmed" mb="xs">
|
<Text size="xs" c="dimmed" mb="xs">
|
||||||
{t("fileManager.toolChain", "Tools Applied")}
|
{t("fileManager.toolChain", "Tools Applied")}
|
||||||
</Text>
|
</Text>
|
||||||
<ToolChain toolChain={currentFile.toolHistory} displayStyle="badges" size="xs" />
|
<ToolChain
|
||||||
|
toolChain={currentFile.toolHistory}
|
||||||
|
displayStyle="badges"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -162,7 +206,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
|||||||
{currentFile && isSharedWithYou && (
|
{currentFile && isSharedWithYou && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Button size="sm" variant="light" onClick={() => onMakeCopy(currentFile)} fullWidth>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => onMakeCopy(currentFile)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
{t("fileManager.makeCopy", "Make a copy")}
|
{t("fileManager.makeCopy", "Make a copy")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@@ -177,7 +226,10 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
|||||||
</Text>
|
</Text>
|
||||||
{uploadEnabled && isOutOfSync ? (
|
{uploadEnabled && isOutOfSync ? (
|
||||||
<Badge size="xs" variant="light" color="yellow">
|
<Badge size="xs" variant="light" color="yellow">
|
||||||
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
|
{t(
|
||||||
|
"fileManager.changesNotUploaded",
|
||||||
|
"Changes not uploaded",
|
||||||
|
)}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : uploadEnabled ? (
|
) : uploadEnabled ? (
|
||||||
<Badge size="xs" variant="light" color="teal">
|
<Badge size="xs" variant="light" color="teal">
|
||||||
@@ -206,7 +258,12 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({ currentFile, modalHeight })
|
|||||||
{t("fileManager.sharedByYou", "Shared by you")}
|
{t("fileManager.sharedByYou", "Shared by you")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
<Button size="sm" variant="light" onClick={() => setShowShareManageModal(true)} fullWidth>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => setShowShareManageModal(true)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
{t("storageShare.manage", "Manage sharing")}
|
{t("storageShare.manage", "Manage sharing")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ interface FileListAreaProps {
|
|||||||
scrollAreaStyle?: React.CSSProperties;
|
scrollAreaStyle?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileListArea: React.FC<FileListAreaProps> = ({ scrollAreaHeight, scrollAreaStyle = {} }) => {
|
const FileListArea: React.FC<FileListAreaProps> = ({
|
||||||
|
scrollAreaHeight,
|
||||||
|
scrollAreaStyle = {},
|
||||||
|
}) => {
|
||||||
const {
|
const {
|
||||||
activeSource,
|
activeSource,
|
||||||
recentFiles,
|
recentFiles,
|
||||||
@@ -94,9 +97,14 @@ const FileListArea: React.FC<FileListAreaProps> = ({ scrollAreaHeight, scrollAre
|
|||||||
return (
|
return (
|
||||||
<Center style={{ height: "12.5rem" }}>
|
<Center style={{ height: "12.5rem" }}>
|
||||||
<Stack align="center" gap="sm">
|
<Stack align="center" gap="sm">
|
||||||
<CloudIcon style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }} />
|
<CloudIcon
|
||||||
|
style={{ fontSize: "3rem", color: "var(--mantine-color-gray-5)" }}
|
||||||
|
/>
|
||||||
<Text c="dimmed" ta="center">
|
<Text c="dimmed" ta="center">
|
||||||
{t("fileManager.googleDriveNotAvailable", "Google Drive integration coming soon")}
|
{t(
|
||||||
|
"fileManager.googleDriveNotAvailable",
|
||||||
|
"Google Drive integration coming soon",
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Center>
|
</Center>
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import React, { useCallback, useMemo, useState } from "react";
|
import React, { useCallback, useMemo, useState } from "react";
|
||||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from "@mantine/core";
|
import {
|
||||||
|
Group,
|
||||||
|
Box,
|
||||||
|
Text,
|
||||||
|
ActionIcon,
|
||||||
|
Checkbox,
|
||||||
|
Divider,
|
||||||
|
Menu,
|
||||||
|
Badge,
|
||||||
|
} from "@mantine/core";
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
import DownloadIcon from "@mui/icons-material/Download";
|
||||||
@@ -60,14 +69,21 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { config } = useAppConfig();
|
const { config } = useAppConfig();
|
||||||
const { expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext();
|
const {
|
||||||
|
expandedFileIds,
|
||||||
|
onToggleExpansion,
|
||||||
|
onUnzipFile,
|
||||||
|
refreshRecentFiles,
|
||||||
|
} = useFileManagerContext();
|
||||||
const { removeFiles } = useFileManagement();
|
const { removeFiles } = useFileManagement();
|
||||||
|
|
||||||
// Check if this is a ZIP file
|
// Check if this is a ZIP file
|
||||||
const isZipFile = zipFileService.isZipFileStub(file);
|
const isZipFile = zipFileService.isZipFileStub(file);
|
||||||
|
|
||||||
// Check file extension
|
// Check file extension
|
||||||
const extLower = (file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || "").toLowerCase();
|
const extLower = (
|
||||||
|
file.name?.match(/\.([a-z0-9]+)$/i)?.[1] || ""
|
||||||
|
).toLowerCase();
|
||||||
const isCBZ = extLower === "cbz";
|
const isCBZ = extLower === "cbz";
|
||||||
const isCBR = extLower === "cbr";
|
const isCBR = extLower === "cbr";
|
||||||
|
|
||||||
@@ -75,33 +91,55 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
const shouldShowHovered = isHovered || isMenuOpen;
|
const shouldShowHovered = isHovered || isMenuOpen;
|
||||||
|
|
||||||
// Get version information for this file
|
// Get version information for this file
|
||||||
const leafFileId = (isLatestVersion ? file.id : file.originalFileId || file.id) as FileId;
|
const leafFileId = (
|
||||||
|
isLatestVersion ? file.id : file.originalFileId || file.id
|
||||||
|
) as FileId;
|
||||||
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
||||||
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
||||||
const isExpanded = expandedFileIds.has(leafFileId);
|
const isExpanded = expandedFileIds.has(leafFileId);
|
||||||
const uploadEnabled = config?.storageEnabled === true;
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
const sharingEnabled =
|
||||||
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
|
const shareLinksEnabled =
|
||||||
|
sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||||
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
||||||
const isSharedWithYou = sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
|
const isSharedWithYou =
|
||||||
|
sharingEnabled &&
|
||||||
|
(file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
|
||||||
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
||||||
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
||||||
const isUploaded = Boolean(file.remoteStorageId);
|
const isUploaded = Boolean(file.remoteStorageId);
|
||||||
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||||
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
|
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
|
||||||
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
|
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
|
||||||
const accessRole = (isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")).toLowerCase();
|
const accessRole = (
|
||||||
const hasReadAccess = isOwnedOrLocal || accessRole === "editor" || accessRole === "commenter" || accessRole === "viewer";
|
isOwnedOrLocal ? "editor" : (file.remoteAccessRole ?? "viewer")
|
||||||
const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate);
|
).toLowerCase();
|
||||||
|
const hasReadAccess =
|
||||||
|
isOwnedOrLocal ||
|
||||||
|
accessRole === "editor" ||
|
||||||
|
accessRole === "commenter" ||
|
||||||
|
accessRole === "viewer";
|
||||||
|
const canUpload =
|
||||||
|
uploadEnabled &&
|
||||||
|
isOwnedOrLocal &&
|
||||||
|
isLatestVersion &&
|
||||||
|
(!isUploaded || !isUpToDate);
|
||||||
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
|
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
|
||||||
const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
|
const canManageShare =
|
||||||
const canCopyShareLink = shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId);
|
sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
|
||||||
|
const canCopyShareLink =
|
||||||
|
shareLinksEnabled &&
|
||||||
|
Boolean(file.remoteHasShareLinks) &&
|
||||||
|
Boolean(file.remoteStorageId);
|
||||||
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
|
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
|
||||||
|
|
||||||
const shareBaseUrl = useMemo(() => {
|
const shareBaseUrl = useMemo(() => {
|
||||||
const frontendUrl = (config?.frontendUrl || "").trim();
|
const frontendUrl = (config?.frontendUrl || "").trim();
|
||||||
if (frontendUrl) {
|
if (frontendUrl) {
|
||||||
const normalized = frontendUrl.endsWith("/") ? frontendUrl.slice(0, -1) : frontendUrl;
|
const normalized = frontendUrl.endsWith("/")
|
||||||
|
? frontendUrl.slice(0, -1)
|
||||||
|
: frontendUrl;
|
||||||
return `${normalized}/share/`;
|
return `${normalized}/share/`;
|
||||||
}
|
}
|
||||||
return absoluteWithBasePath("/share/");
|
return absoluteWithBasePath("/share/");
|
||||||
@@ -110,10 +148,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
const handleCopyShareLink = useCallback(async () => {
|
const handleCopyShareLink = useCallback(async () => {
|
||||||
if (!file.remoteStorageId) return;
|
if (!file.remoteStorageId) return;
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(
|
const response = await apiClient.get<{
|
||||||
`/api/v1/storage/files/${file.remoteStorageId}`,
|
shareLinks?: Array<{ token?: string }>;
|
||||||
{ suppressErrorToast: true } as any,
|
}>(`/api/v1/storage/files/${file.remoteStorageId}`, {
|
||||||
);
|
suppressErrorToast: true,
|
||||||
|
} as any);
|
||||||
const links = response.data?.shareLinks ?? [];
|
const links = response.data?.shareLinks ?? [];
|
||||||
const token = links[links.length - 1]?.token;
|
const token = links[links.length - 1]?.token;
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -163,9 +202,13 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
MozUserSelect: "none",
|
MozUserSelect: "none",
|
||||||
msUserSelect: "none",
|
msUserSelect: "none",
|
||||||
paddingLeft: isHistoryFile ? "2rem" : "0.75rem", // Indent history files
|
paddingLeft: isHistoryFile ? "2rem" : "0.75rem", // Indent history files
|
||||||
borderLeft: isHistoryFile ? "3px solid var(--mantine-color-blue-4)" : "none", // Visual indicator for history
|
borderLeft: isHistoryFile
|
||||||
|
? "3px solid var(--mantine-color-blue-4)"
|
||||||
|
: "none", // Visual indicator for history
|
||||||
}}
|
}}
|
||||||
onClick={isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)}
|
onClick={
|
||||||
|
isHistoryFile || isActive ? undefined : (e) => onSelect(e.shiftKey)
|
||||||
|
}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
onMouseEnter={() => setIsHovered(true)}
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
onMouseLeave={() => setIsHovered(false)}
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
@@ -217,7 +260,10 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
{t("fileManager.sharedWithYou", "Shared with you")}
|
{t("fileManager.sharedWithYou", "Shared with you")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== "editor" ? (
|
{sharingEnabled &&
|
||||||
|
isSharedWithYou &&
|
||||||
|
accessRole &&
|
||||||
|
accessRole !== "editor" ? (
|
||||||
<Badge size="xs" variant="light" color="gray">
|
<Badge size="xs" variant="light" color="gray">
|
||||||
{accessRole === "commenter"
|
{accessRole === "commenter"
|
||||||
? t("storageShare.roleCommenter", "Commenter")
|
? t("storageShare.roleCommenter", "Commenter")
|
||||||
@@ -228,19 +274,31 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
{t("fileManager.localOnly", "Local only")}
|
{t("fileManager.localOnly", "Local only")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : uploadEnabled && isOutOfSync ? (
|
) : uploadEnabled && isOutOfSync ? (
|
||||||
<Badge size="xs" variant="light" color="yellow" leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}>
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}
|
||||||
|
>
|
||||||
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
|
{t("fileManager.changesNotUploaded", "Changes not uploaded")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : uploadEnabled && isUploaded ? (
|
) : uploadEnabled && isUploaded ? (
|
||||||
<Badge size="xs" variant="light" color="teal" leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}>
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}
|
||||||
|
>
|
||||||
{t("fileManager.synced", "Synced")}
|
{t("fileManager.synced", "Synced")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
{sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && (
|
{sharingEnabled &&
|
||||||
<Badge size="xs" variant="light" color="blue">
|
file.remoteOwnedByCurrentUser !== false &&
|
||||||
{t("fileManager.sharedByYou", "Shared by you")}
|
file.remoteHasShareLinks && (
|
||||||
</Badge>
|
<Badge size="xs" variant="light" color="blue">
|
||||||
)}
|
{t("fileManager.sharedByYou", "Shared by you")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs" align="center">
|
<Group gap="xs" align="center">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -249,7 +307,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
|
|
||||||
{/* Tool chain for processed files */}
|
{/* Tool chain for processed files */}
|
||||||
{file.toolHistory && file.toolHistory.length > 0 && (
|
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||||
<ToolChain toolChain={file.toolHistory} maxWidth={"150px"} displayStyle="text" size="xs" />
|
<ToolChain
|
||||||
|
toolChain={file.toolHistory}
|
||||||
|
maxWidth={"150px"}
|
||||||
|
displayStyle="text"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -368,7 +431,9 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
onToggleExpansion(leafFileId);
|
onToggleExpansion(leafFileId);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isExpanded ? t("fileManager.hideHistory", "Hide History") : t("fileManager.showHistory", "Show History")}
|
{isExpanded
|
||||||
|
? t("fileManager.hideHistory", "Hide History")
|
||||||
|
: t("fileManager.showHistory", "Show History")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
</>
|
</>
|
||||||
@@ -436,7 +501,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{canManageShare && (
|
{canManageShare && (
|
||||||
<ShareManagementModal opened={showShareManageModal} onClose={() => setShowShareManageModal(false)} file={file} />
|
<ShareManagementModal
|
||||||
|
opened={showShareManageModal}
|
||||||
|
onClose={() => setShowShareManageModal(false)}
|
||||||
|
file={file}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,10 +32,19 @@ const GoogleDriveIcon: React.FC<{ disabled?: boolean }> = ({ disabled }) => (
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = false }) => {
|
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||||
const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect, onNewFilesSelect } = useFileManagerContext();
|
horizontal = false,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
activeSource,
|
||||||
|
onSourceChange,
|
||||||
|
onLocalFileClick,
|
||||||
|
onGoogleDriveSelect,
|
||||||
|
onNewFilesSelect,
|
||||||
|
} = useFileManagerContext();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker();
|
const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } =
|
||||||
|
useGoogleDrivePicker();
|
||||||
const terminology = useFileActionTerminology();
|
const terminology = useFileActionTerminology();
|
||||||
const icons = useFileActionIcons();
|
const icons = useFileActionIcons();
|
||||||
const UploadIcon = icons.upload;
|
const UploadIcon = icons.upload;
|
||||||
@@ -66,21 +75,29 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Determine visibility of Google Drive button
|
// Determine visibility of Google Drive button
|
||||||
const shouldHideGoogleDrive = !isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
|
const shouldHideGoogleDrive =
|
||||||
|
!isGoogleDriveEnabled && config?.hideDisabledToolsGoogleDrive;
|
||||||
|
|
||||||
// Determine visibility of Mobile QR Scanner button
|
// Determine visibility of Mobile QR Scanner button
|
||||||
const shouldHideMobileQR = !isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
|
const shouldHideMobileQR =
|
||||||
|
!isMobileUploadEnabled && config?.hideDisabledToolsMobileQRScanner;
|
||||||
|
|
||||||
const buttonProps = {
|
const buttonProps = {
|
||||||
variant: (source: string) => (activeSource === source ? "filled" : "subtle"),
|
variant: (source: string) =>
|
||||||
getColor: (source: string) => (activeSource === source ? "var(--mantine-color-gray-2)" : undefined),
|
activeSource === source ? "filled" : "subtle",
|
||||||
|
getColor: (source: string) =>
|
||||||
|
activeSource === source ? "var(--mantine-color-gray-2)" : undefined,
|
||||||
getStyles: (source: string) => ({
|
getStyles: (source: string) => ({
|
||||||
root: {
|
root: {
|
||||||
backgroundColor: activeSource === source ? undefined : "transparent",
|
backgroundColor: activeSource === source ? undefined : "transparent",
|
||||||
color: activeSource === source ? "var(--mantine-color-gray-9)" : "var(--mantine-color-gray-6)",
|
color:
|
||||||
|
activeSource === source
|
||||||
|
? "var(--mantine-color-gray-9)"
|
||||||
|
: "var(--mantine-color-gray-6)",
|
||||||
border: "none",
|
border: "none",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: activeSource === source ? undefined : "var(--mantine-color-gray-0)",
|
backgroundColor:
|
||||||
|
activeSource === source ? undefined : "var(--mantine-color-gray-0)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -97,7 +114,9 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
|
|||||||
color={buttonProps.getColor("recent")}
|
color={buttonProps.getColor("recent")}
|
||||||
styles={buttonProps.getStyles("recent")}
|
styles={buttonProps.getStyles("recent")}
|
||||||
>
|
>
|
||||||
{horizontal ? t("fileManager.recent", "Recent") : t("fileManager.recent", "Recent")}
|
{horizontal
|
||||||
|
? t("fileManager.recent", "Recent")
|
||||||
|
: t("fileManager.recent", "Recent")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -136,17 +155,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
|
|||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: isGoogleDriveEnabled ? "var(--mantine-color-gray-0)" : "transparent",
|
backgroundColor: isGoogleDriveEnabled
|
||||||
|
? "var(--mantine-color-gray-0)"
|
||||||
|
: "transparent",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
!isGoogleDriveEnabled
|
!isGoogleDriveEnabled
|
||||||
? t("fileManager.googleDriveNotAvailable", "Google Drive integration not available")
|
? t(
|
||||||
|
"fileManager.googleDriveNotAvailable",
|
||||||
|
"Google Drive integration not available",
|
||||||
|
)
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{horizontal ? t("fileManager.googleDriveShort", "Drive") : t("fileManager.googleDrive", "Google Drive")}
|
{horizontal
|
||||||
|
? t("fileManager.googleDriveShort", "Drive")
|
||||||
|
: t("fileManager.googleDrive", "Google Drive")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -165,13 +191,24 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
|
|||||||
backgroundColor: "transparent",
|
backgroundColor: "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
"&:hover": {
|
"&:hover": {
|
||||||
backgroundColor: isMobileUploadEnabled ? "var(--mantine-color-gray-0)" : "transparent",
|
backgroundColor: isMobileUploadEnabled
|
||||||
|
? "var(--mantine-color-gray-0)"
|
||||||
|
: "transparent",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
title={!isMobileUploadEnabled ? t("fileManager.mobileUploadNotAvailable", "Mobile upload not available") : undefined}
|
title={
|
||||||
|
!isMobileUploadEnabled
|
||||||
|
? t(
|
||||||
|
"fileManager.mobileUploadNotAvailable",
|
||||||
|
"Mobile upload not available",
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{horizontal ? t("fileManager.mobileShort", "Mobile") : t("fileManager.mobileUpload", "Mobile Upload")}
|
{horizontal
|
||||||
|
? t("fileManager.mobileShort", "Mobile")
|
||||||
|
: t("fileManager.mobileUpload", "Mobile Upload")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -195,7 +232,14 @@ const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({ horizontal = fals
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack gap="xs" style={{ height: "100%" }}>
|
<Stack gap="xs" style={{ height: "100%" }}>
|
||||||
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: "1rem" }}>
|
<Text
|
||||||
|
size="sm"
|
||||||
|
pt="sm"
|
||||||
|
fw={500}
|
||||||
|
c="dimmed"
|
||||||
|
mb="xs"
|
||||||
|
style={{ paddingLeft: "1rem" }}
|
||||||
|
>
|
||||||
{t("fileManager.myFiles", "My Files")}
|
{t("fileManager.myFiles", "My Files")}
|
||||||
</Text>
|
</Text>
|
||||||
{buttons}
|
{buttons}
|
||||||
|
|||||||
@@ -27,7 +27,11 @@ const MobileLayout: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box h="100%" p="sm" style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
<Box
|
||||||
|
h="100%"
|
||||||
|
p="sm"
|
||||||
|
style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}
|
||||||
|
>
|
||||||
{/* Section 1: File Sources - Fixed at top */}
|
{/* Section 1: File Sources - Fixed at top */}
|
||||||
<Box style={{ flexShrink: 0 }}>
|
<Box style={{ flexShrink: 0 }}>
|
||||||
<FileSourceButtons horizontal={true} />
|
<FileSourceButtons horizontal={true} />
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ const baseKeyStyle: React.CSSProperties = {
|
|||||||
color: "var(--mantine-color-text)",
|
color: "var(--mantine-color-text)",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = "sm", muted = false }) => {
|
export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({
|
||||||
|
binding,
|
||||||
|
size = "sm",
|
||||||
|
muted = false,
|
||||||
|
}) => {
|
||||||
const { getDisplayParts } = useHotkeys();
|
const { getDisplayParts } = useHotkeys();
|
||||||
const parts = getDisplayParts(binding);
|
const parts = getDisplayParts(binding);
|
||||||
|
|
||||||
@@ -31,7 +35,10 @@ export const HotkeyDisplay: React.FC<HotkeyDisplayProps> = ({ binding, size = "s
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyStyle = size === "md" ? { ...baseKeyStyle, fontSize: "0.85rem", padding: "0.2rem 0.5rem" } : baseKeyStyle;
|
const keyStyle =
|
||||||
|
size === "md"
|
||||||
|
? { ...baseKeyStyle, fontSize: "0.85rem", padding: "0.2rem 0.5rem" }
|
||||||
|
: baseKeyStyle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import { useRainbowThemeContext } from "@app/components/shared/RainbowThemeProvi
|
|||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { useFileHandler } from "@app/hooks/useFileHandler";
|
import { useFileHandler } from "@app/hooks/useFileHandler";
|
||||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||||
import { useNavigationState, useNavigationActions, useNavigationGuard } from "@app/contexts/NavigationContext";
|
import {
|
||||||
|
useNavigationState,
|
||||||
|
useNavigationActions,
|
||||||
|
useNavigationGuard,
|
||||||
|
} from "@app/contexts/NavigationContext";
|
||||||
import { isBaseWorkbench } from "@app/types/workbench";
|
import { isBaseWorkbench } from "@app/types/workbench";
|
||||||
import { useViewer } from "@app/contexts/ViewerContext";
|
import { useViewer } from "@app/contexts/ViewerContext";
|
||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
@@ -102,7 +106,9 @@ export default function Workbench() {
|
|||||||
// Check if we're showing a custom workbench first
|
// Check if we're showing a custom workbench first
|
||||||
// Custom workbenches may not require files in FileContext (e.g., sign request workbench)
|
// Custom workbenches may not require files in FileContext (e.g., sign request workbench)
|
||||||
if (!isBaseWorkbench(currentView)) {
|
if (!isBaseWorkbench(currentView)) {
|
||||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
const customView = customWorkbenchViews.find(
|
||||||
|
(view) => view.workbenchId === currentView && view.data != null,
|
||||||
|
);
|
||||||
if (customView) {
|
if (customView) {
|
||||||
const CustomComponent = customView.component;
|
const CustomComponent = customView.component;
|
||||||
return <CustomComponent data={customView.data} />;
|
return <CustomComponent data={customView.data} />;
|
||||||
@@ -148,7 +154,15 @@ export default function Workbench() {
|
|||||||
<div style={{ position: "relative", flex: "1 1 0", height: 0 }}>
|
<div style={{ position: "relative", flex: "1 1 0", height: 0 }}>
|
||||||
<PageEditor onFunctionsReady={setPageEditorFunctions} />
|
<PageEditor onFunctionsReady={setPageEditorFunctions} />
|
||||||
{pageEditorFunctions && (
|
{pageEditorFunctions && (
|
||||||
<div style={{ position: "absolute", bottom: 0, left: 0, right: 0, zIndex: 100 }}>
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
zIndex: 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PageEditorControls
|
<PageEditorControls
|
||||||
onClosePdf={pageEditorFunctions.closePdf}
|
onClosePdf={pageEditorFunctions.closePdf}
|
||||||
onUndo={pageEditorFunctions.handleUndo}
|
onUndo={pageEditorFunctions.handleUndo}
|
||||||
@@ -190,20 +204,26 @@ export default function Workbench() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Top Controls */}
|
{/* Top Controls */}
|
||||||
{activeFiles.length > 0 && !customWorkbenchViews.find((v) => v.workbenchId === currentView)?.hideTopControls && (
|
{activeFiles.length > 0 &&
|
||||||
<TopControls
|
!customWorkbenchViews.find((v) => v.workbenchId === currentView)
|
||||||
currentView={currentView}
|
?.hideTopControls && (
|
||||||
setCurrentView={setCurrentView}
|
<TopControls
|
||||||
customViews={customWorkbenchViews}
|
currentView={currentView}
|
||||||
activeFiles={activeFiles.map((f) => {
|
setCurrentView={setCurrentView}
|
||||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
customViews={customWorkbenchViews}
|
||||||
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
activeFiles={activeFiles.map((f) => {
|
||||||
})}
|
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||||
currentFileIndex={activeFileIndex}
|
return {
|
||||||
onFileSelect={handleFileSelect}
|
fileId: f.fileId,
|
||||||
onFileRemove={handleFileRemove}
|
name: f.name,
|
||||||
/>
|
versionNumber: stub?.versionNumber,
|
||||||
)}
|
};
|
||||||
|
})}
|
||||||
|
currentFileIndex={activeFileIndex}
|
||||||
|
onFileSelect={handleFileSelect}
|
||||||
|
onFileRemove={handleFileRemove}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Dismiss All Errors Button */}
|
{/* Dismiss All Errors Button */}
|
||||||
<DismissAllErrorsButton />
|
<DismissAllErrorsButton />
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import React from "react";
|
|||||||
import { Button, Group, ActionIcon } from "@mantine/core";
|
import { Button, Group, ActionIcon } from "@mantine/core";
|
||||||
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ButtonDefinition, type FlowState } from "@app/components/onboarding/onboardingFlowConfig";
|
import {
|
||||||
|
ButtonDefinition,
|
||||||
|
type FlowState,
|
||||||
|
} from "@app/components/onboarding/onboardingFlowConfig";
|
||||||
import type { LicenseNotice } from "@app/types/types";
|
import type { LicenseNotice } from "@app/types/types";
|
||||||
import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
import type { ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
||||||
|
|
||||||
@@ -16,10 +19,19 @@ interface SlideButtonsProps {
|
|||||||
onAction: (action: ButtonAction) => void;
|
onAction: (action: ButtonAction) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SlideButtons({ slideDefinition, licenseNotice, flowState, onAction }: SlideButtonsProps) {
|
export function SlideButtons({
|
||||||
|
slideDefinition,
|
||||||
|
licenseNotice,
|
||||||
|
flowState,
|
||||||
|
onAction,
|
||||||
|
}: SlideButtonsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const leftButtons = slideDefinition.buttons.filter((btn) => btn.group === "left");
|
const leftButtons = slideDefinition.buttons.filter(
|
||||||
const rightButtons = slideDefinition.buttons.filter((btn) => btn.group === "right");
|
(btn) => btn.group === "left",
|
||||||
|
);
|
||||||
|
const rightButtons = slideDefinition.buttons.filter(
|
||||||
|
(btn) => btn.group === "right",
|
||||||
|
);
|
||||||
|
|
||||||
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
const buttonStyles = (variant: ButtonDefinition["variant"]) =>
|
||||||
variant === "primary"
|
variant === "primary"
|
||||||
@@ -76,7 +88,9 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{button.icon === "chevron-left" && <ChevronLeftIcon fontSize="small" />}
|
{button.icon === "chevron-left" && (
|
||||||
|
<ChevronLeftIcon fontSize="small" />
|
||||||
|
)}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -85,7 +99,12 @@ export function SlideButtons({ slideDefinition, licenseNotice, flowState, onActi
|
|||||||
const label = resolveButtonLabel(button);
|
const label = resolveButtonLabel(button);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button key={button.key} onClick={() => onAction(button.action)} disabled={disabled} styles={buttonStyles(variant)}>
|
<Button
|
||||||
|
key={button.key}
|
||||||
|
onClick={() => onAction(button.action)}
|
||||||
|
disabled={disabled}
|
||||||
|
styles={buttonStyles(variant)}
|
||||||
|
>
|
||||||
{label}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,11 +6,21 @@ import { isAuthRoute } from "@app/constants/routes";
|
|||||||
import { dispatchTourState } from "@app/constants/events";
|
import { dispatchTourState } from "@app/constants/events";
|
||||||
import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator";
|
import { useOnboardingOrchestrator } from "@app/components/onboarding/orchestrator/useOnboardingOrchestrator";
|
||||||
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
|
import { useBypassOnboarding } from "@app/components/onboarding/useBypassOnboarding";
|
||||||
import OnboardingTour, { type AdvanceArgs, type CloseArgs } from "@app/components/onboarding/OnboardingTour";
|
import OnboardingTour, {
|
||||||
|
type AdvanceArgs,
|
||||||
|
type CloseArgs,
|
||||||
|
} from "@app/components/onboarding/OnboardingTour";
|
||||||
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
|
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
|
||||||
import { useServerLicenseRequest, useTourRequest } from "@app/components/onboarding/useOnboardingEffects";
|
import {
|
||||||
|
useServerLicenseRequest,
|
||||||
|
useTourRequest,
|
||||||
|
} from "@app/components/onboarding/useOnboardingEffects";
|
||||||
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
|
import { useOnboardingDownload } from "@app/components/onboarding/useOnboardingDownload";
|
||||||
import { SLIDE_DEFINITIONS, type SlideId, type ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
import {
|
||||||
|
SLIDE_DEFINITIONS,
|
||||||
|
type SlideId,
|
||||||
|
type ButtonAction,
|
||||||
|
} from "@app/components/onboarding/onboardingFlowConfig";
|
||||||
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
|
||||||
import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext";
|
import { useTourOrchestration } from "@app/contexts/TourOrchestrationContext";
|
||||||
import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext";
|
import { useAdminTourOrchestration } from "@app/contexts/AdminTourOrchestrationContext";
|
||||||
@@ -36,9 +46,18 @@ export default function Onboarding() {
|
|||||||
const onAuthRoute = isAuthRoute(location.pathname);
|
const onAuthRoute = isAuthRoute(location.pathname);
|
||||||
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
|
const { currentStep, isActive, isLoading, runtimeState, activeFlow } = state;
|
||||||
|
|
||||||
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } = useOnboardingDownload();
|
const { osInfo, osOptions, setSelectedDownloadUrl, handleDownloadSelected } =
|
||||||
const { showLicenseSlide, licenseNotice: externalLicenseNotice, closeLicenseSlide } = useServerLicenseRequest();
|
useOnboardingDownload();
|
||||||
const { tourRequested: externalTourRequested, requestedTourType, clearTourRequest } = useTourRequest();
|
const {
|
||||||
|
showLicenseSlide,
|
||||||
|
licenseNotice: externalLicenseNotice,
|
||||||
|
closeLicenseSlide,
|
||||||
|
} = useServerLicenseRequest();
|
||||||
|
const {
|
||||||
|
tourRequested: externalTourRequested,
|
||||||
|
requestedTourType,
|
||||||
|
clearTourRequest,
|
||||||
|
} = useTourRequest();
|
||||||
const { config, refetch: refetchConfig } = useAppConfig();
|
const { config, refetch: refetchConfig } = useAppConfig();
|
||||||
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
|
const [analyticsError, setAnalyticsError] = useState<string | null>(null);
|
||||||
const [analyticsLoading, setAnalyticsLoading] = useState(false);
|
const [analyticsLoading, setAnalyticsLoading] = useState(false);
|
||||||
@@ -75,10 +94,20 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
// Check if we should show analytics modal before onboarding
|
// Check if we should show analytics modal before onboarding
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !analyticsModalDismissed && serverExperience.effectiveIsAdmin && config?.enableAnalytics == null) {
|
if (
|
||||||
|
!isLoading &&
|
||||||
|
!analyticsModalDismissed &&
|
||||||
|
serverExperience.effectiveIsAdmin &&
|
||||||
|
config?.enableAnalytics == null
|
||||||
|
) {
|
||||||
setShowAnalyticsModal(true);
|
setShowAnalyticsModal(true);
|
||||||
}
|
}
|
||||||
}, [isLoading, analyticsModalDismissed, serverExperience.effectiveIsAdmin, config?.enableAnalytics]);
|
}, [
|
||||||
|
isLoading,
|
||||||
|
analyticsModalDismissed,
|
||||||
|
serverExperience.effectiveIsAdmin,
|
||||||
|
config?.enableAnalytics,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleAnalyticsChoice = useCallback(
|
const handleAnalyticsChoice = useCallback(
|
||||||
async (enableAnalytics: boolean) => {
|
async (enableAnalytics: boolean) => {
|
||||||
@@ -90,12 +119,17 @@ export default function Onboarding() {
|
|||||||
formData.append("enabled", enableAnalytics.toString());
|
formData.append("enabled", enableAnalytics.toString());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post("/api/v1/settings/update-enable-analytics", formData);
|
await apiClient.post(
|
||||||
|
"/api/v1/settings/update-enable-analytics",
|
||||||
|
formData,
|
||||||
|
);
|
||||||
await refetchConfig();
|
await refetchConfig();
|
||||||
setShowAnalyticsModal(false);
|
setShowAnalyticsModal(false);
|
||||||
setAnalyticsModalDismissed(true);
|
setAnalyticsModalDismissed(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAnalyticsError(error instanceof Error ? error.message : "Unknown error");
|
setAnalyticsError(
|
||||||
|
error instanceof Error ? error.message : "Unknown error",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setAnalyticsLoading(false);
|
setAnalyticsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -137,7 +171,11 @@ export default function Onboarding() {
|
|||||||
setIsTourOpen(true);
|
setIsTourOpen(true);
|
||||||
break;
|
break;
|
||||||
case "launch-auto": {
|
case "launch-auto": {
|
||||||
const tourType = serverExperience.effectiveIsAdmin || runtimeState.selectedRole === "admin" ? "admin" : "whatsnew";
|
const tourType =
|
||||||
|
serverExperience.effectiveIsAdmin ||
|
||||||
|
runtimeState.selectedRole === "admin"
|
||||||
|
? "admin"
|
||||||
|
: "whatsnew";
|
||||||
actions.updateRuntimeState({ tourType });
|
actions.updateRuntimeState({ tourType });
|
||||||
setIsTourOpen(true);
|
setIsTourOpen(true);
|
||||||
break;
|
break;
|
||||||
@@ -170,7 +208,10 @@ export default function Onboarding() {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isRTL = typeof document !== "undefined" ? document.documentElement.dir === "rtl" : false;
|
const isRTL =
|
||||||
|
typeof document !== "undefined"
|
||||||
|
? document.documentElement.dir === "rtl"
|
||||||
|
: false;
|
||||||
const [isTourOpen, setIsTourOpen] = useState(false);
|
const [isTourOpen, setIsTourOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
|
useEffect(() => dispatchTourState(isTourOpen), [isTourOpen]);
|
||||||
@@ -241,7 +282,12 @@ export default function Onboarding() {
|
|||||||
default:
|
default:
|
||||||
return Object.values(userStepsConfig);
|
return Object.values(userStepsConfig);
|
||||||
}
|
}
|
||||||
}, [adminStepsConfig, runtimeState.tourType, userStepsConfig, whatsNewStepsConfig]);
|
}, [
|
||||||
|
adminStepsConfig,
|
||||||
|
runtimeState.tourType,
|
||||||
|
userStepsConfig,
|
||||||
|
whatsNewStepsConfig,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (externalTourRequested) {
|
if (externalTourRequested) {
|
||||||
@@ -290,7 +336,12 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
const handleAdvanceTour = useCallback(
|
const handleAdvanceTour = useCallback(
|
||||||
(args: AdvanceArgs) => {
|
(args: AdvanceArgs) => {
|
||||||
const { setCurrentStep, currentStep: tourCurrentStep, steps, setIsOpen } = args;
|
const {
|
||||||
|
setCurrentStep,
|
||||||
|
currentStep: tourCurrentStep,
|
||||||
|
steps,
|
||||||
|
setIsOpen,
|
||||||
|
} = args;
|
||||||
if (steps && tourCurrentStep === steps.length - 1) {
|
if (steps && tourCurrentStep === steps.length - 1) {
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
finishTour();
|
finishTour();
|
||||||
@@ -310,7 +361,11 @@ export default function Onboarding() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const currentSlideDefinition = useMemo(() => {
|
const currentSlideDefinition = useMemo(() => {
|
||||||
if (!currentStep || currentStep.type !== "modal-slide" || !currentStep.slideId) {
|
if (
|
||||||
|
!currentStep ||
|
||||||
|
currentStep.type !== "modal-slide" ||
|
||||||
|
!currentStep.slideId
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
|
return SLIDE_DEFINITIONS[currentStep.slideId as SlideId];
|
||||||
@@ -356,7 +411,9 @@ export default function Onboarding() {
|
|||||||
|
|
||||||
const currentModalSlideIndex = useMemo(() => {
|
const currentModalSlideIndex = useMemo(() => {
|
||||||
if (!currentStep || currentStep.type !== "modal-slide") return 0;
|
if (!currentStep || currentStep.type !== "modal-slide") return 0;
|
||||||
const modalSlides = activeFlow.filter((step) => step.type === "modal-slide");
|
const modalSlides = activeFlow.filter(
|
||||||
|
(step) => step.type === "modal-slide",
|
||||||
|
);
|
||||||
return modalSlides.findIndex((step) => step.id === currentStep.id);
|
return modalSlides.findIndex((step) => step.id === currentStep.id);
|
||||||
}, [activeFlow, currentStep]);
|
}, [activeFlow, currentStep]);
|
||||||
|
|
||||||
@@ -464,9 +521,12 @@ export default function Onboarding() {
|
|||||||
// Remove back button for external license notice
|
// Remove back button for external license notice
|
||||||
const slideDefinition = {
|
const slideDefinition = {
|
||||||
...baseSlideDefinition,
|
...baseSlideDefinition,
|
||||||
buttons: baseSlideDefinition.buttons.filter((btn) => btn.key !== "license-back"),
|
buttons: baseSlideDefinition.buttons.filter(
|
||||||
|
(btn) => btn.key !== "license-back",
|
||||||
|
),
|
||||||
};
|
};
|
||||||
const effectiveLicenseNotice = externalLicenseNotice || runtimeState.licenseNotice;
|
const effectiveLicenseNotice =
|
||||||
|
externalLicenseNotice || runtimeState.licenseNotice;
|
||||||
const slideContent = slideDefinition.createSlide({
|
const slideContent = slideDefinition.createSlide({
|
||||||
osLabel: "",
|
osLabel: "",
|
||||||
osUrl: "",
|
osUrl: "",
|
||||||
@@ -482,7 +542,10 @@ export default function Onboarding() {
|
|||||||
<OnboardingModalSlide
|
<OnboardingModalSlide
|
||||||
slideDefinition={slideDefinition}
|
slideDefinition={slideDefinition}
|
||||||
slideContent={slideContent}
|
slideContent={slideContent}
|
||||||
runtimeState={{ ...runtimeState, licenseNotice: effectiveLicenseNotice }}
|
runtimeState={{
|
||||||
|
...runtimeState,
|
||||||
|
licenseNotice: effectiveLicenseNotice,
|
||||||
|
}}
|
||||||
modalSlideCount={1}
|
modalSlideCount={1}
|
||||||
currentModalSlideIndex={0}
|
currentModalSlideIndex={0}
|
||||||
onSkip={closeLicenseSlide}
|
onSkip={closeLicenseSlide}
|
||||||
@@ -524,7 +587,9 @@ export default function Onboarding() {
|
|||||||
// Render the current onboarding step
|
// Render the current onboarding step
|
||||||
switch (currentStep.type) {
|
switch (currentStep.type) {
|
||||||
case "tool-prompt":
|
case "tool-prompt":
|
||||||
return <ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />;
|
return (
|
||||||
|
<ToolPanelModePrompt forceOpen={true} onComplete={actions.complete} />
|
||||||
|
);
|
||||||
|
|
||||||
case "modal-slide":
|
case "modal-slide":
|
||||||
if (!currentSlideDefinition || !currentSlideContent) return null;
|
if (!currentSlideDefinition || !currentSlideContent) return null;
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import { Modal, Stack, ActionIcon } from "@mantine/core";
|
|||||||
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
|
import DiamondOutlinedIcon from "@mui/icons-material/DiamondOutlined";
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
|
||||||
import type { SlideDefinition, ButtonAction } from "@app/components/onboarding/onboardingFlowConfig";
|
import type {
|
||||||
|
SlideDefinition,
|
||||||
|
ButtonAction,
|
||||||
|
} from "@app/components/onboarding/onboardingFlowConfig";
|
||||||
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
|
import type { OnboardingRuntimeState } from "@app/components/onboarding/orchestrator/onboardingConfig";
|
||||||
import type { SlideConfig } from "@app/types/types";
|
import type { SlideConfig } from "@app/types/types";
|
||||||
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
import AnimatedSlideBackground from "@app/components/onboarding/slides/AnimatedSlideBackground";
|
||||||
@@ -47,7 +50,11 @@ export default function OnboardingModalSlide({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.heroIconsContainer}>
|
<div className={styles.heroIconsContainer}>
|
||||||
<div className={styles.iconWrapper}>
|
<div className={styles.iconWrapper}>
|
||||||
<img src={`${BASE_PATH}/modern-logo/logo512.png`} alt="Stirling icon" className={styles.downloadIcon} />
|
<img
|
||||||
|
src={`${BASE_PATH}/modern-logo/logo512.png`}
|
||||||
|
alt="Stirling icon"
|
||||||
|
className={styles.downloadIcon}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -56,20 +63,45 @@ export default function OnboardingModalSlide({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.heroLogoCircle}>
|
<div className={styles.heroLogoCircle}>
|
||||||
{slideDefinition.hero.type === "rocket" && (
|
{slideDefinition.hero.type === "rocket" && (
|
||||||
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
|
<LocalIcon
|
||||||
|
icon="rocket-launch"
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className={styles.heroIcon}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{slideDefinition.hero.type === "shield" && (
|
{slideDefinition.hero.type === "shield" && (
|
||||||
<LocalIcon icon="verified-user-outline" width={64} height={64} className={styles.heroIcon} />
|
<LocalIcon
|
||||||
|
icon="verified-user-outline"
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className={styles.heroIcon}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{slideDefinition.hero.type === "lock" && (
|
{slideDefinition.hero.type === "lock" && (
|
||||||
<LocalIcon icon="lock-outline" width={64} height={64} className={styles.heroIcon} />
|
<LocalIcon
|
||||||
|
icon="lock-outline"
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className={styles.heroIcon}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{slideDefinition.hero.type === "analytics" && (
|
{slideDefinition.hero.type === "analytics" && (
|
||||||
<LocalIcon icon="analytics" width={64} height={64} className={styles.heroIcon} />
|
<LocalIcon
|
||||||
|
icon="analytics"
|
||||||
|
width={64}
|
||||||
|
height={64}
|
||||||
|
className={styles.heroIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{slideDefinition.hero.type === "diamond" && (
|
||||||
|
<DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />
|
||||||
)}
|
)}
|
||||||
{slideDefinition.hero.type === "diamond" && <DiamondOutlinedIcon sx={{ fontSize: 64, color: "#000000" }} />}
|
|
||||||
{slideDefinition.hero.type === "logo" && (
|
{slideDefinition.hero.type === "logo" && (
|
||||||
<img src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`} alt="Stirling logo" />
|
<img
|
||||||
|
src={`${BASE_PATH}/branding/StirlingPDFLogoNoTextLightHC.svg`}
|
||||||
|
alt="Stirling logo"
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -88,7 +120,12 @@ export default function OnboardingModalSlide({
|
|||||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||||
styles={{
|
styles={{
|
||||||
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
|
body: { padding: 0, maxHeight: "90vh", overflow: "hidden" },
|
||||||
content: { overflow: "hidden", border: "none", background: "var(--bg-surface)", maxHeight: "90vh" },
|
content: {
|
||||||
|
overflow: "hidden",
|
||||||
|
border: "none",
|
||||||
|
background: "var(--bg-surface)",
|
||||||
|
maxHeight: "90vh",
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack gap={0} className={styles.modalContent}>
|
<Stack gap={0} className={styles.modalContent}>
|
||||||
@@ -129,20 +166,34 @@ export default function OnboardingModalSlide({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.modalBody} style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}>
|
<div
|
||||||
|
className={styles.modalBody}
|
||||||
|
style={{ overflowY: "auto", maxHeight: "calc(90vh - 220px)" }}
|
||||||
|
>
|
||||||
<Stack gap={16}>
|
<Stack gap={16}>
|
||||||
<div key={`title-${slideContent.key}`} className={`${styles.title} ${styles.titleText}`}>
|
<div
|
||||||
|
key={`title-${slideContent.key}`}
|
||||||
|
className={`${styles.title} ${styles.titleText}`}
|
||||||
|
>
|
||||||
{slideContent.title}
|
{slideContent.title}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.bodyText}>
|
<div className={styles.bodyText}>
|
||||||
<div key={`body-${slideContent.key}`} className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
|
<div
|
||||||
|
key={`body-${slideContent.key}`}
|
||||||
|
className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}
|
||||||
|
>
|
||||||
{slideContent.body}
|
{slideContent.body}
|
||||||
</div>
|
</div>
|
||||||
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
<style>{`div strong{color: var(--onboarding-title); font-weight: 600;}`}</style>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{modalSlideCount > 1 && <OnboardingStepper totalSteps={modalSlideCount} activeStep={currentModalSlideIndex} />}
|
{modalSlideCount > 1 && (
|
||||||
|
<OnboardingStepper
|
||||||
|
totalSteps={modalSlideCount}
|
||||||
|
activeStep={currentModalSlideIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.buttonContainer}>
|
<div className={styles.buttonContainer}>
|
||||||
<SlideButtons
|
<SlideButtons
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ interface OnboardingStepperProps {
|
|||||||
* Renders a progress indicator where the active step is a pill and others are dots.
|
* Renders a progress indicator where the active step is a pill and others are dots.
|
||||||
* Colors come from theme.css variables.
|
* Colors come from theme.css variables.
|
||||||
*/
|
*/
|
||||||
export function OnboardingStepper({ totalSteps, activeStep, className }: OnboardingStepperProps) {
|
export function OnboardingStepper({
|
||||||
|
totalSteps,
|
||||||
|
activeStep,
|
||||||
|
className,
|
||||||
|
}: OnboardingStepperProps) {
|
||||||
const items = Array.from({ length: totalSteps }, (_, index) => index);
|
const items = Array.from({ length: totalSteps }, (_, index) => index);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -26,7 +30,9 @@ export function OnboardingStepper({ totalSteps, activeStep, className }: Onboard
|
|||||||
{items.map((index) => {
|
{items.map((index) => {
|
||||||
const isActive = index === activeStep;
|
const isActive = index === activeStep;
|
||||||
const baseStyles: React.CSSProperties = {
|
const baseStyles: React.CSSProperties = {
|
||||||
background: isActive ? "var(--onboarding-step-active)" : "var(--onboarding-step-inactive)",
|
background: isActive
|
||||||
|
? "var(--onboarding-step-active)"
|
||||||
|
: "var(--onboarding-step-inactive)",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -57,7 +57,15 @@ interface OnboardingTourProps {
|
|||||||
onClose: (args: CloseArgs) => void;
|
onClose: (args: CloseArgs) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen, onAdvance, onClose }: OnboardingTourProps) {
|
export default function OnboardingTour({
|
||||||
|
tourSteps,
|
||||||
|
tourType,
|
||||||
|
isRTL,
|
||||||
|
t,
|
||||||
|
isOpen,
|
||||||
|
onAdvance,
|
||||||
|
onClose,
|
||||||
|
}: OnboardingTourProps) {
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -111,15 +119,31 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
|
|||||||
disableInteraction={true}
|
disableInteraction={true}
|
||||||
disableDotsNavigation={false}
|
disableDotsNavigation={false}
|
||||||
prevButton={() => null}
|
prevButton={() => null}
|
||||||
nextButton={({ currentStep: tourCurrentStep, stepsLength, setCurrentStep, setIsOpen }) => {
|
nextButton={({
|
||||||
|
currentStep: tourCurrentStep,
|
||||||
|
stepsLength,
|
||||||
|
setCurrentStep,
|
||||||
|
setIsOpen,
|
||||||
|
}) => {
|
||||||
const isLast = tourCurrentStep === stepsLength - 1;
|
const isLast = tourCurrentStep === stepsLength - 1;
|
||||||
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
const ArrowIcon = isRTL ? ArrowBackIcon : ArrowForwardIcon;
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={() => onAdvance({ setCurrentStep, currentStep: tourCurrentStep, steps: tourSteps, setIsOpen })}
|
onClick={() =>
|
||||||
|
onAdvance({
|
||||||
|
setCurrentStep,
|
||||||
|
currentStep: tourCurrentStep,
|
||||||
|
steps: tourSteps,
|
||||||
|
setIsOpen,
|
||||||
|
})
|
||||||
|
}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="lg"
|
size="lg"
|
||||||
aria-label={isLast ? t("onboarding.finish", "Finish") : t("onboarding.next", "Next")}
|
aria-label={
|
||||||
|
isLast
|
||||||
|
? t("onboarding.finish", "Finish")
|
||||||
|
: t("onboarding.next", "Next")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
{isLast ? <CheckIcon /> : <ArrowIcon />}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
@@ -127,10 +151,17 @@ export default function OnboardingTour({ tourSteps, tourType, isRTL, t, isOpen,
|
|||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
Close: ({ onClick }) => (
|
Close: ({ onClick }) => (
|
||||||
<CloseButton onClick={onClick} size="md" style={{ position: "absolute", top: "8px", right: "8px" }} />
|
<CloseButton
|
||||||
|
onClick={onClick}
|
||||||
|
size="md"
|
||||||
|
style={{ position: "absolute", top: "8px", right: "8px" }}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
Content: ({ content }: { content: string }) => (
|
Content: ({ content }: { content: string }) => (
|
||||||
<div style={{ paddingRight: "16px" }} dangerouslySetInnerHTML={{ __html: content }} />
|
<div
|
||||||
|
style={{ paddingRight: "16px" }}
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { StepType } from "@reactour/tour";
|
import type { StepType } from "@reactour/tour";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { addGlowToElements, removeAllGlows } from "@app/components/onboarding/tourGlow";
|
import {
|
||||||
|
addGlowToElements,
|
||||||
|
removeAllGlows,
|
||||||
|
} from "@app/components/onboarding/tourGlow";
|
||||||
|
|
||||||
export enum AdminTourStep {
|
export enum AdminTourStep {
|
||||||
WELCOME,
|
WELCOME,
|
||||||
@@ -26,8 +29,16 @@ interface CreateAdminStepsConfigArgs {
|
|||||||
actions: AdminStepActions;
|
actions: AdminStepActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
|
export function createAdminStepsConfig({
|
||||||
const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions;
|
t,
|
||||||
|
actions,
|
||||||
|
}: CreateAdminStepsConfigArgs): Record<AdminTourStep, StepType> {
|
||||||
|
const {
|
||||||
|
saveAdminState,
|
||||||
|
openConfigModal,
|
||||||
|
navigateToSection,
|
||||||
|
scrollNavToSection,
|
||||||
|
} = actions;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[AdminTourStep.WELCOME]: {
|
[AdminTourStep.WELCOME]: {
|
||||||
@@ -120,7 +131,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
|||||||
},
|
},
|
||||||
[AdminTourStep.DATABASE_SECTION]: {
|
[AdminTourStep.DATABASE_SECTION]: {
|
||||||
selector: '[data-tour="admin-adminDatabase-nav"]',
|
selector: '[data-tour="admin-adminDatabase-nav"]',
|
||||||
highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'],
|
highlightedSelectors: [
|
||||||
|
'[data-tour="admin-adminDatabase-nav"]',
|
||||||
|
'[data-tour="settings-content-area"]',
|
||||||
|
],
|
||||||
content: t(
|
content: t(
|
||||||
"adminOnboarding.databaseSection",
|
"adminOnboarding.databaseSection",
|
||||||
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
|
"For advanced production environments, we have settings to allow <strong>external database hookups</strong> so you can integrate with your existing infrastructure.",
|
||||||
@@ -131,13 +145,19 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
|||||||
removeAllGlows();
|
removeAllGlows();
|
||||||
navigateToSection("adminDatabase");
|
navigateToSection("adminDatabase");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']);
|
addGlowToElements([
|
||||||
|
'[data-tour="admin-adminDatabase-nav"]',
|
||||||
|
'[data-tour="settings-content-area"]',
|
||||||
|
]);
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
[AdminTourStep.CONNECTIONS_SECTION]: {
|
[AdminTourStep.CONNECTIONS_SECTION]: {
|
||||||
selector: '[data-tour="admin-adminConnections-nav"]',
|
selector: '[data-tour="admin-adminConnections-nav"]',
|
||||||
highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'],
|
highlightedSelectors: [
|
||||||
|
'[data-tour="admin-adminConnections-nav"]',
|
||||||
|
'[data-tour="settings-content-area"]',
|
||||||
|
],
|
||||||
content: t(
|
content: t(
|
||||||
"adminOnboarding.connectionsSection",
|
"adminOnboarding.connectionsSection",
|
||||||
"The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
|
"The <strong>Connections</strong> section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications.",
|
||||||
@@ -148,7 +168,10 @@ export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArg
|
|||||||
removeAllGlows();
|
removeAllGlows();
|
||||||
navigateToSection("adminConnections");
|
navigateToSection("adminConnections");
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']);
|
addGlowToElements([
|
||||||
|
'[data-tour="admin-adminConnections-nav"]',
|
||||||
|
'[data-tour="settings-content-area"]',
|
||||||
|
]);
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
actionAfter: async () => {
|
actionAfter: async () => {
|
||||||
|
|||||||
@@ -20,7 +20,14 @@ export type SlideId =
|
|||||||
| "analytics-choice"
|
| "analytics-choice"
|
||||||
| "mfa-setup";
|
| "mfa-setup";
|
||||||
|
|
||||||
export type HeroType = "rocket" | "dual-icon" | "shield" | "diamond" | "logo" | "lock" | "analytics";
|
export type HeroType =
|
||||||
|
| "rocket"
|
||||||
|
| "dual-icon"
|
||||||
|
| "shield"
|
||||||
|
| "diamond"
|
||||||
|
| "logo"
|
||||||
|
| "lock"
|
||||||
|
| "analytics";
|
||||||
|
|
||||||
export type ButtonAction =
|
export type ButtonAction =
|
||||||
| "next"
|
| "next"
|
||||||
@@ -91,7 +98,11 @@ export interface SlideDefinition {
|
|||||||
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
||||||
"first-login": {
|
"first-login": {
|
||||||
id: "first-login",
|
id: "first-login",
|
||||||
createSlide: ({ firstLoginUsername, onPasswordChanged, usingDefaultCredentials }) =>
|
createSlide: ({
|
||||||
|
firstLoginUsername,
|
||||||
|
onPasswordChanged,
|
||||||
|
usingDefaultCredentials,
|
||||||
|
}) =>
|
||||||
FirstLoginSlide({
|
FirstLoginSlide({
|
||||||
username: firstLoginUsername || "",
|
username: firstLoginUsername || "",
|
||||||
onPasswordChanged: onPasswordChanged || (() => {}),
|
onPasswordChanged: onPasswordChanged || (() => {}),
|
||||||
@@ -148,7 +159,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
|||||||
},
|
},
|
||||||
"security-check": {
|
"security-check": {
|
||||||
id: "security-check",
|
id: "security-check",
|
||||||
createSlide: ({ selectedRole, onRoleSelect }) => SecurityCheckSlide({ selectedRole, onRoleSelect }),
|
createSlide: ({ selectedRole, onRoleSelect }) =>
|
||||||
|
SecurityCheckSlide({ selectedRole, onRoleSelect }),
|
||||||
hero: { type: "shield" },
|
hero: { type: "shield" },
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
@@ -172,7 +184,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
|||||||
},
|
},
|
||||||
"admin-overview": {
|
"admin-overview": {
|
||||||
id: "admin-overview",
|
id: "admin-overview",
|
||||||
createSlide: ({ licenseNotice, loginEnabled }) => PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
|
createSlide: ({ licenseNotice, loginEnabled }) =>
|
||||||
|
PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }),
|
||||||
hero: { type: "diamond" },
|
hero: { type: "diamond" },
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
@@ -262,7 +275,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
|||||||
},
|
},
|
||||||
"analytics-choice": {
|
"analytics-choice": {
|
||||||
id: "analytics-choice",
|
id: "analytics-choice",
|
||||||
createSlide: ({ analyticsError }) => AnalyticsChoiceSlide({ analyticsError }),
|
createSlide: ({ analyticsError }) =>
|
||||||
|
AnalyticsChoiceSlide({ analyticsError }),
|
||||||
hero: { type: "analytics" },
|
hero: { type: "analytics" },
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
@@ -285,7 +299,8 @@ export const SLIDE_DEFINITIONS: Record<SlideId, SlideDefinition> = {
|
|||||||
},
|
},
|
||||||
"mfa-setup": {
|
"mfa-setup": {
|
||||||
id: "mfa-setup",
|
id: "mfa-setup",
|
||||||
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) => MFASetupSlide({ onMfaSetupComplete }),
|
createSlide: ({ onMfaSetupComplete = () => {} }: SlideFactoryParams) =>
|
||||||
|
MFASetupSlide({ onMfaSetupComplete }),
|
||||||
hero: { type: "lock" },
|
hero: { type: "lock" },
|
||||||
buttons: [], // Form has its own submit button
|
buttons: [], // Form has its own submit button
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -115,13 +115,15 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
|
|||||||
id: "tour-overview",
|
id: "tour-overview",
|
||||||
type: "modal-slide",
|
type: "modal-slide",
|
||||||
slideId: "tour-overview",
|
slideId: "tour-overview",
|
||||||
condition: (ctx) => !ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
|
condition: (ctx) =>
|
||||||
|
!ctx.effectiveIsAdmin && ctx.tourType !== "admin" && !ctx.isDesktopApp,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "server-license",
|
id: "server-license",
|
||||||
type: "modal-slide",
|
type: "modal-slide",
|
||||||
slideId: "server-license",
|
slideId: "server-license",
|
||||||
condition: (ctx) => ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
condition: (ctx) =>
|
||||||
|
ctx.effectiveIsAdmin && ctx.licenseNotice.requiresLicense,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mfa-setup",
|
id: "mfa-setup",
|
||||||
|
|||||||
@@ -16,7 +16,10 @@ export function markOnboardingCompleted(): void {
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
|
localStorage.setItem(ONBOARDING_COMPLETED_KEY, "true");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[onboardingStorage] Error marking onboarding as completed:", error);
|
console.error(
|
||||||
|
"[onboardingStorage] Error marking onboarding as completed:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +28,10 @@ export function resetOnboardingProgress(): void {
|
|||||||
try {
|
try {
|
||||||
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
|
localStorage.removeItem(ONBOARDING_COMPLETED_KEY);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[onboardingStorage] Error resetting onboarding progress:", error);
|
console.error(
|
||||||
|
"[onboardingStorage] Error resetting onboarding progress:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +49,10 @@ export function markToursTooltipShown(): void {
|
|||||||
try {
|
try {
|
||||||
localStorage.setItem(TOURS_TOOLTIP_KEY, "true");
|
localStorage.setItem(TOURS_TOOLTIP_KEY, "true");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[onboardingStorage] Error marking tours tooltip as shown:", error);
|
console.error(
|
||||||
|
"[onboardingStorage] Error marking tours tooltip as shown:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +70,10 @@ export function migrateFromLegacyPreferences(): void {
|
|||||||
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
const prefs = JSON.parse(prefsRaw) as Record<string, unknown>;
|
||||||
|
|
||||||
// If user had completed onboarding in old system, mark new system as complete
|
// If user had completed onboarding in old system, mark new system as complete
|
||||||
if (prefs.hasCompletedOnboarding === true || prefs.hasSeenIntroOnboarding === true) {
|
if (
|
||||||
|
prefs.hasCompletedOnboarding === true ||
|
||||||
|
prefs.hasSeenIntroOnboarding === true
|
||||||
|
) {
|
||||||
markOnboardingCompleted();
|
markOnboardingCompleted();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,19 +31,27 @@ function hasAuthToken(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get initial runtime state from session storage (survives remounts)
|
// Get initial runtime state from session storage (survives remounts)
|
||||||
function getInitialRuntimeState(baseState: OnboardingRuntimeState): OnboardingRuntimeState {
|
function getInitialRuntimeState(
|
||||||
|
baseState: OnboardingRuntimeState,
|
||||||
|
): OnboardingRuntimeState {
|
||||||
if (typeof window === "undefined") {
|
if (typeof window === "undefined") {
|
||||||
return baseState;
|
return baseState;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tourRequested = sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
|
const tourRequested =
|
||||||
|
sessionStorage.getItem(SESSION_TOUR_REQUESTED) === "true";
|
||||||
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
|
const sessionTourType = sessionStorage.getItem(SESSION_TOUR_TYPE);
|
||||||
const tourType =
|
const tourType =
|
||||||
sessionTourType === "admin" || sessionTourType === "tools" || sessionTourType === "whatsnew"
|
sessionTourType === "admin" ||
|
||||||
|
sessionTourType === "tools" ||
|
||||||
|
sessionTourType === "whatsnew"
|
||||||
? sessionTourType
|
? sessionTourType
|
||||||
: "whatsnew";
|
: "whatsnew";
|
||||||
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as "admin" | "user" | null;
|
const selectedRole = sessionStorage.getItem(SESSION_SELECTED_ROLE) as
|
||||||
|
| "admin"
|
||||||
|
| "user"
|
||||||
|
| null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...baseState,
|
...baseState,
|
||||||
@@ -61,7 +69,10 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (state.tourRequested !== undefined) {
|
if (state.tourRequested !== undefined) {
|
||||||
sessionStorage.setItem(SESSION_TOUR_REQUESTED, state.tourRequested ? "true" : "false");
|
sessionStorage.setItem(
|
||||||
|
SESSION_TOUR_REQUESTED,
|
||||||
|
state.tourRequested ? "true" : "false",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (state.tourType !== undefined) {
|
if (state.tourType !== undefined) {
|
||||||
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
|
sessionStorage.setItem(SESSION_TOUR_TYPE, state.tourType);
|
||||||
@@ -74,7 +85,10 @@ function persistRuntimeState(state: Partial<OnboardingRuntimeState>): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[useOnboardingOrchestrator] Error persisting runtime state:", error);
|
console.error(
|
||||||
|
"[useOnboardingOrchestrator] Error persisting runtime state:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +111,10 @@ function parseMfaRequired(settings: string | null | undefined): boolean {
|
|||||||
const parsed = JSON.parse(settings) as { mfaRequired?: string };
|
const parsed = JSON.parse(settings) as { mfaRequired?: string };
|
||||||
return parsed.mfaRequired?.toLowerCase() === "true";
|
return parsed.mfaRequired?.toLowerCase() === "true";
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[useOnboardingOrchestrator] Failed to parse account settings JSON:", error);
|
console.warn(
|
||||||
|
"[useOnboardingOrchestrator] Failed to parse account settings JSON:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,14 +169,18 @@ export interface UseOnboardingOrchestratorOptions {
|
|||||||
defaultRuntimeState?: OnboardingRuntimeState;
|
defaultRuntimeState?: OnboardingRuntimeState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOptions): UseOnboardingOrchestratorResult {
|
export function useOnboardingOrchestrator(
|
||||||
|
options?: UseOnboardingOrchestratorOptions,
|
||||||
|
): UseOnboardingOrchestratorResult {
|
||||||
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
|
const defaultState = options?.defaultRuntimeState ?? DEFAULT_RUNTIME_STATE;
|
||||||
const serverExperience = useServerExperience();
|
const serverExperience = useServerExperience();
|
||||||
const { config, loading: configLoading } = useAppConfig();
|
const { config, loading: configLoading } = useAppConfig();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const bypassOnboarding = useBypassOnboarding();
|
const bypassOnboarding = useBypassOnboarding();
|
||||||
|
|
||||||
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() => getInitialRuntimeState(defaultState));
|
const [runtimeState, setRuntimeState] = useState<OnboardingRuntimeState>(() =>
|
||||||
|
getInitialRuntimeState(defaultState),
|
||||||
|
);
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
const [currentStepIndex, setCurrentStepIndex] = useState(-1);
|
||||||
@@ -186,7 +207,8 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
|||||||
requiresLicense:
|
requiresLicense:
|
||||||
!serverExperience.hasPaidLicense &&
|
!serverExperience.hasPaidLicense &&
|
||||||
(serverExperience.overFreeTierLimit === true ||
|
(serverExperience.overFreeTierLimit === true ||
|
||||||
(serverExperience.effectiveIsAdmin && serverExperience.userCountResolved)),
|
(serverExperience.effectiveIsAdmin &&
|
||||||
|
serverExperience.userCountResolved)),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}, [
|
}, [
|
||||||
@@ -217,7 +239,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
|||||||
requiresMfaSetup: parseMfaRequired(accountData.settings),
|
requiresMfaSetup: parseMfaRequired(accountData.settings),
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log("[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:", error);
|
console.log(
|
||||||
|
"[OnboardingOrchestrator] Failed to fetch account data for onboarding runtime state:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
// Account endpoint failed - user not logged in or security disabled
|
// Account endpoint failed - user not logged in or security disabled
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -227,17 +252,25 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
|||||||
}
|
}
|
||||||
}, [config?.enableLogin, configLoading]);
|
}, [config?.enableLogin, configLoading]);
|
||||||
|
|
||||||
const isOnAuthRoute = AUTH_ROUTES.some((route) => location.pathname.startsWith(route));
|
const isOnAuthRoute = AUTH_ROUTES.some((route) =>
|
||||||
|
location.pathname.startsWith(route),
|
||||||
|
);
|
||||||
const loginEnabled = config?.enableLogin === true;
|
const loginEnabled = config?.enableLogin === true;
|
||||||
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
const isUnauthenticatedWithLoginEnabled = loginEnabled && !hasAuthToken();
|
||||||
const shouldBlockOnboarding = bypassOnboarding || isOnAuthRoute || configLoading || isUnauthenticatedWithLoginEnabled;
|
const shouldBlockOnboarding =
|
||||||
|
bypassOnboarding ||
|
||||||
|
isOnAuthRoute ||
|
||||||
|
configLoading ||
|
||||||
|
isUnauthenticatedWithLoginEnabled;
|
||||||
|
|
||||||
const conditionContext = useMemo<OnboardingConditionContext>(
|
const conditionContext = useMemo<OnboardingConditionContext>(
|
||||||
() => ({
|
() => ({
|
||||||
...serverExperience,
|
...serverExperience,
|
||||||
...runtimeState,
|
...runtimeState,
|
||||||
effectiveIsAdmin:
|
effectiveIsAdmin:
|
||||||
serverExperience.effectiveIsAdmin || (!serverExperience.loginEnabled && runtimeState.selectedRole === "admin"),
|
serverExperience.effectiveIsAdmin ||
|
||||||
|
(!serverExperience.loginEnabled &&
|
||||||
|
runtimeState.selectedRole === "admin"),
|
||||||
}),
|
}),
|
||||||
[serverExperience, runtimeState],
|
[serverExperience, runtimeState],
|
||||||
);
|
);
|
||||||
@@ -248,7 +281,10 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
|||||||
|
|
||||||
// Wait for config AND admin status before calculating initial step
|
// Wait for config AND admin status before calculating initial step
|
||||||
const adminStatusResolved =
|
const adminStatusResolved =
|
||||||
!configLoading && (config?.enableLogin === false || config?.enableLogin === undefined || config?.isAdmin !== undefined);
|
!configLoading &&
|
||||||
|
(config?.enableLogin === false ||
|
||||||
|
config?.enableLogin === undefined ||
|
||||||
|
config?.isAdmin !== undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (configLoading || !adminStatusResolved) return;
|
if (configLoading || !adminStatusResolved) return;
|
||||||
@@ -276,9 +312,21 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
|||||||
|
|
||||||
const totalSteps = activeFlow.length;
|
const totalSteps = activeFlow.length;
|
||||||
|
|
||||||
const isComplete = isInitialized && (totalSteps === 0 || currentStepIndex >= totalSteps || isOnboardingCompleted());
|
const isComplete =
|
||||||
const currentStep = currentStepIndex >= 0 && currentStepIndex < totalSteps ? activeFlow[currentStepIndex] : null;
|
isInitialized &&
|
||||||
const isActive = !shouldBlockOnboarding && !isPaused && !isComplete && isInitialized && currentStep !== null;
|
(totalSteps === 0 ||
|
||||||
|
currentStepIndex >= totalSteps ||
|
||||||
|
isOnboardingCompleted());
|
||||||
|
const currentStep =
|
||||||
|
currentStepIndex >= 0 && currentStepIndex < totalSteps
|
||||||
|
? activeFlow[currentStepIndex]
|
||||||
|
: null;
|
||||||
|
const isActive =
|
||||||
|
!shouldBlockOnboarding &&
|
||||||
|
!isPaused &&
|
||||||
|
!isComplete &&
|
||||||
|
isInitialized &&
|
||||||
|
currentStep !== null;
|
||||||
const isLoading =
|
const isLoading =
|
||||||
configLoading ||
|
configLoading ||
|
||||||
!adminStatusResolved ||
|
!adminStatusResolved ||
|
||||||
@@ -322,10 +370,13 @@ export function useOnboardingOrchestrator(options?: UseOnboardingOrchestratorOpt
|
|||||||
setCurrentStepIndex(nextIndex);
|
setCurrentStepIndex(nextIndex);
|
||||||
}, [currentStepIndex, totalSteps]);
|
}, [currentStepIndex, totalSteps]);
|
||||||
|
|
||||||
const updateRuntimeState = useCallback((updates: Partial<OnboardingRuntimeState>) => {
|
const updateRuntimeState = useCallback(
|
||||||
persistRuntimeState(updates);
|
(updates: Partial<OnboardingRuntimeState>) => {
|
||||||
setRuntimeState((prev) => ({ ...prev, ...updates }));
|
persistRuntimeState(updates);
|
||||||
}, []);
|
setRuntimeState((prev) => ({ ...prev, ...updates }));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const refreshFlow = useCallback(() => {
|
const refreshFlow = useCallback(() => {
|
||||||
initialIndexSet.current = false;
|
initialIndexSet.current = false;
|
||||||
|
|||||||
@@ -11,10 +11,15 @@ interface AnalyticsChoiceSlideProps {
|
|||||||
analyticsError?: string | null;
|
analyticsError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoiceSlideProps): SlideConfig {
|
export default function AnalyticsChoiceSlide({
|
||||||
|
analyticsError,
|
||||||
|
}: AnalyticsChoiceSlideProps): SlideConfig {
|
||||||
return {
|
return {
|
||||||
key: "analytics-choice",
|
key: "analytics-choice",
|
||||||
title: i18n.t("analytics.title", "Do you want to help make Stirling PDF better?"),
|
title: i18n.t(
|
||||||
|
"analytics.title",
|
||||||
|
"Do you want to help make Stirling PDF better?",
|
||||||
|
),
|
||||||
body: (
|
body: (
|
||||||
<div className={styles.bodyCopyInner}>
|
<div className={styles.bodyCopyInner}>
|
||||||
<Trans
|
<Trans
|
||||||
@@ -33,13 +38,22 @@ export default function AnalyticsChoiceSlide({ analyticsError }: AnalyticsChoice
|
|||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => window.open("https://docs.stirlingpdf.com/analytics-telemetry/", "_blank")}
|
onClick={() =>
|
||||||
|
window.open(
|
||||||
|
"https://docs.stirlingpdf.com/analytics-telemetry/",
|
||||||
|
"_blank",
|
||||||
|
)
|
||||||
|
}
|
||||||
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
||||||
>
|
>
|
||||||
{i18n.t("analytics.learnMore", "Learn more about our analytics")}
|
{i18n.t("analytics.learnMore", "Learn more about our analytics")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{analyticsError && <div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>{analyticsError}</div>}
|
{analyticsError && (
|
||||||
|
<div style={{ color: "var(--mantine-color-red-6)", marginTop: 12 }}>
|
||||||
|
{analyticsError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
background: {
|
background: {
|
||||||
|
|||||||
@@ -65,6 +65,10 @@
|
|||||||
transform: translate3d(0, 0, 0);
|
transform: translate3d(0, 0, 0);
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0);
|
transform: translate3d(
|
||||||
|
var(--circle-move-x, 40px),
|
||||||
|
var(--circle-move-y, 24px),
|
||||||
|
0
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,16 @@ interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundP
|
|||||||
slideKey: string;
|
slideKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AnimatedSlideBackground({ gradientStops, circles, isActive }: AnimatedSlideBackgroundComponentProps) {
|
export default function AnimatedSlideBackground({
|
||||||
const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null);
|
gradientStops,
|
||||||
const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops);
|
circles,
|
||||||
|
isActive,
|
||||||
|
}: AnimatedSlideBackgroundComponentProps) {
|
||||||
|
const [prevGradient, setPrevGradient] = React.useState<
|
||||||
|
[string, string] | null
|
||||||
|
>(null);
|
||||||
|
const [currentGradient, setCurrentGradient] =
|
||||||
|
React.useState<[string, string]>(gradientStops);
|
||||||
const [isTransitioning, setIsTransitioning] = React.useState(false);
|
const [isTransitioning, setIsTransitioning] = React.useState(false);
|
||||||
const isFirstMount = React.useRef(true);
|
const isFirstMount = React.useRef(true);
|
||||||
|
|
||||||
@@ -29,7 +36,10 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only transition if gradient actually changed
|
// Only transition if gradient actually changed
|
||||||
if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) {
|
if (
|
||||||
|
currentGradient[0] !== gradientStops[0] ||
|
||||||
|
currentGradient[1] !== gradientStops[1]
|
||||||
|
) {
|
||||||
// Store previous gradient and start transition
|
// Store previous gradient and start transition
|
||||||
setPrevGradient(currentGradient);
|
setPrevGradient(currentGradient);
|
||||||
setIsTransitioning(true);
|
setIsTransitioning(true);
|
||||||
@@ -69,10 +79,20 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
|
|||||||
style={currentGradientStyle}
|
style={currentGradientStyle}
|
||||||
/>
|
/>
|
||||||
{circles.map((circle, index) => {
|
{circles.map((circle, index) => {
|
||||||
const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle;
|
const {
|
||||||
|
position,
|
||||||
|
size,
|
||||||
|
color,
|
||||||
|
opacity,
|
||||||
|
blur,
|
||||||
|
amplitude = 48,
|
||||||
|
duration = 15,
|
||||||
|
delay = 0,
|
||||||
|
} = circle;
|
||||||
|
|
||||||
const moveX = position === "bottom-left" ? amplitude : -amplitude;
|
const moveX = position === "bottom-left" ? amplitude : -amplitude;
|
||||||
const moveY = position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
|
const moveY =
|
||||||
|
position === "bottom-left" ? -amplitude * 0.6 : amplitude * 0.6;
|
||||||
|
|
||||||
const circleStyle: CircleStyles = {
|
const circleStyle: CircleStyles = {
|
||||||
width: size,
|
width: size,
|
||||||
@@ -98,7 +118,13 @@ export default function AnimatedSlideBackground({ gradientStops, circles, isActi
|
|||||||
circleStyle.top = `${defaultOffset + offsetY}px`;
|
circleStyle.top = `${defaultOffset + offsetY}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <div key={`circle-${index}-${position}`} className={styles.circle} style={circleStyle} />;
|
return (
|
||||||
|
<div
|
||||||
|
key={`circle-${index}-${position}`}
|
||||||
|
className={styles.circle}
|
||||||
|
style={circleStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { SlideConfig } from "@app/types/types";
|
import { SlideConfig } from "@app/types/types";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
import { DesktopInstallTitle, type OSOption } from "@app/components/onboarding/slides/DesktopInstallTitle";
|
import {
|
||||||
|
DesktopInstallTitle,
|
||||||
|
type OSOption,
|
||||||
|
} from "@app/components/onboarding/slides/DesktopInstallTitle";
|
||||||
|
|
||||||
export type { OSOption };
|
export type { OSOption };
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
|||||||
|
|
||||||
const displayLabel = currentOsOption.label || osLabel;
|
const displayLabel = currentOsOption.label || osLabel;
|
||||||
const title = displayLabel
|
const title = displayLabel
|
||||||
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", { osLabel: displayLabel })
|
? t("onboarding.desktopInstall.titleWithOs", "Download for {{osLabel}}", {
|
||||||
|
osLabel: displayLabel,
|
||||||
|
})
|
||||||
: t("onboarding.desktopInstall.title", "Download");
|
: t("onboarding.desktopInstall.title", "Download");
|
||||||
|
|
||||||
// If only one option or no options, don't show dropdown
|
// If only one option or no options, don't show dropdown
|
||||||
@@ -52,7 +54,15 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "0.5rem", width: "100%" }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: "0.5rem",
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
|
<span style={{ whiteSpace: "nowrap" }}>{title}</span>
|
||||||
<Menu position="bottom" offset={5} zIndex={10000}>
|
<Menu position="bottom" offset={5} zIndex={10000}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
@@ -80,7 +90,9 @@ export const DesktopInstallTitle: React.FC<DesktopInstallTitleProps> = ({
|
|||||||
backgroundColor: isSelected
|
backgroundColor: isSelected
|
||||||
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
|
? "light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))"
|
||||||
: "transparent",
|
: "transparent",
|
||||||
color: isSelected ? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))" : "inherit",
|
color: isSelected
|
||||||
|
? "light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))"
|
||||||
|
: "inherit",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|||||||
@@ -16,10 +16,16 @@ interface FirstLoginSlideProps {
|
|||||||
|
|
||||||
const DEFAULT_PASSWORD = "stirling";
|
const DEFAULT_PASSWORD = "stirling";
|
||||||
|
|
||||||
function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials = false }: FirstLoginSlideProps) {
|
function FirstLoginForm({
|
||||||
|
username,
|
||||||
|
onPasswordChanged,
|
||||||
|
usingDefaultCredentials = false,
|
||||||
|
}: FirstLoginSlideProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// If using default credentials, pre-fill with "stirling" - user won't see this field
|
// If using default credentials, pre-fill with "stirling" - user won't see this field
|
||||||
const [currentPassword, setCurrentPassword] = useState(usingDefaultCredentials ? DEFAULT_PASSWORD : "");
|
const [currentPassword, setCurrentPassword] = useState(
|
||||||
|
usingDefaultCredentials ? DEFAULT_PASSWORD : "",
|
||||||
|
);
|
||||||
const [newPassword, setNewPassword] = useState("");
|
const [newPassword, setNewPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -27,23 +33,39 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
// Validation
|
// Validation
|
||||||
if ((!usingDefaultCredentials && !currentPassword) || !newPassword || !confirmPassword) {
|
if (
|
||||||
|
(!usingDefaultCredentials && !currentPassword) ||
|
||||||
|
!newPassword ||
|
||||||
|
!confirmPassword
|
||||||
|
) {
|
||||||
setError(t("firstLogin.allFieldsRequired", "All fields are required"));
|
setError(t("firstLogin.allFieldsRequired", "All fields are required"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
setError(t("firstLogin.passwordsDoNotMatch", "New passwords do not match"));
|
setError(
|
||||||
|
t("firstLogin.passwordsDoNotMatch", "New passwords do not match"),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
if (newPassword.length < 8) {
|
||||||
setError(t("firstLogin.passwordTooShort", "Password must be at least 8 characters"));
|
setError(
|
||||||
|
t(
|
||||||
|
"firstLogin.passwordTooShort",
|
||||||
|
"Password must be at least 8 characters",
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword === currentPassword) {
|
if (newPassword === currentPassword) {
|
||||||
setError(t("firstLogin.passwordMustBeDifferent", "New password must be different from current password"));
|
setError(
|
||||||
|
t(
|
||||||
|
"firstLogin.passwordMustBeDifferent",
|
||||||
|
"New password must be different from current password",
|
||||||
|
),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,11 +73,18 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
|
||||||
await accountService.changePasswordOnLogin(currentPassword, newPassword, confirmPassword);
|
await accountService.changePasswordOnLogin(
|
||||||
|
currentPassword,
|
||||||
|
newPassword,
|
||||||
|
confirmPassword,
|
||||||
|
);
|
||||||
|
|
||||||
showToast({
|
showToast({
|
||||||
alertType: "success",
|
alertType: "success",
|
||||||
title: t("firstLogin.passwordChangedSuccess", "Password changed successfully! Please log in again."),
|
title: t(
|
||||||
|
"firstLogin.passwordChangedSuccess",
|
||||||
|
"Password changed successfully! Please log in again.",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear form
|
// Clear form
|
||||||
@@ -73,7 +102,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
const axiosError = err as { response?: { data?: { message?: string } } };
|
const axiosError = err as { response?: { data?: { message?: string } } };
|
||||||
setError(
|
setError(
|
||||||
axiosError.response?.data?.message ||
|
axiosError.response?.data?.message ||
|
||||||
t("firstLogin.passwordChangeFailed", "Failed to change password. Please check your current password."),
|
t(
|
||||||
|
"firstLogin.passwordChangeFailed",
|
||||||
|
"Failed to change password. Please check your current password.",
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -85,18 +117,33 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
<div className={styles.securityCard}>
|
<div className={styles.securityCard}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<div className={styles.securityAlertRow}>
|
<div className={styles.securityAlertRow}>
|
||||||
<LocalIcon icon="info-rounded" width={20} height={20} style={{ color: "#3B82F6", flexShrink: 0 }} />
|
<LocalIcon
|
||||||
|
icon="info-rounded"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
style={{ color: "#3B82F6", flexShrink: 0 }}
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
{t("firstLogin.welcomeMessage", "For security reasons, you must change your password on your first login.")}
|
{t(
|
||||||
|
"firstLogin.welcomeMessage",
|
||||||
|
"For security reasons, you must change your password on your first login.",
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{t("firstLogin.loggedInAs", "Logged in as")}: <strong>{username}</strong>
|
{t("firstLogin.loggedInAs", "Logged in as")}:{" "}
|
||||||
|
<strong>{username}</strong>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert icon={<LocalIcon icon="error-rounded" width="1rem" height="1rem" />} color="red" variant="light">
|
<Alert
|
||||||
|
icon={
|
||||||
|
<LocalIcon icon="error-rounded" width="1rem" height="1rem" />
|
||||||
|
}
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -105,7 +152,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
{!usingDefaultCredentials && (
|
{!usingDefaultCredentials && (
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("firstLogin.currentPassword", "Current Password")}
|
label={t("firstLogin.currentPassword", "Current Password")}
|
||||||
placeholder={t("firstLogin.enterCurrentPassword", "Enter your current password")}
|
placeholder={t(
|
||||||
|
"firstLogin.enterCurrentPassword",
|
||||||
|
"Enter your current password",
|
||||||
|
)}
|
||||||
value={currentPassword}
|
value={currentPassword}
|
||||||
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
|
onChange={(e) => setCurrentPassword(e.currentTarget.value)}
|
||||||
required
|
required
|
||||||
@@ -117,7 +167,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("firstLogin.newPassword", "New Password")}
|
label={t("firstLogin.newPassword", "New Password")}
|
||||||
placeholder={t("firstLogin.enterNewPassword", "Enter new password (min 8 characters)")}
|
placeholder={t(
|
||||||
|
"firstLogin.enterNewPassword",
|
||||||
|
"Enter new password (min 8 characters)",
|
||||||
|
)}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
onChange={(e) => setNewPassword(e.currentTarget.value)}
|
||||||
minLength={8}
|
minLength={8}
|
||||||
@@ -129,7 +182,10 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
|
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t("firstLogin.confirmPassword", "Confirm New Password")}
|
label={t("firstLogin.confirmPassword", "Confirm New Password")}
|
||||||
placeholder={t("firstLogin.reEnterNewPassword", "Re-enter new password")}
|
placeholder={t(
|
||||||
|
"firstLogin.reEnterNewPassword",
|
||||||
|
"Re-enter new password",
|
||||||
|
)}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
onChange={(e) => setConfirmPassword(e.currentTarget.value)}
|
||||||
required
|
required
|
||||||
@@ -143,7 +199,12 @@ function FirstLoginForm({ username, onPasswordChanged, usingDefaultCredentials =
|
|||||||
fullWidth
|
fullWidth
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={!newPassword || !confirmPassword || newPassword.length < 8 || confirmPassword.length < 8}
|
disabled={
|
||||||
|
!newPassword ||
|
||||||
|
!confirmPassword ||
|
||||||
|
newPassword.length < 8 ||
|
||||||
|
confirmPassword.length < 8
|
||||||
|
}
|
||||||
size="md"
|
size="md"
|
||||||
mt="xs"
|
mt="xs"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
|
import {
|
||||||
import { Alert, Box, Button, Group, Loader, Stack, Text, TextInput } from "@mantine/core";
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type FormEvent,
|
||||||
|
} from "react";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import { SlideConfig } from "@app/types/types";
|
import { SlideConfig } from "@app/types/types";
|
||||||
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
import { UNIFIED_CIRCLE_CONFIG } from "@app/components/onboarding/slides/unifiedBackgroundConfig";
|
||||||
@@ -16,7 +31,9 @@ interface MFASetupSlideProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
||||||
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(null);
|
const [mfaSetupData, setMfaSetupData] = useState<MfaSetupResponse | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const [mfaSetupCode, setMfaSetupCode] = useState("");
|
const [mfaSetupCode, setMfaSetupCode] = useState("");
|
||||||
const [mfaError, setMfaError] = useState("");
|
const [mfaError, setMfaError] = useState("");
|
||||||
const [mfaLoading, setMfaLoading] = useState(false);
|
const [mfaLoading, setMfaLoading] = useState(false);
|
||||||
@@ -27,7 +44,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
const accountLogout = useAccountLogout();
|
const accountLogout = useAccountLogout();
|
||||||
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
|
const qrLogoSrc = `${BASE_PATH}/modern-logo/StirlingPDFLogoNoTextDark.svg`;
|
||||||
|
|
||||||
const normalizeMfaCode = useCallback((value: string) => value.replace(/\D/g, "").slice(0, 6), []);
|
const normalizeMfaCode = useCallback(
|
||||||
|
(value: string) => value.replace(/\D/g, "").slice(0, 6),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const fetchMfaSetup = useCallback(async () => {
|
const fetchMfaSetup = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -38,7 +58,10 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
setMfaSetupData(data);
|
setMfaSetupData(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||||
setMfaError(axiosError.response?.data?.error || "Unable to start two-factor setup. Please try again.");
|
setMfaError(
|
||||||
|
axiosError.response?.data?.error ||
|
||||||
|
"Unable to start two-factor setup. Please try again.",
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setMfaLoading(false);
|
setMfaLoading(false);
|
||||||
}
|
}
|
||||||
@@ -84,7 +107,8 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const axiosError = err as { response?: { data?: { error?: string } } };
|
const axiosError = err as { response?: { data?: { error?: string } } };
|
||||||
setMfaError(
|
setMfaError(
|
||||||
axiosError.response?.data?.error || "Unable to enable two-factor authentication. Check the code and try again.",
|
axiosError.response?.data?.error ||
|
||||||
|
"Unable to enable two-factor authentication. Check the code and try again.",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -137,12 +161,17 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
<div className={styles.mfaCard}>
|
<div className={styles.mfaCard}>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Secure your account by linking an authenticator app. Scan the QR code or enter the setup key, then confirm the
|
Secure your account by linking an authenticator app. Scan the QR
|
||||||
6-digit code to finish.
|
code or enter the setup key, then confirm the 6-digit code to
|
||||||
|
finish.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{mfaError && (
|
{mfaError && (
|
||||||
<Alert icon={<LocalIcon icon="error" width={16} height={16} />} color="red" variant="light">
|
<Alert
|
||||||
|
icon={<LocalIcon icon="error" width={16} height={16} />}
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
{mfaError}
|
{mfaError}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -163,7 +192,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
label="Authentication code"
|
label="Authentication code"
|
||||||
placeholder="123456"
|
placeholder="123456"
|
||||||
value={mfaSetupCode}
|
value={mfaSetupCode}
|
||||||
onChange={(event) => setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))}
|
onChange={(event) =>
|
||||||
|
setMfaSetupCode(normalizeMfaCode(event.currentTarget.value))
|
||||||
|
}
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
maxLength={6}
|
maxLength={6}
|
||||||
minLength={6}
|
minLength={6}
|
||||||
@@ -179,7 +210,13 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
>
|
>
|
||||||
Regenerate QR code
|
Regenerate QR code
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" loading={submitting} disabled={!isReady || setupComplete || mfaSetupCode.length < 6}>
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={submitting}
|
||||||
|
disabled={
|
||||||
|
!isReady || setupComplete || mfaSetupCode.length < 6
|
||||||
|
}
|
||||||
|
>
|
||||||
Enable MFA
|
Enable MFA
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="button" variant="light" onClick={onLogout}>
|
<Button type="button" variant="light" onClick={onLogout}>
|
||||||
@@ -200,7 +237,9 @@ function MFASetupContent({ onMfaSetupComplete }: MFASetupSlideProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MFASetupSlide({ onMfaSetupComplete }: MFASetupSlideProps = {}): SlideConfig {
|
export default function MFASetupSlide({
|
||||||
|
onMfaSetupComplete,
|
||||||
|
}: MFASetupSlideProps = {}): SlideConfig {
|
||||||
return {
|
return {
|
||||||
key: "mfa-setup-slide",
|
key: "mfa-setup-slide",
|
||||||
title: "Multi-Factor Authentication Setup",
|
title: "Multi-Factor Authentication Setup",
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ freeTierLimit, loginEnabled }) => {
|
const AdminOverviewBody: React.FC<{
|
||||||
|
freeTierLimit: number;
|
||||||
|
loginEnabled: boolean;
|
||||||
|
}> = ({ freeTierLimit, loginEnabled }) => {
|
||||||
const adminBodyKey = loginEnabled
|
const adminBodyKey = loginEnabled
|
||||||
? "onboarding.planOverview.adminBodyLoginEnabled"
|
? "onboarding.planOverview.adminBodyLoginEnabled"
|
||||||
: "onboarding.planOverview.adminBodyLoginDisabled";
|
: "onboarding.planOverview.adminBodyLoginDisabled";
|
||||||
@@ -32,7 +35,12 @@ const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean
|
|||||||
: "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.";
|
: "Once you enable login mode, you can manage users, configure settings, and monitor server health. The first <strong>{{freeTierLimit}}</strong> people on your server get to use Stirling free of charge.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Trans i18nKey={adminBodyKey} values={{ freeTierLimit }} components={{ strong: <strong /> }} defaults={defaultValue} />
|
<Trans
|
||||||
|
i18nKey={adminBodyKey}
|
||||||
|
values={{ freeTierLimit }}
|
||||||
|
components={{ strong: <strong /> }}
|
||||||
|
defaults={defaultValue}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -48,11 +56,19 @@ const UserOverviewBody: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; loginEnabled: boolean }> = ({
|
const PlanOverviewBody: React.FC<{
|
||||||
isAdmin,
|
isAdmin: boolean;
|
||||||
freeTierLimit,
|
freeTierLimit: number;
|
||||||
loginEnabled,
|
loginEnabled: boolean;
|
||||||
}) => (isAdmin ? <AdminOverviewBody freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} /> : <UserOverviewBody />);
|
}> = ({ isAdmin, freeTierLimit, loginEnabled }) =>
|
||||||
|
isAdmin ? (
|
||||||
|
<AdminOverviewBody
|
||||||
|
freeTierLimit={freeTierLimit}
|
||||||
|
loginEnabled={loginEnabled}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOverviewBody />
|
||||||
|
);
|
||||||
|
|
||||||
export default function PlanOverviewSlide({
|
export default function PlanOverviewSlide({
|
||||||
isAdmin,
|
isAdmin,
|
||||||
@@ -64,7 +80,13 @@ export default function PlanOverviewSlide({
|
|||||||
return {
|
return {
|
||||||
key: isAdmin ? "admin-overview" : "plan-overview",
|
key: isAdmin ? "admin-overview" : "plan-overview",
|
||||||
title: <PlanOverviewTitle isAdmin={isAdmin} />,
|
title: <PlanOverviewTitle isAdmin={isAdmin} />,
|
||||||
body: <PlanOverviewBody isAdmin={isAdmin} freeTierLimit={freeTierLimit} loginEnabled={loginEnabled} />,
|
body: (
|
||||||
|
<PlanOverviewBody
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
freeTierLimit={freeTierLimit}
|
||||||
|
loginEnabled={loginEnabled}
|
||||||
|
/>
|
||||||
|
),
|
||||||
background: {
|
background: {
|
||||||
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
|
gradientStops: isAdmin ? ["#4F46E5", "#0EA5E9"] : ["#F97316", "#EF4444"],
|
||||||
circles: UNIFIED_CIRCLE_CONFIG,
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ interface SecurityCheckSlideProps {
|
|||||||
onRoleSelect: (role: "admin" | "user" | null) => void;
|
onRoleSelect: (role: "admin" | "user" | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: SecurityCheckSlideProps): SlideConfig {
|
export default function SecurityCheckSlide({
|
||||||
|
selectedRole,
|
||||||
|
onRoleSelect,
|
||||||
|
}: SecurityCheckSlideProps): SlideConfig {
|
||||||
return {
|
return {
|
||||||
key: "security-check",
|
key: "security-check",
|
||||||
title: "Security Check",
|
title: "Security Check",
|
||||||
@@ -19,7 +22,12 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
|
|||||||
<div className={styles.securitySlideContent}>
|
<div className={styles.securitySlideContent}>
|
||||||
<div className={styles.securityCard}>
|
<div className={styles.securityCard}>
|
||||||
<div className={styles.securityAlertRow}>
|
<div className={styles.securityAlertRow}>
|
||||||
<LocalIcon icon="error" width={20} height={20} style={{ color: "#F04438", flexShrink: 0 }} />
|
<LocalIcon
|
||||||
|
icon="error"
|
||||||
|
width={20}
|
||||||
|
height={20}
|
||||||
|
style={{ color: "#F04438", flexShrink: 0 }}
|
||||||
|
/>
|
||||||
<span>
|
<span>
|
||||||
{i18n.t(
|
{i18n.t(
|
||||||
"onboarding.securityCheck.message",
|
"onboarding.securityCheck.message",
|
||||||
@@ -35,7 +43,9 @@ export default function SecurityCheckSlide({ selectedRole, onRoleSelect }: Secur
|
|||||||
{ value: "admin", label: "Admin" },
|
{ value: "admin", label: "Admin" },
|
||||||
{ value: "user", label: "User" },
|
{ value: "user", label: "User" },
|
||||||
]}
|
]}
|
||||||
onChange={(value) => onRoleSelect((value as "admin" | "user") ?? null)}
|
onChange={(value) =>
|
||||||
|
onRoleSelect((value as "admin" | "user") ?? null)
|
||||||
|
}
|
||||||
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
|
comboboxProps={{ withinPortal: true, zIndex: 5000 }}
|
||||||
styles={{
|
styles={{
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -10,11 +10,14 @@ interface ServerLicenseSlideProps {
|
|||||||
|
|
||||||
const DEFAULT_FREE_TIER_LIMIT = 5;
|
const DEFAULT_FREE_TIER_LIMIT = 5;
|
||||||
|
|
||||||
export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlideProps = {}): SlideConfig {
|
export default function ServerLicenseSlide({
|
||||||
|
licenseNotice,
|
||||||
|
}: ServerLicenseSlideProps = {}): SlideConfig {
|
||||||
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT;
|
||||||
const totalUsers = licenseNotice?.totalUsers ?? null;
|
const totalUsers = licenseNotice?.totalUsers ?? null;
|
||||||
const isOverLimit = licenseNotice?.isOverLimit ?? false;
|
const isOverLimit = licenseNotice?.isOverLimit ?? false;
|
||||||
const formattedTotalUsers = totalUsers != null ? totalUsers.toLocaleString() : null;
|
const formattedTotalUsers =
|
||||||
|
totalUsers != null ? totalUsers.toLocaleString() : null;
|
||||||
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
|
const overLimitUserCopy = formattedTotalUsers ?? `more than ${freeTierLimit}`;
|
||||||
const title = isOverLimit
|
const title = isOverLimit
|
||||||
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
|
? i18n.t("onboarding.serverLicense.overLimitTitle", "Server License Needed")
|
||||||
@@ -50,7 +53,9 @@ export default function ServerLicenseSlide({ licenseNotice }: ServerLicenseSlide
|
|||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
background: {
|
background: {
|
||||||
gradientStops: isOverLimit ? ["#F472B6", "#8B5CF6"] : ["#F97316", "#F59E0B"],
|
gradientStops: isOverLimit
|
||||||
|
? ["#F472B6", "#8B5CF6"]
|
||||||
|
: ["#F97316", "#F59E0B"],
|
||||||
circles: UNIFIED_CIRCLE_CONFIG,
|
circles: UNIFIED_CIRCLE_CONFIG,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export const addGlowToElements = (selectors: string[]) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const removeAllGlows = () => {
|
export const removeAllGlows = () => {
|
||||||
document.querySelectorAll(".tour-content-glow").forEach((el) => el.classList.remove("tour-content-glow"));
|
document
|
||||||
document.querySelectorAll(".tour-nav-glow").forEach((el) => el.classList.remove("tour-nav-glow"));
|
.querySelectorAll(".tour-content-glow")
|
||||||
|
.forEach((el) => el.classList.remove("tour-content-glow"));
|
||||||
|
document
|
||||||
|
.querySelectorAll(".tour-nav-glow")
|
||||||
|
.forEach((el) => el.classList.remove("tour-nav-glow"));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ function setStoredBypass(enabled: boolean): void {
|
|||||||
*/
|
*/
|
||||||
export function useBypassOnboarding(): boolean {
|
export function useBypassOnboarding(): boolean {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() => readStoredBypass());
|
const [bypassOnboarding, setBypassOnboarding] = useState<boolean>(() =>
|
||||||
|
readStoredBypass(),
|
||||||
|
);
|
||||||
|
|
||||||
const shouldBypassFromSearch = useMemo(() => {
|
const shouldBypassFromSearch = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
|||||||
case "windows":
|
case "windows":
|
||||||
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
|
return { label: "Windows", url: DOWNLOAD_URLS.WINDOWS };
|
||||||
case "mac-apple":
|
case "mac-apple":
|
||||||
return { label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON };
|
return {
|
||||||
|
label: "Mac (Apple Silicon)",
|
||||||
|
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
|
||||||
|
};
|
||||||
case "mac-intel":
|
case "mac-intel":
|
||||||
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
|
return { label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL };
|
||||||
case "linux-x64":
|
case "linux-x64":
|
||||||
@@ -51,8 +54,16 @@ export function useOnboardingDownload(): UseOnboardingDownloadResult {
|
|||||||
() =>
|
() =>
|
||||||
[
|
[
|
||||||
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
|
{ label: "Windows", url: DOWNLOAD_URLS.WINDOWS, value: "windows" },
|
||||||
{ label: "Mac (Apple Silicon)", url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: "mac-apple" },
|
{
|
||||||
{ label: "Mac (Intel)", url: DOWNLOAD_URLS.MAC_INTEL, value: "mac-intel" },
|
label: "Mac (Apple Silicon)",
|
||||||
|
url: DOWNLOAD_URLS.MAC_APPLE_SILICON,
|
||||||
|
value: "mac-apple",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mac (Intel)",
|
||||||
|
url: DOWNLOAD_URLS.MAC_INTEL,
|
||||||
|
value: "mac-intel",
|
||||||
|
},
|
||||||
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
|
{ label: "Linux", url: DOWNLOAD_URLS.LINUX_DOCS, value: "linux" },
|
||||||
].filter((opt) => opt.url),
|
].filter((opt) => opt.url),
|
||||||
[],
|
[],
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export function useServerLicenseRequest(): {
|
|||||||
closeLicenseSlide: () => void;
|
closeLicenseSlide: () => void;
|
||||||
} {
|
} {
|
||||||
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
|
const [showLicenseSlide, setShowLicenseSlide] = useState(false);
|
||||||
const [licenseNotice, setLicenseNotice] = useState<OnboardingRuntimeState["licenseNotice"] | null>(null);
|
const [licenseNotice, setLicenseNotice] = useState<
|
||||||
|
OnboardingRuntimeState["licenseNotice"] | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -35,7 +37,11 @@ export function useServerLicenseRequest(): {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
window.addEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
||||||
return () => window.removeEventListener(SERVER_LICENSE_REQUEST_EVENT, handleLicenseRequest);
|
return () =>
|
||||||
|
window.removeEventListener(
|
||||||
|
SERVER_LICENSE_REQUEST_EVENT,
|
||||||
|
handleLicenseRequest,
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const closeLicenseSlide = useCallback(() => {
|
const closeLicenseSlide = useCallback(() => {
|
||||||
@@ -51,7 +57,8 @@ export function useTourRequest(): {
|
|||||||
clearTourRequest: () => void;
|
clearTourRequest: () => void;
|
||||||
} {
|
} {
|
||||||
const [tourRequested, setTourRequested] = useState(false);
|
const [tourRequested, setTourRequested] = useState(false);
|
||||||
const [requestedTourType, setRequestedTourType] = useState<TourType>("whatsnew");
|
const [requestedTourType, setRequestedTourType] =
|
||||||
|
useState<TourType>("whatsnew");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
@@ -63,7 +70,8 @@ export function useTourRequest(): {
|
|||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
|
window.addEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||||
return () => window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
|
return () =>
|
||||||
|
window.removeEventListener(START_TOUR_EVENT, handleTourRequest);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearTourRequest = useCallback(() => {
|
const clearTourRequest = useCallback(() => {
|
||||||
|
|||||||
@@ -36,7 +36,10 @@ interface CreateUserStepsConfigArgs {
|
|||||||
actions: UserStepActions;
|
actions: UserStepActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUserStepsConfig({ t, actions }: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
|
export function createUserStepsConfig({
|
||||||
|
t,
|
||||||
|
actions,
|
||||||
|
}: CreateUserStepsConfigArgs): Record<TourStep, StepType> {
|
||||||
const {
|
const {
|
||||||
saveWorkbenchState,
|
saveWorkbenchState,
|
||||||
closeFilesModal,
|
closeFilesModal,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { StepType } from "@reactour/tour";
|
import type { StepType } from "@reactour/tour";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 100): Promise<void> {
|
async function waitForElement(
|
||||||
|
selector: string,
|
||||||
|
timeoutMs = 7000,
|
||||||
|
intervalMs = 100,
|
||||||
|
): Promise<void> {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
// Immediate hit
|
// Immediate hit
|
||||||
@@ -19,7 +23,11 @@ async function waitForElement(selector: string, timeoutMs = 7000, intervalMs = 1
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForHighlightable(selector: string, timeoutMs = 7000, intervalMs = 500): Promise<void> {
|
async function waitForHighlightable(
|
||||||
|
selector: string,
|
||||||
|
timeoutMs = 7000,
|
||||||
|
intervalMs = 500,
|
||||||
|
): Promise<void> {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
@@ -68,7 +76,10 @@ interface CreateWhatsNewStepsConfigArgs {
|
|||||||
actions: WhatsNewStepActions;
|
actions: WhatsNewStepActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
|
export function createWhatsNewStepsConfig({
|
||||||
|
t,
|
||||||
|
actions,
|
||||||
|
}: CreateWhatsNewStepsConfigArgs): Record<WhatsNewTourStep, StepType> {
|
||||||
const {
|
const {
|
||||||
saveWorkbenchState,
|
saveWorkbenchState,
|
||||||
closeFilesModal,
|
closeFilesModal,
|
||||||
@@ -128,7 +139,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
|||||||
},
|
},
|
||||||
[WhatsNewTourStep.RIGHT_RAIL]: {
|
[WhatsNewTourStep.RIGHT_RAIL]: {
|
||||||
selector: '[data-tour="right-rail-controls"]',
|
selector: '[data-tour="right-rail-controls"]',
|
||||||
highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'],
|
highlightedSelectors: [
|
||||||
|
'[data-tour="right-rail-controls"]',
|
||||||
|
'[data-tour="right-rail-settings"]',
|
||||||
|
],
|
||||||
content: t(
|
content: t(
|
||||||
"onboarding.whatsNew.rightRail",
|
"onboarding.whatsNew.rightRail",
|
||||||
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
|
"The <strong>Right Rail</strong> holds quick actions to select files, change theme or language, and download results.",
|
||||||
@@ -157,7 +171,10 @@ export function createWhatsNewStepsConfig({ t, actions }: CreateWhatsNewStepsCon
|
|||||||
},
|
},
|
||||||
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
[WhatsNewTourStep.PAGE_EDITOR_VIEW]: {
|
||||||
selector: '[data-tour="view-switcher"]',
|
selector: '[data-tour="view-switcher"]',
|
||||||
content: t("onboarding.whatsNew.pageEditorView", "Switch to the Page Editor to reorder, rotate, or delete pages."),
|
content: t(
|
||||||
|
"onboarding.whatsNew.pageEditorView",
|
||||||
|
"Switch to the Page Editor to reorder, rotate, or delete pages.",
|
||||||
|
),
|
||||||
position: "bottom",
|
position: "bottom",
|
||||||
padding: 8,
|
padding: 8,
|
||||||
action: async () => {
|
action: async () => {
|
||||||
|
|||||||
@@ -39,9 +39,17 @@ const BulkSelectionPanel = ({
|
|||||||
onToggleAdvanced={setAdvancedOpened}
|
onToggleAdvanced={setAdvancedOpened}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PageSelectionSyntaxHint input={csvInput} maxPages={maxPages} variant="panel" />
|
<PageSelectionSyntaxHint
|
||||||
|
input={csvInput}
|
||||||
|
maxPages={maxPages}
|
||||||
|
variant="panel"
|
||||||
|
/>
|
||||||
|
|
||||||
<SelectedPagesDisplay selectedPageIds={selectedPageIds} displayDocument={displayDocument} syntaxError={null} />
|
<SelectedPagesDisplay
|
||||||
|
selectedPageIds={selectedPageIds}
|
||||||
|
displayDocument={displayDocument}
|
||||||
|
syntaxError={null}
|
||||||
|
/>
|
||||||
|
|
||||||
<AdvancedSelectionPanel
|
<AdvancedSelectionPanel
|
||||||
csvInput={csvInput}
|
csvInput={csvInput}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback, useMemo } from "react";
|
import React, {
|
||||||
|
useRef,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
|
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
|
||||||
import styles from "@app/components/pageEditor/DragDropGrid.module.css";
|
import styles from "@app/components/pageEditor/DragDropGrid.module.css";
|
||||||
import { Z_INDEX_SELECTION_BOX, Z_INDEX_DROP_INDICATOR, Z_INDEX_DRAG_BADGE } from "@app/styles/zIndex";
|
import {
|
||||||
|
Z_INDEX_SELECTION_BOX,
|
||||||
|
Z_INDEX_DROP_INDICATOR,
|
||||||
|
Z_INDEX_DRAG_BADGE,
|
||||||
|
} from "@app/styles/zIndex";
|
||||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -28,7 +38,11 @@ interface DragDropItem {
|
|||||||
|
|
||||||
interface DragDropGridProps<T extends DragDropItem> {
|
interface DragDropGridProps<T extends DragDropItem> {
|
||||||
items: T[];
|
items: T[];
|
||||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
onReorderPages: (
|
||||||
|
sourcePageNumber: number,
|
||||||
|
targetIndex: number,
|
||||||
|
selectedPageIds?: string[],
|
||||||
|
) => void;
|
||||||
renderItem: (
|
renderItem: (
|
||||||
item: T,
|
item: T,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -40,7 +54,9 @@ interface DragDropGridProps<T extends DragDropItem> {
|
|||||||
dragHandleProps?: any,
|
dragHandleProps?: any,
|
||||||
zoomLevel?: number,
|
zoomLevel?: number,
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
getThumbnailData?: (
|
||||||
|
itemId: string,
|
||||||
|
) => { src: string; rotation: number } | null;
|
||||||
zoomLevel?: number;
|
zoomLevel?: number;
|
||||||
selectedFileIds?: string[];
|
selectedFileIds?: string[];
|
||||||
selectedPageIds?: string[];
|
selectedPageIds?: string[];
|
||||||
@@ -67,7 +83,10 @@ function resolveDropHint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const items: ItemRect[] = Array.from(itemRefs.current.entries())
|
const items: ItemRect[] = Array.from(itemRefs.current.entries())
|
||||||
.filter((entry): entry is [string, HTMLDivElement] => !!entry[1] && entry[0] !== activeId)
|
.filter(
|
||||||
|
(entry): entry is [string, HTMLDivElement] =>
|
||||||
|
!!entry[1] && entry[0] !== activeId,
|
||||||
|
)
|
||||||
.map(([itemId, element]) => ({
|
.map(([itemId, element]) => ({
|
||||||
id: itemId,
|
id: itemId,
|
||||||
rect: element.getBoundingClientRect(),
|
rect: element.getBoundingClientRect(),
|
||||||
@@ -90,7 +109,8 @@ function resolveDropHint(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSameRow = Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance;
|
const isSameRow =
|
||||||
|
Math.abs(item.rect.top - currentRow[0].rect.top) <= rowTolerance;
|
||||||
if (isSameRow) {
|
if (isSameRow) {
|
||||||
currentRow.push(item);
|
currentRow.push(item);
|
||||||
} else {
|
} else {
|
||||||
@@ -174,7 +194,9 @@ function resolveTargetIndex<T extends DragDropItem>(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (hoveredId) {
|
if (hoveredId) {
|
||||||
const filteredIndex = filteredItems.findIndex((item) => item.id === hoveredId);
|
const filteredIndex = filteredItems.findIndex(
|
||||||
|
(item) => item.id === hoveredId,
|
||||||
|
);
|
||||||
if (filteredIndex !== -1) {
|
if (filteredIndex !== -1) {
|
||||||
const adjustedIndex = filteredIndex + (dropSide === "right" ? 1 : 0);
|
const adjustedIndex = filteredIndex + (dropSide === "right" ? 1 : 0);
|
||||||
return convertFilteredIndexToOriginal(adjustedIndex);
|
return convertFilteredIndexToOriginal(adjustedIndex);
|
||||||
@@ -198,7 +220,9 @@ interface DraggableItemProps<T extends DragDropItem> {
|
|||||||
clearBoxSelection: () => void;
|
clearBoxSelection: () => void;
|
||||||
activeDragIds: string[];
|
activeDragIds: string[];
|
||||||
justMoved: boolean;
|
justMoved: boolean;
|
||||||
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
getThumbnailData?: (
|
||||||
|
itemId: string,
|
||||||
|
) => { src: string; rotation: number } | null;
|
||||||
onUpdateDropTarget: (itemId: string | null) => void;
|
onUpdateDropTarget: (itemId: string | null) => void;
|
||||||
renderItem: (
|
renderItem: (
|
||||||
item: T,
|
item: T,
|
||||||
@@ -247,11 +271,15 @@ const DraggableItemInner = <T extends DragDropItem>({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const element = itemRefs.current.get(item.id);
|
const element = itemRefs.current.get(item.id);
|
||||||
const imgElement = element?.querySelector("img.ph-no-capture") as HTMLImageElement;
|
const imgElement = element?.querySelector(
|
||||||
|
"img.ph-no-capture",
|
||||||
|
) as HTMLImageElement;
|
||||||
if (imgElement?.src) {
|
if (imgElement?.src) {
|
||||||
return {
|
return {
|
||||||
src: imgElement.src,
|
src: imgElement.src,
|
||||||
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0,
|
rotation: imgElement.dataset.originalRotation
|
||||||
|
? parseInt(imgElement.dataset.originalRotation)
|
||||||
|
: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -312,8 +340,12 @@ const DraggableItem = React.memo(DraggableItemInner, (prevProps, nextProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if page selection changed (for checkbox selection, not box selection)
|
// Check if page selection changed (for checkbox selection, not box selection)
|
||||||
const prevSelectedSet = prevProps.selectedPageIds ? new Set(prevProps.selectedPageIds) : null;
|
const prevSelectedSet = prevProps.selectedPageIds
|
||||||
const nextSelectedSet = nextProps.selectedPageIds ? new Set(nextProps.selectedPageIds) : null;
|
? new Set(prevProps.selectedPageIds)
|
||||||
|
: null;
|
||||||
|
const nextSelectedSet = nextProps.selectedPageIds
|
||||||
|
? new Set(nextProps.selectedPageIds)
|
||||||
|
: null;
|
||||||
|
|
||||||
if (prevSelectedSet && nextSelectedSet) {
|
if (prevSelectedSet && nextSelectedSet) {
|
||||||
const prevSelected = prevSelectedSet.has(prevProps.item.id);
|
const prevSelected = prevSelectedSet.has(prevProps.item.id);
|
||||||
@@ -348,36 +380,51 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const getScrollElement = useCallback(() => {
|
const getScrollElement = useCallback(() => {
|
||||||
return containerRef.current?.closest("[data-scrolling-container]") as HTMLElement | null;
|
return containerRef.current?.closest(
|
||||||
|
"[data-scrolling-container]",
|
||||||
|
) as HTMLElement | null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Create stable signature for items to ensure useMemo detects changes
|
// Create stable signature for items to ensure useMemo detects changes
|
||||||
const itemsSignature = useMemo(() => items.map((item) => item.id).join(","), [items]);
|
const itemsSignature = useMemo(
|
||||||
const selectedFileIdsSignature = useMemo(() => selectedFileIds?.join(",") || "", [selectedFileIds]);
|
() => items.map((item) => item.id).join(","),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
const selectedFileIdsSignature = useMemo(
|
||||||
|
() => selectedFileIds?.join(",") || "",
|
||||||
|
[selectedFileIds],
|
||||||
|
);
|
||||||
|
|
||||||
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
|
const { filteredItems: visibleItems, filteredToOriginalIndex } =
|
||||||
const filtered: T[] = [];
|
useMemo(() => {
|
||||||
const indexMap: number[] = [];
|
const filtered: T[] = [];
|
||||||
const selectedIds = selectedFileIds && selectedFileIds.length > 0 ? new Set(selectedFileIds) : null;
|
const indexMap: number[] = [];
|
||||||
|
const selectedIds =
|
||||||
|
selectedFileIds && selectedFileIds.length > 0
|
||||||
|
? new Set(selectedFileIds)
|
||||||
|
: null;
|
||||||
|
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
const isPlaceholder = Boolean(item.isPlaceholder);
|
const isPlaceholder = Boolean(item.isPlaceholder);
|
||||||
if (isPlaceholder) {
|
if (isPlaceholder) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const belongsToVisibleFile = !selectedIds || !item.originalFileId || selectedIds.has(item.originalFileId);
|
const belongsToVisibleFile =
|
||||||
|
!selectedIds ||
|
||||||
|
!item.originalFileId ||
|
||||||
|
selectedIds.has(item.originalFileId);
|
||||||
|
|
||||||
if (!belongsToVisibleFile) {
|
if (!belongsToVisibleFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
filtered.push(item);
|
filtered.push(item);
|
||||||
indexMap.push(index);
|
indexMap.push(index);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { filteredItems: filtered, filteredToOriginalIndex: indexMap };
|
return { filteredItems: filtered, filteredToOriginalIndex: indexMap };
|
||||||
}, [items, selectedFileIds, itemsSignature, selectedFileIdsSignature]);
|
}, [items, selectedFileIds, itemsSignature, selectedFileIdsSignature]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const visibleIdSet = new Set(visibleItems.map((item) => item.id));
|
const visibleIdSet = new Set(visibleItems.map((item) => item.id));
|
||||||
@@ -389,8 +436,14 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
}, [visibleItems]);
|
}, [visibleItems]);
|
||||||
|
|
||||||
// Box selection state
|
// Box selection state
|
||||||
const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null);
|
const [boxSelectStart, setBoxSelectStart] = useState<{
|
||||||
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null);
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [boxSelectEnd, setBoxSelectEnd] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
|
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
|
||||||
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
|
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
|
||||||
const [justMovedIds, setJustMovedIds] = useState<string[]>([]);
|
const [justMovedIds, setJustMovedIds] = useState<string[]>([]);
|
||||||
@@ -398,7 +451,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
|
|
||||||
// Drag state
|
// Drag state
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null);
|
const [dragPreview, setDragPreview] = useState<{
|
||||||
|
src: string;
|
||||||
|
rotation: number;
|
||||||
|
} | null>(null);
|
||||||
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
|
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
|
||||||
const [dropSide, setDropSide] = useState<DropSide>(null);
|
const [dropSide, setDropSide] = useState<DropSide>(null);
|
||||||
|
|
||||||
@@ -438,7 +494,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("pointermove", handlePointerMove, { passive: true });
|
window.addEventListener("pointermove", handlePointerMove, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("pointermove", handlePointerMove);
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
if (rafId !== null) {
|
if (rafId !== null) {
|
||||||
@@ -449,7 +507,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
|
|
||||||
// Responsive grid configuration
|
// Responsive grid configuration
|
||||||
const [itemsPerRow, setItemsPerRow] = useState(4);
|
const [itemsPerRow, setItemsPerRow] = useState(4);
|
||||||
const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
const OVERSCAN =
|
||||||
|
visibleItems.length > 1000
|
||||||
|
? GRID_CONSTANTS.OVERSCAN_LARGE
|
||||||
|
: GRID_CONSTANTS.OVERSCAN_SMALL;
|
||||||
|
|
||||||
// Calculate items per row based on container width
|
// Calculate items per row based on container width
|
||||||
const calculateItemsPerRow = useCallback(() => {
|
const calculateItemsPerRow = useCallback(() => {
|
||||||
@@ -459,8 +520,11 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
if (containerWidth === 0) return 4; // Container not measured yet
|
if (containerWidth === 0) return 4; // Container not measured yet
|
||||||
|
|
||||||
// Convert rem to pixels for calculation
|
// Convert rem to pixels for calculation
|
||||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
const remToPx = parseFloat(
|
||||||
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
getComputedStyle(document.documentElement).fontSize,
|
||||||
|
);
|
||||||
|
const ITEM_WIDTH =
|
||||||
|
parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||||
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
||||||
|
|
||||||
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
||||||
@@ -501,7 +565,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
count: Math.ceil(visibleItems.length / itemsPerRow),
|
count: Math.ceil(visibleItems.length / itemsPerRow),
|
||||||
getScrollElement,
|
getScrollElement,
|
||||||
estimateSize: () => {
|
estimateSize: () => {
|
||||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
const remToPx = parseFloat(
|
||||||
|
getComputedStyle(document.documentElement).fontSize,
|
||||||
|
);
|
||||||
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
|
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
|
||||||
},
|
},
|
||||||
overscan: OVERSCAN,
|
overscan: OVERSCAN,
|
||||||
@@ -524,10 +590,19 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
|
|
||||||
// Re-measure virtualizer when zoom or items per row changes
|
// Re-measure virtualizer when zoom or items per row changes
|
||||||
// Also remeasure when items change (not just length) to handle item additions/removals
|
// Also remeasure when items change (not just length) to handle item additions/removals
|
||||||
const visibleItemsSignature = useMemo(() => visibleItems.map((item) => item.id).join(","), [visibleItems]);
|
const visibleItemsSignature = useMemo(
|
||||||
|
() => visibleItems.map((item) => item.id).join(","),
|
||||||
|
[visibleItems],
|
||||||
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
rowVirtualizer.measure();
|
rowVirtualizer.measure();
|
||||||
}, [zoomLevel, itemsPerRow, visibleItems.length, visibleItemsSignature, rowVirtualizer]);
|
}, [
|
||||||
|
zoomLevel,
|
||||||
|
itemsPerRow,
|
||||||
|
visibleItems.length,
|
||||||
|
visibleItemsSignature,
|
||||||
|
rowVirtualizer,
|
||||||
|
]);
|
||||||
|
|
||||||
// Cleanup highlight timeout on unmount
|
// Cleanup highlight timeout on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -559,7 +634,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
if (clickedPageId) {
|
if (clickedPageId) {
|
||||||
// Clicking directly on a page shouldn't initiate box selection
|
// Clicking directly on a page shouldn't initiate box selection
|
||||||
// but clear previous box selection if clicking outside current group
|
// but clear previous box selection if clicking outside current group
|
||||||
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
|
if (
|
||||||
|
boxSelectedPageIds.length > 0 &&
|
||||||
|
!boxSelectedPageIds.includes(clickedPageId)
|
||||||
|
) {
|
||||||
setBoxSelectedPageIds([]);
|
setBoxSelectedPageIds([]);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -600,7 +678,12 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
const pageBottom = pageRect.bottom - rect.top;
|
const pageBottom = pageRect.bottom - rect.top;
|
||||||
|
|
||||||
// Check if page intersects with selection box
|
// Check if page intersects with selection box
|
||||||
const intersects = !(pageRight < boxLeft || pageLeft > boxRight || pageBottom < boxTop || pageTop > boxBottom);
|
const intersects = !(
|
||||||
|
pageRight < boxLeft ||
|
||||||
|
pageLeft > boxRight ||
|
||||||
|
pageBottom < boxTop ||
|
||||||
|
pageTop > boxBottom
|
||||||
|
);
|
||||||
|
|
||||||
if (intersects) {
|
if (intersects) {
|
||||||
selectedIds.push(pageId);
|
selectedIds.push(pageId);
|
||||||
@@ -637,7 +720,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
if (getThumbnail) {
|
if (getThumbnail) {
|
||||||
const thumbnailData = getThumbnail();
|
const thumbnailData = getThumbnail();
|
||||||
if (thumbnailData?.src) {
|
if (thumbnailData?.src) {
|
||||||
setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation });
|
setDragPreview({
|
||||||
|
src: thumbnailData.src,
|
||||||
|
rotation: thumbnailData.rotation,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -692,7 +778,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
|
|
||||||
// Check if this page is box-selected
|
// Check if this page is box-selected
|
||||||
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
|
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
|
||||||
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
|
const pagesToDrag =
|
||||||
|
isBoxSelected && boxSelectedPageIds.length > 0
|
||||||
|
? boxSelectedPageIds
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Call reorder with page number and target index
|
// Call reorder with page number and target index
|
||||||
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
|
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
|
||||||
@@ -726,7 +815,9 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Calculate optimal width for centering
|
// Calculate optimal width for centering
|
||||||
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
const remToPx = parseFloat(
|
||||||
|
getComputedStyle(document.documentElement).fontSize,
|
||||||
|
);
|
||||||
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
||||||
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
||||||
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
||||||
@@ -814,9 +905,13 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onWheel={handleWheelWhileDragging}
|
onWheel={handleWheelWhileDragging}
|
||||||
>
|
>
|
||||||
{selectionBoxStyle && <div className={styles.selectionBox} style={selectionBoxStyle} />}
|
{selectionBoxStyle && (
|
||||||
|
<div className={styles.selectionBox} style={selectionBoxStyle} />
|
||||||
|
)}
|
||||||
|
|
||||||
{dropIndicatorStyle && <div className={styles.dropIndicator} style={dropIndicatorStyle} />}
|
{dropIndicatorStyle && (
|
||||||
|
<div className={styles.dropIndicator} style={dropIndicatorStyle} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.virtualRows}
|
className={styles.virtualRows}
|
||||||
@@ -828,7 +923,10 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
>
|
>
|
||||||
{virtualRows.map((virtualRow) => {
|
{virtualRows.map((virtualRow) => {
|
||||||
const startIndex = virtualRow.index * itemsPerRow;
|
const startIndex = virtualRow.index * itemsPerRow;
|
||||||
const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length);
|
const endIndex = Math.min(
|
||||||
|
startIndex + itemsPerRow,
|
||||||
|
visibleItems.length,
|
||||||
|
);
|
||||||
const rowItems = visibleItems.slice(startIndex, endIndex);
|
const rowItems = visibleItems.slice(startIndex, endIndex);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -877,11 +975,15 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
{activeId && (
|
{activeId && (
|
||||||
<div className={styles.dragOverlay}>
|
<div className={styles.dragOverlay}>
|
||||||
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
|
{boxSelectedPageIds.includes(activeId) &&
|
||||||
<div className={styles.dragOverlayBadge} style={{ zIndex: Z_INDEX_DRAG_BADGE }}>
|
boxSelectedPageIds.length > 1 && (
|
||||||
{boxSelectedPageIds.length}
|
<div
|
||||||
</div>
|
className={styles.dragOverlayBadge}
|
||||||
)}
|
style={{ zIndex: Z_INDEX_DRAG_BADGE }}
|
||||||
|
>
|
||||||
|
{boxSelectedPageIds.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{dragPreview ? (
|
{dragPreview ? (
|
||||||
<img
|
<img
|
||||||
src={dragPreview.src}
|
src={dragPreview.src}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
import React, {
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
import { ActionIcon, CheckboxIndicator } from "@mantine/core";
|
import { ActionIcon, CheckboxIndicator } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||||
@@ -6,7 +12,10 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
|||||||
import PushPinIcon from "@mui/icons-material/PushPin";
|
import PushPinIcon from "@mui/icons-material/PushPin";
|
||||||
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
|
||||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||||
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
import {
|
||||||
|
draggable,
|
||||||
|
dropTargetForElements,
|
||||||
|
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
|
||||||
|
|
||||||
import styles from "@app/components/pageEditor/PageEditor.module.css";
|
import styles from "@app/components/pageEditor/PageEditor.module.css";
|
||||||
import { useFileContext } from "@app/contexts/FileContext";
|
import { useFileContext } from "@app/contexts/FileContext";
|
||||||
@@ -35,7 +44,11 @@ interface FileThumbnailProps {
|
|||||||
onDeleteFile: (fileId: FileId) => void;
|
onDeleteFile: (fileId: FileId) => void;
|
||||||
onViewFile: (fileId: FileId) => void;
|
onViewFile: (fileId: FileId) => void;
|
||||||
onSetStatus: (status: string) => void;
|
onSetStatus: (status: string) => void;
|
||||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
onReorderFiles?: (
|
||||||
|
sourceFileId: FileId,
|
||||||
|
targetFileId: FileId,
|
||||||
|
selectedFileIds: FileId[],
|
||||||
|
) => void;
|
||||||
onDownloadFile?: (fileId: FileId) => void;
|
onDownloadFile?: (fileId: FileId) => void;
|
||||||
toolMode?: boolean;
|
toolMode?: boolean;
|
||||||
isSupported?: boolean;
|
isSupported?: boolean;
|
||||||
@@ -61,7 +74,9 @@ const FileThumbnail = ({
|
|||||||
// ---- Drag state ----
|
// ---- Drag state ----
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
|
const [actionsWidth, setActionsWidth] = useState<number | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
const [showActions, setShowActions] = useState(false);
|
const [showActions, setShowActions] = useState(false);
|
||||||
|
|
||||||
// Resolve the actual File object for pin/unpin operations
|
// Resolve the actual File object for pin/unpin operations
|
||||||
@@ -80,7 +95,10 @@ const FileThumbnail = ({
|
|||||||
// Fallback: attempt to download using the File object if provided
|
// Fallback: attempt to download using the File object if provided
|
||||||
const maybeFile = (file as unknown as { file?: File }).file;
|
const maybeFile = (file as unknown as { file?: File }).file;
|
||||||
if (maybeFile instanceof File) {
|
if (maybeFile instanceof File) {
|
||||||
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || "download" });
|
void downloadFile({
|
||||||
|
data: maybeFile,
|
||||||
|
filename: maybeFile.name || file.name || "download",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +164,8 @@ const FileThumbnail = ({
|
|||||||
// Update dropdown width on resize
|
// Update dropdown width on resize
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const update = () => {
|
const update = () => {
|
||||||
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
|
if (dragElementRef.current)
|
||||||
|
setActionsWidth(dragElementRef.current.offsetWidth);
|
||||||
};
|
};
|
||||||
update();
|
update();
|
||||||
window.addEventListener("resize", update);
|
window.addEventListener("resize", update);
|
||||||
@@ -177,7 +196,9 @@ const FileThumbnail = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("touchstart", handleTouchStart, { passive: true });
|
document.addEventListener("touchstart", handleTouchStart, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("touchstart", handleTouchStart);
|
document.removeEventListener("touchstart", handleTouchStart);
|
||||||
@@ -208,7 +229,9 @@ const FileThumbnail = ({
|
|||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
>
|
>
|
||||||
{/* Header bar */}
|
{/* Header bar */}
|
||||||
<div className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}>
|
<div
|
||||||
|
className={`${styles.header} ${isSelected ? styles.headerSelected : styles.headerResting}`}
|
||||||
|
>
|
||||||
{/* Logo/checkbox area */}
|
{/* Logo/checkbox area */}
|
||||||
<div className={styles.logoMark}>
|
<div className={styles.logoMark}>
|
||||||
{isSupported ? (
|
{isSupported ? (
|
||||||
@@ -225,7 +248,10 @@ const FileThumbnail = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Centered index */}
|
{/* Centered index */}
|
||||||
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
|
<div
|
||||||
|
className={styles.headerIndex}
|
||||||
|
aria-label={`Position ${index + 1}`}
|
||||||
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -245,7 +271,11 @@ const FileThumbnail = ({
|
|||||||
|
|
||||||
{/* Actions overlay */}
|
{/* Actions overlay */}
|
||||||
{showActions && (
|
{showActions && (
|
||||||
<div className={styles.actionsOverlay} style={{ width: actionsWidth }} onClick={(e) => e.stopPropagation()}>
|
<div
|
||||||
|
className={styles.actionsOverlay}
|
||||||
|
style={{ width: actionsWidth }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
className={styles.actionRow}
|
className={styles.actionRow}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -261,7 +291,11 @@ const FileThumbnail = ({
|
|||||||
setShowActions(false);
|
setShowActions(false);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
|
{isPinned ? (
|
||||||
|
<PushPinIcon fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<PushPinOutlinedIcon fontSize="small" />
|
||||||
|
)}
|
||||||
<span>{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}</span>
|
<span>{isPinned ? t("unpin", "Unpin") : t("pin", "Pin")}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||||
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
|
||||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||||
import { useNavigationGuard, useNavigationState } from "@app/contexts/NavigationContext";
|
import {
|
||||||
|
useNavigationGuard,
|
||||||
|
useNavigationState,
|
||||||
|
} from "@app/contexts/NavigationContext";
|
||||||
import { usePageEditor } from "@app/contexts/PageEditorContext";
|
import { usePageEditor } from "@app/contexts/PageEditorContext";
|
||||||
import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
|
import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
|
||||||
// Thumbnail generation is now handled by individual PageThumbnail components
|
// Thumbnail generation is now handled by individual PageThumbnail components
|
||||||
@@ -35,8 +38,11 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
const { actions } = useFileActions();
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
// Navigation guard for unsaved changes
|
// Navigation guard for unsaved changes
|
||||||
const { setHasUnsavedChanges, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers } =
|
const {
|
||||||
useNavigationGuard();
|
setHasUnsavedChanges,
|
||||||
|
registerNavigationWarningHandlers,
|
||||||
|
unregisterNavigationWarningHandlers,
|
||||||
|
} = useNavigationGuard();
|
||||||
const navigationState = useNavigationState();
|
const navigationState = useNavigationState();
|
||||||
|
|
||||||
// Get PageEditor coordination functions
|
// Get PageEditor coordination functions
|
||||||
@@ -56,7 +62,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
const handleVisibleItemsChange = useCallback((items: PDFPage[]) => {
|
const handleVisibleItemsChange = useCallback((items: PDFPage[]) => {
|
||||||
setVisiblePageIds((prev) => {
|
setVisiblePageIds((prev) => {
|
||||||
const ids = items.map((item) => item.id);
|
const ids = items.map((item) => item.id);
|
||||||
if (prev.length === ids.length && prev.every((id, index) => id === ids[index])) {
|
if (
|
||||||
|
prev.length === ids.length &&
|
||||||
|
prev.every((id, index) => id === ids[index])
|
||||||
|
) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
return ids;
|
return ids;
|
||||||
@@ -97,7 +106,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
const filesSignature = selectors.getFilesSignature();
|
const filesSignature = selectors.getFilesSignature();
|
||||||
|
|
||||||
const fileObjectsRef = useRef(new Map<FileId, any>());
|
const fileObjectsRef = useRef(new Map<FileId, any>());
|
||||||
const gridItemRefsRef = useRef<React.MutableRefObject<Map<string, HTMLDivElement>> | null>(null);
|
const gridItemRefsRef = useRef<React.MutableRefObject<
|
||||||
|
Map<string, HTMLDivElement>
|
||||||
|
> | null>(null);
|
||||||
|
|
||||||
const pageEditorFiles = useMemo(() => {
|
const pageEditorFiles = useMemo(() => {
|
||||||
const cache = fileObjectsRef.current;
|
const cache = fileObjectsRef.current;
|
||||||
@@ -163,14 +174,15 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
const initialDocument = useInitialPageDocument();
|
const initialDocument = useInitialPageDocument();
|
||||||
const { document: mergedPdfDocument } = usePageDocument();
|
const { document: mergedPdfDocument } = usePageDocument();
|
||||||
|
|
||||||
const { setEditedDocument, displayDocument, getEditedDocument } = useEditedDocumentState({
|
const { setEditedDocument, displayDocument, getEditedDocument } =
|
||||||
initialDocument,
|
useEditedDocumentState({
|
||||||
mergedPdfDocument,
|
initialDocument,
|
||||||
reorderedPages,
|
mergedPdfDocument,
|
||||||
clearReorderedPages,
|
reorderedPages,
|
||||||
fileOrder,
|
clearReorderedPages,
|
||||||
updateCurrentPages,
|
fileOrder,
|
||||||
});
|
updateCurrentPages,
|
||||||
|
});
|
||||||
|
|
||||||
const displayDocumentRef = useRef(displayDocument);
|
const displayDocumentRef = useRef(displayDocument);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -184,7 +196,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
|
|
||||||
const loadedCount = doc.pages.filter((p) => p.thumbnail).length;
|
const loadedCount = doc.pages.filter((p) => p.thumbnail).length;
|
||||||
const pending = thumbnailRequestsRef.current.size;
|
const pending = thumbnailRequestsRef.current.size;
|
||||||
const MAX_CONCURRENT_THUMBNAILS = loadedCount < 8 ? 1 : doc.totalPages < 20 ? 3 : doc.totalPages < 50 ? 5 : 8;
|
const MAX_CONCURRENT_THUMBNAILS =
|
||||||
|
loadedCount < 8
|
||||||
|
? 1
|
||||||
|
: doc.totalPages < 20
|
||||||
|
? 3
|
||||||
|
: doc.totalPages < 50
|
||||||
|
? 5
|
||||||
|
: 8;
|
||||||
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
|
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
|
||||||
if (available === 0) return;
|
if (available === 0) return;
|
||||||
|
|
||||||
@@ -214,7 +233,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
if (pageIndex === -1) return prev;
|
if (pageIndex === -1) return prev;
|
||||||
|
|
||||||
const updated = [...prev.pages];
|
const updated = [...prev.pages];
|
||||||
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache };
|
updated[pageIndex] = {
|
||||||
|
...prev.pages[pageIndex],
|
||||||
|
thumbnail: cache,
|
||||||
|
};
|
||||||
return { ...prev, pages: updated };
|
return { ...prev, pages: updated };
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -230,7 +252,11 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
thumbnailRequestsRef.current.add(pageId);
|
thumbnailRequestsRef.current.add(pageId);
|
||||||
requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber)
|
requestThumbnail(
|
||||||
|
pageId,
|
||||||
|
file,
|
||||||
|
page.originalPageNumber || page.pageNumber,
|
||||||
|
)
|
||||||
.then((thumbnail) => {
|
.then((thumbnail) => {
|
||||||
if (thumbnail) {
|
if (thumbnail) {
|
||||||
setEditedDocument((prev) => {
|
setEditedDocument((prev) => {
|
||||||
@@ -277,11 +303,17 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_VISIBLE_PAGE_COUNT = 8;
|
const INITIAL_VISIBLE_PAGE_COUNT = 8;
|
||||||
const initialIds = displayDocument.pages.slice(0, INITIAL_VISIBLE_PAGE_COUNT).map((page) => page.id);
|
const initialIds = displayDocument.pages
|
||||||
|
.slice(0, INITIAL_VISIBLE_PAGE_COUNT)
|
||||||
|
.map((page) => page.id);
|
||||||
|
|
||||||
queueThumbnailRequestsForPages(initialIds);
|
queueThumbnailRequestsForPages(initialIds);
|
||||||
lastInitialDocumentSignatureRef.current = signature;
|
lastInitialDocumentSignatureRef.current = signature;
|
||||||
}, [displayDocumentId, displayDocumentLength, queueThumbnailRequestsForPages]);
|
}, [
|
||||||
|
displayDocumentId,
|
||||||
|
displayDocumentLength,
|
||||||
|
queueThumbnailRequestsForPages,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setVisiblePageIds([]);
|
setVisiblePageIds([]);
|
||||||
@@ -340,7 +372,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
// Grid container ref for positioning split indicators
|
// Grid container ref for positioning split indicators
|
||||||
const gridContainerRef = useRef<HTMLDivElement>(null);
|
const gridContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { canUndo, canRedo, executeCommandWithTracking, handleUndo, handleRedo, clearUndoHistory } = useUndoManagerState({
|
const {
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
executeCommandWithTracking,
|
||||||
|
handleUndo,
|
||||||
|
handleRedo,
|
||||||
|
clearUndoHistory,
|
||||||
|
} = useUndoManagerState({
|
||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -402,7 +441,12 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
return () => unregisterNavigationWarningHandlers();
|
return () => unregisterNavigationWarningHandlers();
|
||||||
}, [applyChanges, onExportAll, registerNavigationWarningHandlers, unregisterNavigationWarningHandlers]);
|
}, [
|
||||||
|
applyChanges,
|
||||||
|
onExportAll,
|
||||||
|
registerNavigationWarningHandlers,
|
||||||
|
unregisterNavigationWarningHandlers,
|
||||||
|
]);
|
||||||
|
|
||||||
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
|
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
|
||||||
const selectedPageCount = selectedPageIds.length;
|
const selectedPageCount = selectedPageIds.length;
|
||||||
@@ -556,7 +600,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
zoomLevelParam?: number,
|
zoomLevelParam?: number,
|
||||||
) => {
|
) => {
|
||||||
gridItemRefsRef.current = refs;
|
gridItemRefsRef.current = refs;
|
||||||
const fileColorIndex = page.originalFileId ? (fileColorIndexMap.get(page.originalFileId) ?? 0) : 0;
|
const fileColorIndex = page.originalFileId
|
||||||
|
? (fileColorIndexMap.get(page.originalFileId) ?? 0)
|
||||||
|
: 0;
|
||||||
const isBoxSelected = boxSelectedIds.includes(page.id);
|
const isBoxSelected = boxSelectedIds.includes(page.id);
|
||||||
return (
|
return (
|
||||||
<PageThumbnail
|
<PageThumbnail
|
||||||
@@ -631,19 +677,21 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
>
|
>
|
||||||
<LoadingOverlay visible={globalProcessing && !initialDocument} />
|
<LoadingOverlay visible={globalProcessing && !initialDocument} />
|
||||||
|
|
||||||
{!initialDocument && !globalProcessing && selectedFileIds.length === 0 && (
|
{!initialDocument &&
|
||||||
<Center h="100%">
|
!globalProcessing &&
|
||||||
<Stack align="center" gap="md">
|
selectedFileIds.length === 0 && (
|
||||||
<Text size="lg" c="dimmed">
|
<Center h="100%">
|
||||||
📄
|
<Stack align="center" gap="md">
|
||||||
</Text>
|
<Text size="lg" c="dimmed">
|
||||||
<Text c="dimmed">No PDF files loaded</Text>
|
📄
|
||||||
<Text size="sm" c="dimmed">
|
</Text>
|
||||||
Add files to start editing pages
|
<Text c="dimmed">No PDF files loaded</Text>
|
||||||
</Text>
|
<Text size="sm" c="dimmed">
|
||||||
</Stack>
|
Add files to start editing pages
|
||||||
</Center>
|
</Text>
|
||||||
)}
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
{!initialDocument && globalProcessing && (
|
{!initialDocument && globalProcessing && (
|
||||||
<Box p={0}>
|
<Box p={0}>
|
||||||
@@ -653,7 +701,13 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{displayDocument && (
|
{displayDocument && (
|
||||||
<Box ref={gridContainerRef} p={0} pt="2rem" pb="4rem" style={{ position: "relative" }}>
|
<Box
|
||||||
|
ref={gridContainerRef}
|
||||||
|
p={0}
|
||||||
|
pt="2rem"
|
||||||
|
pb="4rem"
|
||||||
|
style={{ position: "relative" }}
|
||||||
|
>
|
||||||
{/* Split Lines Overlay */}
|
{/* Split Lines Overlay */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -674,7 +728,10 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const containerRect = containerEl.getBoundingClientRect();
|
const containerRect = containerEl.getBoundingClientRect();
|
||||||
const splitIndexes = convertSplitPageIdsToIndexes(displayDocument, splitPositions);
|
const splitIndexes = convertSplitPageIdsToIndexes(
|
||||||
|
displayDocument,
|
||||||
|
splitPositions,
|
||||||
|
);
|
||||||
|
|
||||||
return Array.from(splitIndexes).map((position) => {
|
return Array.from(splitIndexes).map((position) => {
|
||||||
const currentPage = displayedPages[position];
|
const currentPage = displayedPages[position];
|
||||||
@@ -695,7 +752,9 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
|
|||||||
const nextEl = refsMap.get(nextPage.id);
|
const nextEl = refsMap.get(nextPage.id);
|
||||||
if (nextEl) {
|
if (nextEl) {
|
||||||
const nextRect = nextEl.getBoundingClientRect();
|
const nextRect = nextEl.getBoundingClientRect();
|
||||||
const sameRow = Math.abs(nextRect.top - currentRect.top) < currentRect.height / 2;
|
const sameRow =
|
||||||
|
Math.abs(nextRect.top - currentRect.top) <
|
||||||
|
currentRect.height / 2;
|
||||||
if (sameRow) {
|
if (sameRow) {
|
||||||
lineLeft = (currentRect.right + nextRect.left) / 2;
|
lineLeft = (currentRect.right + nextRect.left) / 2;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -60,22 +60,31 @@ const PageEditorControls = ({
|
|||||||
|
|
||||||
const totalPages = displayDocument.pages.length;
|
const totalPages = displayDocument.pages.length;
|
||||||
const selectedValidPageIds = displayDocument.pages
|
const selectedValidPageIds = displayDocument.pages
|
||||||
.filter((page, index) => selectedPageIds.includes(page.id) && index < totalPages - 1)
|
.filter(
|
||||||
|
(page, index) =>
|
||||||
|
selectedPageIds.includes(page.id) && index < totalPages - 1,
|
||||||
|
)
|
||||||
.map((page) => page.id);
|
.map((page) => page.id);
|
||||||
|
|
||||||
if (selectedValidPageIds.length === 0) {
|
if (selectedValidPageIds.length === 0) {
|
||||||
return "Split Selected";
|
return "Split Selected";
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSplitsCount = selectedValidPageIds.filter((id) => splitPositions.has(id)).length;
|
const existingSplitsCount = selectedValidPageIds.filter((id) =>
|
||||||
|
splitPositions.has(id),
|
||||||
|
).length;
|
||||||
const noSplitsCount = selectedValidPageIds.length - existingSplitsCount;
|
const noSplitsCount = selectedValidPageIds.length - existingSplitsCount;
|
||||||
|
|
||||||
const willRemoveSplits = existingSplitsCount > noSplitsCount;
|
const willRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||||
|
|
||||||
if (willRemoveSplits) {
|
if (willRemoveSplits) {
|
||||||
return existingSplitsCount === selectedValidPageIds.length ? "Remove All Selected Splits" : "Remove Selected Splits";
|
return existingSplitsCount === selectedValidPageIds.length
|
||||||
|
? "Remove All Selected Splits"
|
||||||
|
: "Remove Selected Splits";
|
||||||
} else {
|
} else {
|
||||||
return existingSplitsCount === 0 ? "Split Selected" : "Complete Selected Splits";
|
return existingSplitsCount === 0
|
||||||
|
? "Split Selected"
|
||||||
|
: "Complete Selected Splits";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,7 +137,11 @@ const PageEditorControls = ({
|
|||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={!canUndo}
|
disabled={!canUndo}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: canUndo ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
style={{
|
||||||
|
color: canUndo
|
||||||
|
? "var(--right-rail-icon)"
|
||||||
|
: "var(--right-rail-icon-disabled)",
|
||||||
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -140,7 +153,11 @@ const PageEditorControls = ({
|
|||||||
onClick={onRedo}
|
onClick={onRedo}
|
||||||
disabled={!canRedo}
|
disabled={!canRedo}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: canRedo ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
style={{
|
||||||
|
color: canRedo
|
||||||
|
? "var(--right-rail-icon)"
|
||||||
|
: "var(--right-rail-icon-disabled)",
|
||||||
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -148,7 +165,14 @@ const PageEditorControls = ({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div style={{ width: 1, height: 28, backgroundColor: "var(--mantine-color-gray-3)", margin: "0 8px" }} />
|
<div
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: 28,
|
||||||
|
backgroundColor: "var(--mantine-color-gray-3)",
|
||||||
|
margin: "0 8px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Page Operations */}
|
{/* Page Operations */}
|
||||||
<Tooltip label="Rotate Selected Left">
|
<Tooltip label="Rotate Selected Left">
|
||||||
@@ -156,7 +180,12 @@ const PageEditorControls = ({
|
|||||||
onClick={() => onRotate("left")}
|
onClick={() => onRotate("left")}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
style={{
|
||||||
|
color:
|
||||||
|
selectedPageIds.length > 0
|
||||||
|
? "var(--right-rail-icon)"
|
||||||
|
: "var(--right-rail-icon-disabled)",
|
||||||
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -168,7 +197,12 @@ const PageEditorControls = ({
|
|||||||
onClick={() => onRotate("right")}
|
onClick={() => onRotate("right")}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
style={{
|
||||||
|
color:
|
||||||
|
selectedPageIds.length > 0
|
||||||
|
? "var(--right-rail-icon)"
|
||||||
|
: "var(--right-rail-icon-disabled)",
|
||||||
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -180,7 +214,12 @@ const PageEditorControls = ({
|
|||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
style={{
|
||||||
|
color:
|
||||||
|
selectedPageIds.length > 0
|
||||||
|
? "var(--right-rail-icon)"
|
||||||
|
: "var(--right-rail-icon-disabled)",
|
||||||
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -192,7 +231,12 @@ const PageEditorControls = ({
|
|||||||
onClick={onSplit}
|
onClick={onSplit}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
style={{
|
||||||
|
color:
|
||||||
|
selectedPageIds.length > 0
|
||||||
|
? "var(--right-rail-icon)"
|
||||||
|
: "var(--right-rail-icon-disabled)",
|
||||||
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -204,7 +248,12 @@ const PageEditorControls = ({
|
|||||||
onClick={onPageBreak}
|
onClick={onPageBreak}
|
||||||
disabled={selectedPageIds.length === 0}
|
disabled={selectedPageIds.length === 0}
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
style={{ color: selectedPageIds.length > 0 ? "var(--right-rail-icon)" : "var(--right-rail-icon-disabled)" }}
|
style={{
|
||||||
|
color:
|
||||||
|
selectedPageIds.length > 0
|
||||||
|
? "var(--right-rail-icon)"
|
||||||
|
: "var(--right-rail-icon-disabled)",
|
||||||
|
}}
|
||||||
radius="md"
|
radius="md"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ export default function PageSelectByNumberButton({
|
|||||||
updatePagesFromCSV,
|
updatePagesFromCSV,
|
||||||
}: PageSelectByNumberButtonProps) {
|
}: PageSelectByNumberButtonProps) {
|
||||||
return (
|
return (
|
||||||
<Tooltip content={label} position="left" offset={12} arrow portalTarget={document.body}>
|
<Tooltip
|
||||||
|
content={label}
|
||||||
|
position="left"
|
||||||
|
offset={12}
|
||||||
|
arrow
|
||||||
|
portalTarget={document.body}
|
||||||
|
>
|
||||||
<div className={`right-rail-fade enter`}>
|
<div className={`right-rail-fade enter`}>
|
||||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import React, { useCallback, useState, useEffect, useRef, useMemo } from "react";
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useMemo,
|
||||||
|
} from "react";
|
||||||
import { Text, Checkbox } from "@mantine/core";
|
import { Text, Checkbox } from "@mantine/core";
|
||||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
||||||
@@ -12,7 +18,9 @@ import { PDFPage, PDFDocument } from "@app/types/pageEditor";
|
|||||||
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
|
||||||
import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors";
|
import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors";
|
||||||
import styles from "@app/components/pageEditor/PageEditor.module.css";
|
import styles from "@app/components/pageEditor/PageEditor.module.css";
|
||||||
import HoverActionMenu, { HoverAction } from "@app/components/shared/HoverActionMenu";
|
import HoverActionMenu, {
|
||||||
|
HoverAction,
|
||||||
|
} from "@app/components/shared/HoverActionMenu";
|
||||||
import { StirlingFileStub } from "@app/types/fileContext";
|
import { StirlingFileStub } from "@app/types/fileContext";
|
||||||
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
import { PrivateContent } from "@app/components/shared/PrivateContent";
|
||||||
|
|
||||||
@@ -31,20 +39,34 @@ interface PageThumbnailProps {
|
|||||||
justMoved?: boolean;
|
justMoved?: boolean;
|
||||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||||
dragHandleProps?: any;
|
dragHandleProps?: any;
|
||||||
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
onReorderPages: (
|
||||||
|
sourcePageNumber: number,
|
||||||
|
targetIndex: number,
|
||||||
|
selectedPageIds?: string[],
|
||||||
|
) => void;
|
||||||
onTogglePage: (pageId: string) => void;
|
onTogglePage: (pageId: string) => void;
|
||||||
onAnimateReorder: () => void;
|
onAnimateReorder: () => void;
|
||||||
onExecuteCommand: (command: { execute: () => void }) => void;
|
onExecuteCommand: (command: { execute: () => void }) => void;
|
||||||
onSetStatus: (status: string) => void;
|
onSetStatus: (status: string) => void;
|
||||||
onSetMovingPage: (page: number | null) => void;
|
onSetMovingPage: (page: number | null) => void;
|
||||||
onDeletePage: (pageNumber: number) => void;
|
onDeletePage: (pageNumber: number) => void;
|
||||||
createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void };
|
createRotateCommand: (
|
||||||
|
pageIds: string[],
|
||||||
|
rotation: number,
|
||||||
|
) => { execute: () => void };
|
||||||
createDeleteCommand: (pageIds: string[]) => { execute: () => void };
|
createDeleteCommand: (pageIds: string[]) => { execute: () => void };
|
||||||
createSplitCommand: (pageId: string, pageNumber: number) => { execute: () => void };
|
createSplitCommand: (
|
||||||
|
pageId: string,
|
||||||
|
pageNumber: number,
|
||||||
|
) => { execute: () => void };
|
||||||
pdfDocument: PDFDocument;
|
pdfDocument: PDFDocument;
|
||||||
setPdfDocument: (doc: PDFDocument) => void;
|
setPdfDocument: (doc: PDFDocument) => void;
|
||||||
splitPositions: Set<string>;
|
splitPositions: Set<string>;
|
||||||
onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void;
|
onInsertFiles?: (
|
||||||
|
files: File[] | StirlingFileStub[],
|
||||||
|
insertAfterPage: number,
|
||||||
|
isFromStorage?: boolean,
|
||||||
|
) => void;
|
||||||
zoomLevel?: number;
|
zoomLevel?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,15 +99,22 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
justMoved = false,
|
justMoved = false,
|
||||||
}: PageThumbnailProps) => {
|
}: PageThumbnailProps) => {
|
||||||
const pageIndex = page.pageNumber - 1;
|
const pageIndex = page.pageNumber - 1;
|
||||||
const isSelected = Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false;
|
const isSelected = Array.isArray(selectedPageIds)
|
||||||
|
? selectedPageIds.includes(page.id)
|
||||||
|
: false;
|
||||||
|
|
||||||
const [isMouseDown, setIsMouseDown] = useState(false);
|
const [isMouseDown, setIsMouseDown] = useState(false);
|
||||||
const [mouseStartPos, setMouseStartPos] = useState<{ x: number; y: number } | null>(null);
|
const [mouseStartPos, setMouseStartPos] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const lastClickTimeRef = useRef<number>(0);
|
const lastClickTimeRef = useRef<number>(0);
|
||||||
|
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(
|
||||||
|
page.thumbnail,
|
||||||
|
);
|
||||||
const elementRef = useRef<HTMLDivElement | null>(null);
|
const elementRef = useRef<HTMLDivElement | null>(null);
|
||||||
const { openFilesModal } = useFilesModalContext();
|
const { openFilesModal } = useFilesModalContext();
|
||||||
|
|
||||||
@@ -95,7 +124,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
// Calculate document aspect ratio from first non-blank page
|
// Calculate document aspect ratio from first non-blank page
|
||||||
const getDocumentAspectRatio = useCallback(() => {
|
const getDocumentAspectRatio = useCallback(() => {
|
||||||
// Find first non-blank page with a thumbnail to get aspect ratio
|
// Find first non-blank page with a thumbnail to get aspect ratio
|
||||||
const firstRealPage = pdfDocument.pages.find((p) => !p.isBlankPage && p.thumbnail);
|
const firstRealPage = pdfDocument.pages.find(
|
||||||
|
(p) => !p.isBlankPage && p.thumbnail,
|
||||||
|
);
|
||||||
if (firstRealPage?.thumbnail) {
|
if (firstRealPage?.thumbnail) {
|
||||||
// Try to get aspect ratio from an actual thumbnail image
|
// Try to get aspect ratio from an actual thumbnail image
|
||||||
// For now, default to A4 but could be enhanced to measure image dimensions
|
// For now, default to A4 but could be enhanced to measure image dimensions
|
||||||
@@ -139,7 +170,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
onExecuteCommand(command);
|
onExecuteCommand(command);
|
||||||
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
||||||
},
|
},
|
||||||
[page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand],
|
[
|
||||||
|
page.id,
|
||||||
|
page.pageNumber,
|
||||||
|
onExecuteCommand,
|
||||||
|
onSetStatus,
|
||||||
|
createRotateCommand,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRotateRight = useCallback(
|
const handleRotateRight = useCallback(
|
||||||
@@ -150,7 +187,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
onExecuteCommand(command);
|
onExecuteCommand(command);
|
||||||
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
||||||
},
|
},
|
||||||
[page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand],
|
[
|
||||||
|
page.id,
|
||||||
|
page.pageNumber,
|
||||||
|
onExecuteCommand,
|
||||||
|
onSetStatus,
|
||||||
|
createRotateCommand,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
@@ -174,7 +217,13 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
const action = hasSplit ? "removed" : "added";
|
const action = hasSplit ? "removed" : "added";
|
||||||
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
|
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
|
||||||
},
|
},
|
||||||
[pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand],
|
[
|
||||||
|
pageIndex,
|
||||||
|
splitPositions,
|
||||||
|
onExecuteCommand,
|
||||||
|
onSetStatus,
|
||||||
|
createSplitCommand,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleInsertFileAfter = useCallback(
|
const handleInsertFileAfter = useCallback(
|
||||||
@@ -185,7 +234,11 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
// Open file manager modal with custom handler for page insertion
|
// Open file manager modal with custom handler for page insertion
|
||||||
openFilesModal({
|
openFilesModal({
|
||||||
insertAfterPage: page.pageNumber,
|
insertAfterPage: page.pageNumber,
|
||||||
customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => {
|
customHandler: (
|
||||||
|
files: File[] | StirlingFileStub[],
|
||||||
|
insertAfterPage?: number,
|
||||||
|
isFromStorage?: boolean,
|
||||||
|
) => {
|
||||||
if (insertAfterPage !== undefined) {
|
if (insertAfterPage !== undefined) {
|
||||||
onInsertFiles(files, insertAfterPage, isFromStorage);
|
onInsertFiles(files, insertAfterPage, isFromStorage);
|
||||||
}
|
}
|
||||||
@@ -242,7 +295,15 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
setIsMouseDown(false);
|
setIsMouseDown(false);
|
||||||
setMouseStartPos(null);
|
setMouseStartPos(null);
|
||||||
},
|
},
|
||||||
[isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage],
|
[
|
||||||
|
isMouseDown,
|
||||||
|
mouseStartPos,
|
||||||
|
isDragging,
|
||||||
|
page.id,
|
||||||
|
isBoxSelected,
|
||||||
|
clearBoxSelection,
|
||||||
|
onTogglePage,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback(() => {
|
||||||
@@ -251,7 +312,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
setIsHovered(false);
|
setIsHovered(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fileColorBorder = page.isBlankPage ? "transparent" : getFileColorWithOpacity(fileColorIndex, 0.3);
|
const fileColorBorder = page.isBlankPage
|
||||||
|
? "transparent"
|
||||||
|
: getFileColorWithOpacity(fileColorIndex, 0.3);
|
||||||
|
|
||||||
// Spread dragHandleProps but use our merged ref
|
// Spread dragHandleProps but use our merged ref
|
||||||
const { ref: _, ...restDragProps } = dragHandleProps || {};
|
const { ref: _, ...restDragProps } = dragHandleProps || {};
|
||||||
@@ -420,7 +483,9 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
backgroundColor: "var(--mantine-color-gray-1)",
|
backgroundColor: "var(--mantine-color-gray-1)",
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
boxShadow: page.isBlankPage ? "none" : `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
|
boxShadow: page.isBlankPage
|
||||||
|
? "none"
|
||||||
|
: `0 0 ${4 + 4 * zoomLevel}px 3px ${fileColorBorder}`,
|
||||||
padding: 4,
|
padding: 4,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
+20
-5
@@ -98,7 +98,10 @@ const AdvancedSelectionPanel = ({
|
|||||||
<Flex direction="row" mb="xs" wrap="wrap">
|
<Flex direction="row" mb="xs" wrap="wrap">
|
||||||
<SelectPages
|
<SelectPages
|
||||||
title={t("bulkSelection.firstNPages.title", "First N Pages")}
|
title={t("bulkSelection.firstNPages.title", "First N Pages")}
|
||||||
placeholder={t("bulkSelection.firstNPages.placeholder", "Number of pages")}
|
placeholder={t(
|
||||||
|
"bulkSelection.firstNPages.placeholder",
|
||||||
|
"Number of pages",
|
||||||
|
)}
|
||||||
onApply={handleFirstNApply}
|
onApply={handleFirstNApply}
|
||||||
maxPages={maxPages}
|
maxPages={maxPages}
|
||||||
validationFn={validatePositiveNumber}
|
validationFn={validatePositiveNumber}
|
||||||
@@ -113,12 +116,18 @@ const AdvancedSelectionPanel = ({
|
|||||||
isRange={true}
|
isRange={true}
|
||||||
rangeEndValue={rangeEnd}
|
rangeEndValue={rangeEnd}
|
||||||
onRangeEndChange={handleRangeEndChange}
|
onRangeEndChange={handleRangeEndChange}
|
||||||
rangeEndPlaceholder={t("bulkSelection.range.toPlaceholder", "To")}
|
rangeEndPlaceholder={t(
|
||||||
|
"bulkSelection.range.toPlaceholder",
|
||||||
|
"To",
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SelectPages
|
<SelectPages
|
||||||
title={t("bulkSelection.lastNPages.title", "Last N Pages")}
|
title={t("bulkSelection.lastNPages.title", "Last N Pages")}
|
||||||
placeholder={t("bulkSelection.lastNPages.placeholder", "Number of pages")}
|
placeholder={t(
|
||||||
|
"bulkSelection.lastNPages.placeholder",
|
||||||
|
"Number of pages",
|
||||||
|
)}
|
||||||
onApply={handleLastNApply}
|
onApply={handleLastNApply}
|
||||||
maxPages={maxPages}
|
maxPages={maxPages}
|
||||||
validationFn={validatePositiveNumber}
|
validationFn={validatePositiveNumber}
|
||||||
@@ -126,14 +135,20 @@ const AdvancedSelectionPanel = ({
|
|||||||
|
|
||||||
<SelectPages
|
<SelectPages
|
||||||
title={t("bulkSelection.everyNthPage.title", "Every Nth Page")}
|
title={t("bulkSelection.everyNthPage.title", "Every Nth Page")}
|
||||||
placeholder={t("bulkSelection.everyNthPage.placeholder", "Step size")}
|
placeholder={t(
|
||||||
|
"bulkSelection.everyNthPage.placeholder",
|
||||||
|
"Step size",
|
||||||
|
)}
|
||||||
onApply={handleEveryNthApply}
|
onApply={handleEveryNthApply}
|
||||||
maxPages={maxPages}
|
maxPages={maxPages}
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* Operators row at bottom */}
|
{/* Operators row at bottom */}
|
||||||
<OperatorsSection csvInput={csvInput} onInsertOperator={insertOperator} />
|
<OperatorsSection
|
||||||
|
csvInput={csvInput}
|
||||||
|
onInsertOperator={insertOperator}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ interface OperatorsSectionProps {
|
|||||||
onInsertOperator: (op: LogicalOperator) => void;
|
onInsertOperator: (op: LogicalOperator) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => {
|
const OperatorsSection = ({
|
||||||
|
csvInput,
|
||||||
|
onInsertOperator,
|
||||||
|
}: OperatorsSectionProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -93,7 +93,12 @@ const SelectPages = ({
|
|||||||
error={Boolean(error)}
|
error={Boolean(error)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" className={classes.applyButton} onClick={handleApply} disabled={isDisabled}>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className={classes.applyButton}
|
||||||
|
onClick={handleApply}
|
||||||
|
disabled={isDisabled}
|
||||||
|
>
|
||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ interface SelectedPagesDisplayProps {
|
|||||||
syntaxError: string | null;
|
syntaxError: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectedPagesDisplay = ({ selectedPageIds, displayDocument, syntaxError }: SelectedPagesDisplayProps) => {
|
const SelectedPagesDisplay = ({
|
||||||
|
selectedPageIds,
|
||||||
|
displayDocument,
|
||||||
|
syntaxError,
|
||||||
|
}: SelectedPagesDisplayProps) => {
|
||||||
if (selectedPageIds.length === 0 && !syntaxError) {
|
if (selectedPageIds.length === 0 && !syntaxError) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,15 @@ export class RotatePageCommand extends DOMCommand {
|
|||||||
|
|
||||||
this.setDocument({
|
this.setDocument({
|
||||||
...currentDoc,
|
...currentDoc,
|
||||||
pages: currentDoc.pages.map((p) => (p.id === this.pageId ? { ...p, rotation: newRotation } : p)),
|
pages: currentDoc.pages.map((p) =>
|
||||||
|
p.id === this.pageId ? { ...p, rotation: newRotation } : p,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also update DOM immediately for CSS animation on currently-mounted pages
|
// Also update DOM immediately for CSS animation on currently-mounted pages
|
||||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
const pageElement = document.querySelector(
|
||||||
|
`[data-page-id="${this.pageId}"]`,
|
||||||
|
);
|
||||||
if (pageElement) {
|
if (pageElement) {
|
||||||
const img = pageElement.querySelector("img");
|
const img = pageElement.querySelector("img");
|
||||||
if (img) img.style.transform = `rotate(${newRotation}deg)`;
|
if (img) img.style.transform = `rotate(${newRotation}deg)`;
|
||||||
@@ -54,10 +58,14 @@ export class RotatePageCommand extends DOMCommand {
|
|||||||
|
|
||||||
this.setDocument({
|
this.setDocument({
|
||||||
...currentDoc,
|
...currentDoc,
|
||||||
pages: currentDoc.pages.map((p) => (p.id === this.pageId ? { ...p, rotation: this.originalRotation! } : p)),
|
pages: currentDoc.pages.map((p) =>
|
||||||
|
p.id === this.pageId ? { ...p, rotation: this.originalRotation! } : p,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
|
const pageElement = document.querySelector(
|
||||||
|
`[data-page-id="${this.pageId}"]`,
|
||||||
|
);
|
||||||
if (pageElement) {
|
if (pageElement) {
|
||||||
const img = pageElement.querySelector("img");
|
const img = pageElement.querySelector("img");
|
||||||
if (img) img.style.transform = `rotate(${this.originalRotation}deg)`;
|
if (img) img.style.transform = `rotate(${this.originalRotation}deg)`;
|
||||||
@@ -118,12 +126,17 @@ export class DeletePagesCommand extends DOMCommand {
|
|||||||
const selectedPageNumbersBefore = this.getSelectedPages();
|
const selectedPageNumbersBefore = this.getSelectedPages();
|
||||||
const selectedIdSet = new Set(
|
const selectedIdSet = new Set(
|
||||||
selectedPageNumbersBefore
|
selectedPageNumbersBefore
|
||||||
.map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum)?.id)
|
.map(
|
||||||
|
(pageNum) =>
|
||||||
|
currentDoc.pages.find((p) => p.pageNumber === pageNum)?.id,
|
||||||
|
)
|
||||||
.filter((id): id is string => Boolean(id)),
|
.filter((id): id is string => Boolean(id)),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter out deleted pages by ID (stable across undo/redo)
|
// Filter out deleted pages by ID (stable across undo/redo)
|
||||||
const remainingPages = currentDoc.pages.filter((page) => !this.pageIdsToDelete.includes(page.id));
|
const remainingPages = currentDoc.pages.filter(
|
||||||
|
(page) => !this.pageIdsToDelete.includes(page.id),
|
||||||
|
);
|
||||||
|
|
||||||
if (remainingPages.length === 0) {
|
if (remainingPages.length === 0) {
|
||||||
// If all pages would be deleted, clear selection/splits and close PDF
|
// If all pages would be deleted, clear selection/splits and close PDF
|
||||||
@@ -162,7 +175,9 @@ export class DeletePagesCommand extends DOMCommand {
|
|||||||
// Apply changes
|
// Apply changes
|
||||||
this.setDocument(updatedDocument);
|
this.setDocument(updatedDocument);
|
||||||
|
|
||||||
const remainingSelectedPageIds = remainingPages.filter((page) => selectedIdSet.has(page.id)).map((page) => page.id);
|
const remainingSelectedPageIds = remainingPages
|
||||||
|
.filter((page) => selectedIdSet.has(page.id))
|
||||||
|
.map((page) => page.id);
|
||||||
this.setSelectedPageIds(remainingSelectedPageIds);
|
this.setSelectedPageIds(remainingSelectedPageIds);
|
||||||
|
|
||||||
this.setSplitPositions(newPositions);
|
this.setSplitPositions(newPositions);
|
||||||
@@ -175,7 +190,12 @@ export class DeletePagesCommand extends DOMCommand {
|
|||||||
this.setDocument(this.originalDocument);
|
this.setDocument(this.originalDocument);
|
||||||
this.setSplitPositions(this.originalSplitPositions);
|
this.setSplitPositions(this.originalSplitPositions);
|
||||||
const restoredIds = this.originalSelectedPages
|
const restoredIds = this.originalSelectedPages
|
||||||
.map((pageNum) => this.originalDocument!.pages.find((page) => page.pageNumber === pageNum)?.id || "")
|
.map(
|
||||||
|
(pageNum) =>
|
||||||
|
this.originalDocument!.pages.find(
|
||||||
|
(page) => page.pageNumber === pageNum,
|
||||||
|
)?.id || "",
|
||||||
|
)
|
||||||
.filter((id) => id !== "");
|
.filter((id) => id !== "");
|
||||||
this.setSelectedPageIds(restoredIds);
|
this.setSelectedPageIds(restoredIds);
|
||||||
}
|
}
|
||||||
@@ -207,18 +227,28 @@ export class ReorderPagesCommand extends DOMCommand {
|
|||||||
this.originalPages = currentDoc.pages.map((page) => ({ ...page }));
|
this.originalPages = currentDoc.pages.map((page) => ({ ...page }));
|
||||||
|
|
||||||
// Perform the reorder
|
// Perform the reorder
|
||||||
const sourceIndex = currentDoc.pages.findIndex((p) => p.pageNumber === this.sourcePageNumber);
|
const sourceIndex = currentDoc.pages.findIndex(
|
||||||
|
(p) => p.pageNumber === this.sourcePageNumber,
|
||||||
|
);
|
||||||
if (sourceIndex === -1) return;
|
if (sourceIndex === -1) return;
|
||||||
|
|
||||||
const newPages = [...currentDoc.pages];
|
const newPages = [...currentDoc.pages];
|
||||||
|
|
||||||
if (this.selectedPages && this.selectedPages.length > 1 && this.selectedPages.includes(this.sourcePageNumber)) {
|
if (
|
||||||
|
this.selectedPages &&
|
||||||
|
this.selectedPages.length > 1 &&
|
||||||
|
this.selectedPages.includes(this.sourcePageNumber)
|
||||||
|
) {
|
||||||
// Multi-page reorder
|
// Multi-page reorder
|
||||||
const selectedPageObjects = this.selectedPages
|
const selectedPageObjects = this.selectedPages
|
||||||
.map((pageNum) => currentDoc.pages.find((p) => p.pageNumber === pageNum))
|
.map((pageNum) =>
|
||||||
|
currentDoc.pages.find((p) => p.pageNumber === pageNum),
|
||||||
|
)
|
||||||
.filter((page) => page !== undefined) as PDFPage[];
|
.filter((page) => page !== undefined) as PDFPage[];
|
||||||
|
|
||||||
const remainingPages = newPages.filter((page) => !this.selectedPages!.includes(page.pageNumber));
|
const remainingPages = newPages.filter(
|
||||||
|
(page) => !this.selectedPages!.includes(page.pageNumber),
|
||||||
|
);
|
||||||
remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects);
|
remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects);
|
||||||
|
|
||||||
remainingPages.forEach((page, index) => {
|
remainingPages.forEach((page, index) => {
|
||||||
@@ -231,7 +261,10 @@ export class ReorderPagesCommand extends DOMCommand {
|
|||||||
const [movedPage] = newPages.splice(sourceIndex, 1);
|
const [movedPage] = newPages.splice(sourceIndex, 1);
|
||||||
|
|
||||||
// Adjust target index if moving forward (after removal, indices shift)
|
// Adjust target index if moving forward (after removal, indices shift)
|
||||||
const adjustedTargetIndex = sourceIndex < this.targetIndex ? this.targetIndex - 1 : this.targetIndex;
|
const adjustedTargetIndex =
|
||||||
|
sourceIndex < this.targetIndex
|
||||||
|
? this.targetIndex - 1
|
||||||
|
: this.targetIndex;
|
||||||
|
|
||||||
newPages.splice(adjustedTargetIndex, 0, movedPage);
|
newPages.splice(adjustedTargetIndex, 0, movedPage);
|
||||||
|
|
||||||
@@ -346,7 +379,8 @@ export class BulkRotateCommand extends DOMCommand {
|
|||||||
const newRotationById = new Map<string, number>();
|
const newRotationById = new Map<string, number>();
|
||||||
const updatedPages = currentDoc.pages.map((page) => {
|
const updatedPages = currentDoc.pages.map((page) => {
|
||||||
if (pageIdSet.has(page.id)) {
|
if (pageIdSet.has(page.id)) {
|
||||||
const newRotation = (((page.rotation + this.degrees) % 360) + 360) % 360;
|
const newRotation =
|
||||||
|
(((page.rotation + this.degrees) % 360) + 360) % 360;
|
||||||
newRotationById.set(page.id, newRotation);
|
newRotationById.set(page.id, newRotation);
|
||||||
return { ...page, rotation: newRotation };
|
return { ...page, rotation: newRotation };
|
||||||
}
|
}
|
||||||
@@ -360,7 +394,8 @@ export class BulkRotateCommand extends DOMCommand {
|
|||||||
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
|
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
|
||||||
if (pageElement) {
|
if (pageElement) {
|
||||||
const img = pageElement.querySelector("img");
|
const img = pageElement.querySelector("img");
|
||||||
if (img) img.style.transform = `rotate(${newRotationById.get(pageId)}deg)`;
|
if (img)
|
||||||
|
img.style.transform = `rotate(${newRotationById.get(pageId)}deg)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -371,7 +406,9 @@ export class BulkRotateCommand extends DOMCommand {
|
|||||||
|
|
||||||
// Restore original rotations in state
|
// Restore original rotations in state
|
||||||
const updatedPages = currentDoc.pages.map((page) =>
|
const updatedPages = currentDoc.pages.map((page) =>
|
||||||
this.originalRotations.has(page.id) ? { ...page, rotation: this.originalRotations.get(page.id)! } : page,
|
this.originalRotations.has(page.id)
|
||||||
|
? { ...page, rotation: this.originalRotations.get(page.id)! }
|
||||||
|
: page,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.setDocument({ ...currentDoc, pages: updatedPages });
|
this.setDocument({ ...currentDoc, pages: updatedPages });
|
||||||
@@ -455,7 +492,9 @@ export class SplitAllCommand extends DOMCommand {
|
|||||||
|
|
||||||
// Check if all splits are already active
|
// Check if all splits are already active
|
||||||
const currentSplits = this.getSplitPositions();
|
const currentSplits = this.getSplitPositions();
|
||||||
const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) => currentSplits.has(pos));
|
const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) =>
|
||||||
|
currentSplits.has(pos),
|
||||||
|
);
|
||||||
|
|
||||||
if (hasAllSplits) {
|
if (hasAllSplits) {
|
||||||
// Remove all splits
|
// Remove all splits
|
||||||
@@ -473,7 +512,9 @@ export class SplitAllCommand extends DOMCommand {
|
|||||||
|
|
||||||
get description(): string {
|
get description(): string {
|
||||||
const currentSplits = this.getSplitPositions();
|
const currentSplits = this.getSplitPositions();
|
||||||
const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) => currentSplits.has(pos));
|
const hasAllSplits = Array.from(this.allPossibleSplits).every((pos) =>
|
||||||
|
currentSplits.has(pos),
|
||||||
|
);
|
||||||
return hasAllSplits ? "Remove all splits" : "Split all pages";
|
return hasAllSplits ? "Remove all splits" : "Split all pages";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -622,7 +663,9 @@ export class BulkPageBreakCommand extends DOMCommand {
|
|||||||
// Find the original page by matching the page ID from the original document
|
// Find the original page by matching the page ID from the original document
|
||||||
const originalPage = this.originalDocument?.pages[originalPageNum - 1];
|
const originalPage = this.originalDocument?.pages[originalPageNum - 1];
|
||||||
if (originalPage) {
|
if (originalPage) {
|
||||||
const foundPage = newPages.find((page) => page.id === originalPage.id && !page.isBlankPage);
|
const foundPage = newPages.find(
|
||||||
|
(page) => page.id === originalPage.id && !page.isBlankPage,
|
||||||
|
);
|
||||||
if (foundPage) {
|
if (foundPage) {
|
||||||
updatedSelection.push(foundPage.pageNumber);
|
updatedSelection.push(foundPage.pageNumber);
|
||||||
}
|
}
|
||||||
@@ -655,7 +698,10 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
private setDocument: (doc: PDFDocument) => void,
|
private setDocument: (doc: PDFDocument) => void,
|
||||||
private setSelectedPages: (pages: number[]) => void,
|
private setSelectedPages: (pages: number[]) => void,
|
||||||
private getSelectedPages: () => number[],
|
private getSelectedPages: () => number[],
|
||||||
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<FileId, File>) => void,
|
private updateFileContext?: (
|
||||||
|
updatedDocument: PDFDocument,
|
||||||
|
insertedFiles?: Map<FileId, File>,
|
||||||
|
) => void,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -677,7 +723,8 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
// Process all files and wait for their completion
|
// Process all files and wait for their completion
|
||||||
const baseTimestamp = Date.now();
|
const baseTimestamp = Date.now();
|
||||||
const extractionPromises = this.files.map(async (file, index) => {
|
const extractionPromises = this.files.map(async (file, index) => {
|
||||||
const fileId = `inserted-${file.name}-${baseTimestamp + index}` as FileId;
|
const fileId =
|
||||||
|
`inserted-${file.name}-${baseTimestamp + index}` as FileId;
|
||||||
// Store inserted file for export
|
// Store inserted file for export
|
||||||
this.insertedFileMap.set(fileId, file);
|
this.insertedFileMap.set(fileId, file);
|
||||||
// Use base timestamp + index to ensure unique but predictable file IDs
|
// Use base timestamp + index to ensure unique but predictable file IDs
|
||||||
@@ -702,7 +749,10 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
|
|
||||||
// Add pages before insertion point
|
// Add pages before insertion point
|
||||||
for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) {
|
for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) {
|
||||||
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
|
const page = {
|
||||||
|
...currentDoc.pages[i],
|
||||||
|
pageNumber: pageNumberCounter++,
|
||||||
|
};
|
||||||
newPages.push(page);
|
newPages.push(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,7 +770,10 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
|
|
||||||
// Add remaining pages after insertion point
|
// Add remaining pages after insertion point
|
||||||
for (let i = insertIndex; i < currentDoc.pages.length; i++) {
|
for (let i = insertIndex; i < currentDoc.pages.length; i++) {
|
||||||
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
|
const page = {
|
||||||
|
...currentDoc.pages[i],
|
||||||
|
pageNumber: pageNumberCounter++,
|
||||||
|
};
|
||||||
newPages.push(page);
|
newPages.push(page);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -765,9 +818,12 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise<void> {
|
private async generateThumbnailsForInsertedPages(
|
||||||
|
updatedDocument: PDFDocument,
|
||||||
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { thumbnailGenerationService } = await import("@app/services/thumbnailGenerationService");
|
const { thumbnailGenerationService } =
|
||||||
|
await import("@app/services/thumbnailGenerationService");
|
||||||
|
|
||||||
// Group pages by file ID to generate thumbnails efficiently
|
// Group pages by file ID to generate thumbnails efficiently
|
||||||
const pagesByFileId = new Map<FileId, PDFPage[]>();
|
const pagesByFileId = new Map<FileId, PDFPage[]>();
|
||||||
@@ -789,7 +845,10 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
|
|
||||||
console.log("Generating thumbnails for file:", fileId);
|
console.log("Generating thumbnails for file:", fileId);
|
||||||
console.log("Pages:", pages.length);
|
console.log("Pages:", pages.length);
|
||||||
console.log("ArrayBuffer size:", arrayBuffer?.byteLength || "undefined");
|
console.log(
|
||||||
|
"ArrayBuffer size:",
|
||||||
|
arrayBuffer?.byteLength || "undefined",
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (arrayBuffer && arrayBuffer.byteLength > 0) {
|
if (arrayBuffer && arrayBuffer.byteLength > 0) {
|
||||||
@@ -802,12 +861,21 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
console.log("Generating thumbnails for page numbers:", pageNumbers);
|
console.log("Generating thumbnails for page numbers:", pageNumbers);
|
||||||
|
|
||||||
// Generate thumbnails for all pages from this file at once
|
// Generate thumbnails for all pages from this file at once
|
||||||
const results = await thumbnailGenerationService.generateThumbnails(fileId, arrayBuffer, pageNumbers, {
|
const results = await thumbnailGenerationService.generateThumbnails(
|
||||||
scale: 0.2,
|
fileId,
|
||||||
quality: 0.8,
|
arrayBuffer,
|
||||||
});
|
pageNumbers,
|
||||||
|
{
|
||||||
|
scale: 0.2,
|
||||||
|
quality: 0.8,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
console.log("Thumbnail generation results:", results.length, "thumbnails generated");
|
console.log(
|
||||||
|
"Thumbnail generation results:",
|
||||||
|
results.length,
|
||||||
|
"thumbnails generated",
|
||||||
|
);
|
||||||
|
|
||||||
// Update pages with generated thumbnails
|
// Update pages with generated thumbnails
|
||||||
for (let i = 0; i < results.length && i < pages.length; i++) {
|
for (let i = 0; i < results.length && i < pages.length; i++) {
|
||||||
@@ -815,7 +883,9 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
const page = pages[i];
|
const page = pages[i];
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const pageIndex = updatedDocument.pages.findIndex((p) => p.id === page.id);
|
const pageIndex = updatedDocument.pages.findIndex(
|
||||||
|
(p) => p.id === page.id,
|
||||||
|
);
|
||||||
if (pageIndex >= 0) {
|
if (pageIndex >= 0) {
|
||||||
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
|
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
|
||||||
console.log("Updated thumbnail for page:", page.id);
|
console.log("Updated thumbnail for page:", page.id);
|
||||||
@@ -829,7 +899,11 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
console.error("No valid ArrayBuffer found for file ID:", fileId);
|
console.error("No valid ArrayBuffer found for file ID:", fileId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to generate thumbnails for file:", fileId, error);
|
console.error(
|
||||||
|
"Failed to generate thumbnails for file:",
|
||||||
|
fileId,
|
||||||
|
error,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
this.fileDataMap.delete(fileId);
|
this.fileDataMap.delete(fileId);
|
||||||
}
|
}
|
||||||
@@ -839,13 +913,19 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extractPagesFromFile(file: File, baseTimestamp: number): Promise<PDFPage[]> {
|
private async extractPagesFromFile(
|
||||||
|
file: File,
|
||||||
|
baseTimestamp: number,
|
||||||
|
): Promise<PDFPage[]> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = async (event) => {
|
reader.onload = async (event) => {
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = event.target?.result as ArrayBuffer;
|
const arrayBuffer = event.target?.result as ArrayBuffer;
|
||||||
console.log("File reader onload - arrayBuffer size:", arrayBuffer?.byteLength || "undefined");
|
console.log(
|
||||||
|
"File reader onload - arrayBuffer size:",
|
||||||
|
arrayBuffer?.byteLength || "undefined",
|
||||||
|
);
|
||||||
|
|
||||||
if (!arrayBuffer) {
|
if (!arrayBuffer) {
|
||||||
reject(new Error("Failed to read file"));
|
reject(new Error("Failed to read file"));
|
||||||
@@ -856,7 +936,8 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
const clonedArrayBuffer = arrayBuffer.slice(0);
|
const clonedArrayBuffer = arrayBuffer.slice(0);
|
||||||
|
|
||||||
// Use PDF.js via the worker manager to extract pages
|
// Use PDF.js via the worker manager to extract pages
|
||||||
const { pdfWorkerManager } = await import("@app/services/pdfWorkerManager");
|
const { pdfWorkerManager } =
|
||||||
|
await import("@app/services/pdfWorkerManager");
|
||||||
const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
|
const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
|
||||||
|
|
||||||
const pageCount = pdf.numPages;
|
const pageCount = pdf.numPages;
|
||||||
@@ -864,13 +945,24 @@ export class InsertFilesCommand extends DOMCommand {
|
|||||||
const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId;
|
const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId;
|
||||||
|
|
||||||
console.log("Original ArrayBuffer size:", arrayBuffer.byteLength);
|
console.log("Original ArrayBuffer size:", arrayBuffer.byteLength);
|
||||||
console.log("Storing ArrayBuffer for fileId:", fileId, "size:", arrayBuffer.byteLength);
|
console.log(
|
||||||
|
"Storing ArrayBuffer for fileId:",
|
||||||
|
fileId,
|
||||||
|
"size:",
|
||||||
|
arrayBuffer.byteLength,
|
||||||
|
);
|
||||||
|
|
||||||
// Store the original ArrayBuffer for thumbnail generation
|
// Store the original ArrayBuffer for thumbnail generation
|
||||||
this.fileDataMap.set(fileId, arrayBuffer);
|
this.fileDataMap.set(fileId, arrayBuffer);
|
||||||
|
|
||||||
console.log("After storing - fileDataMap size:", this.fileDataMap.size);
|
console.log(
|
||||||
console.log("Stored value size:", this.fileDataMap.get(fileId)?.byteLength || "undefined");
|
"After storing - fileDataMap size:",
|
||||||
|
this.fileDataMap.size,
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"Stored value size:",
|
||||||
|
this.fileDataMap.get(fileId)?.byteLength || "undefined",
|
||||||
|
);
|
||||||
|
|
||||||
for (let i = 1; i <= pageCount; i++) {
|
for (let i = 1; i <= pageCount; i++) {
|
||||||
const pageId = `${fileId}-page-${i}`;
|
const pageId = `${fileId}-page-${i}`;
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ export function getFileColor(index: number): string {
|
|||||||
* @param opacity - Opacity value (0-1), defaults to 0.3
|
* @param opacity - Opacity value (0-1), defaults to 0.3
|
||||||
* @returns RGBA color string
|
* @returns RGBA color string
|
||||||
*/
|
*/
|
||||||
export function getFileColorWithOpacity(index: number, opacity: number = 0.2): string {
|
export function getFileColorWithOpacity(
|
||||||
|
index: number,
|
||||||
|
opacity: number = 0.2,
|
||||||
|
): string {
|
||||||
const rgb = getFileColor(index);
|
const rgb = getFileColor(index);
|
||||||
// Convert rgb(r, g, b) to rgba(r, g, b, a)
|
// Convert rgb(r, g, b) to rgba(r, g, b, a)
|
||||||
return rgb.replace("rgb(", "rgba(").replace(")", `, ${opacity})`);
|
return rgb.replace("rgb(", "rgba(").replace(")", `, ${opacity})`);
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ export const useEditedDocumentState = ({
|
|||||||
fileOrder,
|
fileOrder,
|
||||||
updateCurrentPages,
|
updateCurrentPages,
|
||||||
}: UseEditedDocumentStateParams) => {
|
}: UseEditedDocumentStateParams) => {
|
||||||
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
|
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const editedDocumentRef = useRef<PDFDocument | null>(null);
|
const editedDocumentRef = useRef<PDFDocument | null>(null);
|
||||||
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
|
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
|
||||||
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
|
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
|
||||||
@@ -89,7 +91,8 @@ export const useEditedDocumentState = ({
|
|||||||
const currentEditedDocument = editedDocumentRef.current;
|
const currentEditedDocument = editedDocumentRef.current;
|
||||||
if (!mergedPdfDocument || !currentEditedDocument) return;
|
if (!mergedPdfDocument || !currentEditedDocument) return;
|
||||||
|
|
||||||
const signatureChanged = mergedDocSignature !== lastSyncedSignatureRef.current;
|
const signatureChanged =
|
||||||
|
mergedDocSignature !== lastSyncedSignatureRef.current;
|
||||||
const metadataChanged =
|
const metadataChanged =
|
||||||
currentEditedDocument.id !== mergedPdfDocument.id ||
|
currentEditedDocument.id !== mergedPdfDocument.id ||
|
||||||
currentEditedDocument.file !== mergedPdfDocument.file ||
|
currentEditedDocument.file !== mergedPdfDocument.file ||
|
||||||
@@ -117,7 +120,8 @@ export const useEditedDocumentState = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasAdditions = newPages.length > 0;
|
const hasAdditions = newPages.length > 0;
|
||||||
const isEphemeralPage = (page: PDFPage) => Boolean(page.isBlankPage || page.isPlaceholder);
|
const isEphemeralPage = (page: PDFPage) =>
|
||||||
|
Boolean(page.isBlankPage || page.isPlaceholder);
|
||||||
|
|
||||||
let hasRemovals = false;
|
let hasRemovals = false;
|
||||||
for (const page of prev.pages) {
|
for (const page of prev.pages) {
|
||||||
@@ -142,12 +146,16 @@ export const useEditedDocumentState = ({
|
|||||||
const nextInsertIndexByFile = new Map(placeholderPositions);
|
const nextInsertIndexByFile = new Map(placeholderPositions);
|
||||||
|
|
||||||
if (hasRemovals) {
|
if (hasRemovals) {
|
||||||
pages = pages.filter((page) => sourceIds.has(page.id) || isEphemeralPage(page));
|
pages = pages.filter(
|
||||||
|
(page) => sourceIds.has(page.id) || isEphemeralPage(page),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasAdditions) {
|
if (hasAdditions) {
|
||||||
const mergedIndexMap = new Map<string, number>();
|
const mergedIndexMap = new Map<string, number>();
|
||||||
sourcePages.forEach((page, index) => mergedIndexMap.set(page.id, index));
|
sourcePages.forEach((page, index) =>
|
||||||
|
mergedIndexMap.set(page.id, index),
|
||||||
|
);
|
||||||
|
|
||||||
const additions = newPages
|
const additions = newPages
|
||||||
.map((page) => ({
|
.map((page) => ({
|
||||||
@@ -163,36 +171,45 @@ export const useEditedDocumentState = ({
|
|||||||
return a.mergedIndex - b.mergedIndex;
|
return a.mergedIndex - b.mergedIndex;
|
||||||
});
|
});
|
||||||
|
|
||||||
additions.forEach(({ page, neighborId, cachedIndex, mergedIndex }) => {
|
additions.forEach(
|
||||||
if (pages.some((existing) => existing.id === page.id)) {
|
({ page, neighborId, cachedIndex, mergedIndex }) => {
|
||||||
return;
|
if (pages.some((existing) => existing.id === page.id)) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let insertIndex: number;
|
let insertIndex: number;
|
||||||
const originalFileId = page.originalFileId;
|
const originalFileId = page.originalFileId;
|
||||||
const placeholderIndex = originalFileId !== undefined ? nextInsertIndexByFile.get(originalFileId) : undefined;
|
const placeholderIndex =
|
||||||
|
originalFileId !== undefined
|
||||||
|
? nextInsertIndexByFile.get(originalFileId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (originalFileId && placeholderIndex !== undefined) {
|
if (originalFileId && placeholderIndex !== undefined) {
|
||||||
insertIndex = Math.min(placeholderIndex, pages.length);
|
insertIndex = Math.min(placeholderIndex, pages.length);
|
||||||
nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
|
nextInsertIndexByFile.set(originalFileId, insertIndex + 1);
|
||||||
} else if (neighborId === null) {
|
} else if (neighborId === null) {
|
||||||
insertIndex = 0;
|
insertIndex = 0;
|
||||||
} else if (neighborId) {
|
} else if (neighborId) {
|
||||||
const neighborIndex = pages.findIndex((p) => p.id === neighborId);
|
const neighborIndex = pages.findIndex(
|
||||||
if (neighborIndex !== -1) {
|
(p) => p.id === neighborId,
|
||||||
insertIndex = neighborIndex + 1;
|
);
|
||||||
|
if (neighborIndex !== -1) {
|
||||||
|
insertIndex = neighborIndex + 1;
|
||||||
|
} else {
|
||||||
|
const fallbackIndex =
|
||||||
|
cachedIndex ?? mergedIndex ?? pages.length;
|
||||||
|
insertIndex = Math.min(fallbackIndex, pages.length);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
|
const fallbackIndex =
|
||||||
|
cachedIndex ?? mergedIndex ?? pages.length;
|
||||||
insertIndex = Math.min(fallbackIndex, pages.length);
|
insertIndex = Math.min(fallbackIndex, pages.length);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const fallbackIndex = cachedIndex ?? mergedIndex ?? pages.length;
|
|
||||||
insertIndex = Math.min(fallbackIndex, pages.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
const clonedPage = { ...page };
|
const clonedPage = { ...page };
|
||||||
pages.splice(insertIndex, 0, clonedPage);
|
pages.splice(insertIndex, 0, clonedPage);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,4 +261,6 @@ export const useEditedDocumentState = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UseEditedDocumentStateReturn = ReturnType<typeof useEditedDocumentState>;
|
export type UseEditedDocumentStateReturn = ReturnType<
|
||||||
|
typeof useEditedDocumentState
|
||||||
|
>;
|
||||||
|
|||||||
@@ -63,7 +63,12 @@ export const usePageEditorCommands = ({
|
|||||||
|
|
||||||
const handleRotatePages = useCallback(
|
const handleRotatePages = useCallback(
|
||||||
(pageIds: string[], rotation: number) => {
|
(pageIds: string[], rotation: number) => {
|
||||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation, getEditedDocument, setEditedDocument);
|
const bulkRotateCommand = new BulkRotateCommand(
|
||||||
|
pageIds,
|
||||||
|
rotation,
|
||||||
|
getEditedDocument,
|
||||||
|
setEditedDocument,
|
||||||
|
);
|
||||||
executeCommandWithTracking(bulkRotateCommand);
|
executeCommandWithTracking(bulkRotateCommand);
|
||||||
},
|
},
|
||||||
[executeCommandWithTracking, getEditedDocument, setEditedDocument],
|
[executeCommandWithTracking, getEditedDocument, setEditedDocument],
|
||||||
@@ -72,7 +77,12 @@ export const usePageEditorCommands = ({
|
|||||||
const createRotateCommand = useCallback(
|
const createRotateCommand = useCallback(
|
||||||
(pageIds: string[], rotation: number) => ({
|
(pageIds: string[], rotation: number) => ({
|
||||||
execute: () => {
|
execute: () => {
|
||||||
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation, getEditedDocument, setEditedDocument);
|
const bulkRotateCommand = new BulkRotateCommand(
|
||||||
|
pageIds,
|
||||||
|
rotation,
|
||||||
|
getEditedDocument,
|
||||||
|
setEditedDocument,
|
||||||
|
);
|
||||||
executeCommandWithTracking(bulkRotateCommand);
|
executeCommandWithTracking(bulkRotateCommand);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -123,7 +133,12 @@ export const usePageEditorCommands = ({
|
|||||||
const createSplitCommand = useCallback(
|
const createSplitCommand = useCallback(
|
||||||
(pageId: string, pageNumber: number) => ({
|
(pageId: string, pageNumber: number) => ({
|
||||||
execute: () => {
|
execute: () => {
|
||||||
const splitCommand = new SplitCommand(pageId, pageNumber, () => splitPositionsRef.current, setSplitPositions);
|
const splitCommand = new SplitCommand(
|
||||||
|
pageId,
|
||||||
|
pageNumber,
|
||||||
|
() => splitPositionsRef.current,
|
||||||
|
setSplitPositions,
|
||||||
|
);
|
||||||
executeCommandWithTracking(splitCommand);
|
executeCommandWithTracking(splitCommand);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -208,12 +223,18 @@ export const usePageEditorCommands = ({
|
|||||||
if (!displayDocument || selectedPageIds.length === 0) return;
|
if (!displayDocument || selectedPageIds.length === 0) return;
|
||||||
|
|
||||||
const selectedSplitPageIds = displayDocument.pages
|
const selectedSplitPageIds = displayDocument.pages
|
||||||
.filter((page, index) => selectedPageIds.includes(page.id) && index < displayDocument.pages.length - 1)
|
.filter(
|
||||||
|
(page, index) =>
|
||||||
|
selectedPageIds.includes(page.id) &&
|
||||||
|
index < displayDocument.pages.length - 1,
|
||||||
|
)
|
||||||
.map((page) => page.id);
|
.map((page) => page.id);
|
||||||
|
|
||||||
if (selectedSplitPageIds.length === 0) return;
|
if (selectedSplitPageIds.length === 0) return;
|
||||||
|
|
||||||
const existingSplitsCount = selectedSplitPageIds.filter((id) => splitPositions.has(id)).length;
|
const existingSplitsCount = selectedSplitPageIds.filter((id) =>
|
||||||
|
splitPositions.has(id),
|
||||||
|
).length;
|
||||||
const noSplitsCount = selectedSplitPageIds.length - existingSplitsCount;
|
const noSplitsCount = selectedSplitPageIds.length - existingSplitsCount;
|
||||||
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
|
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
|
||||||
|
|
||||||
@@ -234,7 +255,14 @@ export const usePageEditorCommands = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
executeCommandWithTracking(smartSplitCommand);
|
executeCommandWithTracking(smartSplitCommand);
|
||||||
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
|
}, [
|
||||||
|
selectedPageIds,
|
||||||
|
displayDocument,
|
||||||
|
splitPositions,
|
||||||
|
setSplitPositions,
|
||||||
|
getPageNumbersFromIds,
|
||||||
|
executeCommandWithTracking,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleSplitAll = handleSplit;
|
const handleSplitAll = handleSplit;
|
||||||
|
|
||||||
@@ -243,7 +271,11 @@ export const usePageEditorCommands = ({
|
|||||||
|
|
||||||
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
|
||||||
|
|
||||||
const pageBreakCommand = new PageBreakCommand(selectedPageNumbers, getEditedDocument, setEditedDocument);
|
const pageBreakCommand = new PageBreakCommand(
|
||||||
|
selectedPageNumbers,
|
||||||
|
getEditedDocument,
|
||||||
|
setEditedDocument,
|
||||||
|
);
|
||||||
executeCommandWithTracking(pageBreakCommand);
|
executeCommandWithTracking(pageBreakCommand);
|
||||||
}, [
|
}, [
|
||||||
displayDocument,
|
displayDocument,
|
||||||
@@ -257,7 +289,11 @@ export const usePageEditorCommands = ({
|
|||||||
const handlePageBreakAll = handlePageBreak;
|
const handlePageBreakAll = handlePageBreak;
|
||||||
|
|
||||||
const handleInsertFiles = useCallback(
|
const handleInsertFiles = useCallback(
|
||||||
async (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => {
|
async (
|
||||||
|
files: File[] | StirlingFileStub[],
|
||||||
|
insertAfterPage: number,
|
||||||
|
isFromStorage?: boolean,
|
||||||
|
) => {
|
||||||
console.log("[PageEditor] handleInsertFiles called:", {
|
console.log("[PageEditor] handleInsertFiles called:", {
|
||||||
fileCount: files.length,
|
fileCount: files.length,
|
||||||
insertAfterPage,
|
insertAfterPage,
|
||||||
@@ -274,7 +310,9 @@ export const usePageEditorCommands = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const targetPage = workingDocument.pages.find((p) => p.pageNumber === insertAfterPage);
|
const targetPage = workingDocument.pages.find(
|
||||||
|
(p) => p.pageNumber === insertAfterPage,
|
||||||
|
);
|
||||||
if (!targetPage) {
|
if (!targetPage) {
|
||||||
console.log("[PageEditor] Target page not found:", insertAfterPage);
|
console.log("[PageEditor] Target page not found:", insertAfterPage);
|
||||||
return;
|
return;
|
||||||
@@ -317,7 +355,8 @@ export const usePageEditorCommands = ({
|
|||||||
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
|
id: `${fileId}-${page.pageNumber ?? idx + 1}`,
|
||||||
pageNumber: page.pageNumber ?? idx + 1,
|
pageNumber: page.pageNumber ?? idx + 1,
|
||||||
originalFileId: fileId,
|
originalFileId: fileId,
|
||||||
originalPageNumber: page.originalPageNumber ?? page.pageNumber ?? idx + 1,
|
originalPageNumber:
|
||||||
|
page.originalPageNumber ?? page.pageNumber ?? idx + 1,
|
||||||
rotation: page.rotation ?? 0,
|
rotation: page.rotation ?? 0,
|
||||||
thumbnail: page.thumbnail ?? null,
|
thumbnail: page.thumbnail ?? null,
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -328,7 +367,9 @@ export const usePageEditorCommands = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newPages.length > 0) {
|
if (newPages.length > 0) {
|
||||||
const targetIndex = workingDocument.pages.findIndex((p) => p.id === targetPage.id);
|
const targetIndex = workingDocument.pages.findIndex(
|
||||||
|
(p) => p.id === targetPage.id,
|
||||||
|
);
|
||||||
|
|
||||||
if (targetIndex >= 0) {
|
if (targetIndex >= 0) {
|
||||||
const updatedPages = [...workingDocument.pages];
|
const updatedPages = [...workingDocument.pages];
|
||||||
@@ -350,14 +391,26 @@ export const usePageEditorCommands = ({
|
|||||||
console.error("Failed to insert files:", error);
|
console.error("Failed to insert files:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[getEditedDocument, actions, selectors, updateFileOrderFromPages, setEditedDocument],
|
[
|
||||||
|
getEditedDocument,
|
||||||
|
actions,
|
||||||
|
selectors,
|
||||||
|
updateFileOrderFromPages,
|
||||||
|
setEditedDocument,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReorderPages = useCallback(
|
const handleReorderPages = useCallback(
|
||||||
(sourcePageNumber: number, targetIndex: number, draggedPageIds?: string[]) => {
|
(
|
||||||
|
sourcePageNumber: number,
|
||||||
|
targetIndex: number,
|
||||||
|
draggedPageIds?: string[],
|
||||||
|
) => {
|
||||||
if (!displayDocument) return;
|
if (!displayDocument) return;
|
||||||
|
|
||||||
const selectedPages = draggedPageIds ? getPageNumbersFromIds(draggedPageIds) : undefined;
|
const selectedPages = draggedPageIds
|
||||||
|
? getPageNumbersFromIds(draggedPageIds)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const reorderCommand = new ReorderPagesCommand(
|
const reorderCommand = new ReorderPagesCommand(
|
||||||
sourcePageNumber,
|
sourcePageNumber,
|
||||||
@@ -397,4 +450,6 @@ export const usePageEditorCommands = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UsePageEditorCommandsReturn = ReturnType<typeof usePageEditorCommands>;
|
export type UsePageEditorCommandsReturn = ReturnType<
|
||||||
|
typeof usePageEditorCommands
|
||||||
|
>;
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import { PDFDocument } from "@app/types/pageEditor";
|
|||||||
*/
|
*/
|
||||||
export function useInitialPageDocument(): PDFDocument | null {
|
export function useInitialPageDocument(): PDFDocument | null {
|
||||||
const { document: liveDocument } = usePageDocument();
|
const { document: liveDocument } = usePageDocument();
|
||||||
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(null);
|
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
const lastDocumentIdRef = useRef<string | null>(null);
|
const lastDocumentIdRef = useRef<string | null>(null);
|
||||||
const liveDocumentId = liveDocument?.id ?? null;
|
const liveDocumentId = liveDocument?.id ?? null;
|
||||||
|
|
||||||
@@ -30,7 +32,11 @@ export function useInitialPageDocument(): PDFDocument | null {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📄 useInitialPageDocument: Captured initial document with", liveDocument.pages.length, "pages");
|
console.log(
|
||||||
|
"📄 useInitialPageDocument: Captured initial document with",
|
||||||
|
liveDocument.pages.length,
|
||||||
|
"pages",
|
||||||
|
);
|
||||||
setInitialDocument(liveDocument);
|
setInitialDocument(liveDocument);
|
||||||
}, [liveDocument, initialDocument]);
|
}, [liveDocument, initialDocument]);
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,12 @@ export interface PageDocumentHook {
|
|||||||
*/
|
*/
|
||||||
export function usePageDocument(): PageDocumentHook {
|
export function usePageDocument(): PageDocumentHook {
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
const { fileOrder, currentPages, persistedDocument, persistedDocumentSignature } = usePageEditor();
|
const {
|
||||||
|
fileOrder,
|
||||||
|
currentPages,
|
||||||
|
persistedDocument,
|
||||||
|
persistedDocumentSignature,
|
||||||
|
} = usePageEditor();
|
||||||
|
|
||||||
// Use PageEditorContext's fileOrder instead of FileContext's global order
|
// Use PageEditorContext's fileOrder instead of FileContext's global order
|
||||||
// This ensures the page editor respects its own workspace ordering
|
// This ensures the page editor respects its own workspace ordering
|
||||||
@@ -55,9 +60,12 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
const globalProcessing = state.ui.isProcessing;
|
const globalProcessing = state.ui.isProcessing;
|
||||||
|
|
||||||
// Get primary file record outside useMemo to track processedFile changes
|
// Get primary file record outside useMemo to track processedFile changes
|
||||||
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
|
const primaryStirlingFileStub = primaryFileId
|
||||||
|
? selectors.getStirlingFileStub(primaryFileId)
|
||||||
|
: null;
|
||||||
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
|
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
|
||||||
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
|
const processedFileTotalPages =
|
||||||
|
primaryStirlingFileStub?.processedFile?.totalPages;
|
||||||
|
|
||||||
const placeholderDocumentRef = useRef<PDFDocument | null>(null);
|
const placeholderDocumentRef = useRef<PDFDocument | null>(null);
|
||||||
const [placeholderVersion, setPlaceholderVersion] = useState(0);
|
const [placeholderVersion, setPlaceholderVersion] = useState(0);
|
||||||
@@ -89,20 +97,24 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
const analysis = await FileAnalyzer.quickPDFAnalysis(file);
|
const analysis = await FileAnalyzer.quickPDFAnalysis(file);
|
||||||
if (canceled) return;
|
if (canceled) return;
|
||||||
const totalPages = Math.max(1, analysis.pageCount || 1);
|
const totalPages = Math.max(1, analysis.pageCount || 1);
|
||||||
const pages: PDFPage[] = Array.from({ length: totalPages }, (_, index) => ({
|
const pages: PDFPage[] = Array.from(
|
||||||
id: `placeholder-${primaryFileId}-page-${index + 1}`,
|
{ length: totalPages },
|
||||||
pageNumber: index + 1,
|
(_, index) => ({
|
||||||
thumbnail: null,
|
id: `placeholder-${primaryFileId}-page-${index + 1}`,
|
||||||
rotation: 0,
|
pageNumber: index + 1,
|
||||||
selected: false,
|
thumbnail: null,
|
||||||
originalFileId: primaryFileId,
|
rotation: 0,
|
||||||
originalPageNumber: index + 1,
|
selected: false,
|
||||||
}));
|
originalFileId: primaryFileId,
|
||||||
|
originalPageNumber: index + 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
if (!canceled) {
|
if (!canceled) {
|
||||||
placeholderDocumentRef.current = {
|
placeholderDocumentRef.current = {
|
||||||
id: `placeholder-${primaryFileId}`,
|
id: `placeholder-${primaryFileId}`,
|
||||||
name: selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name,
|
name:
|
||||||
|
selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name,
|
||||||
file,
|
file,
|
||||||
pages,
|
pages,
|
||||||
totalPages,
|
totalPages,
|
||||||
@@ -147,7 +159,13 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
// Check if persisted document is still valid
|
// Check if persisted document is still valid
|
||||||
// Must match signature AND have the same number of source files
|
// Must match signature AND have the same number of source files
|
||||||
const persistedFileIds = persistedDocument
|
const persistedFileIds = persistedDocument
|
||||||
? Array.from(new Set(persistedDocument.pages.map((p) => p.originalFileId).filter(Boolean)))
|
? Array.from(
|
||||||
|
new Set(
|
||||||
|
persistedDocument.pages
|
||||||
|
.map((p) => p.originalFileId)
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
const persistedIsValid =
|
const persistedIsValid =
|
||||||
persistedDocument &&
|
persistedDocument &&
|
||||||
@@ -160,14 +178,20 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
console.log("[usePageDocument] Using persisted document");
|
console.log("[usePageDocument] Using persisted document");
|
||||||
return persistedDocument;
|
return persistedDocument;
|
||||||
} else if (persistedDocument) {
|
} else if (persistedDocument) {
|
||||||
console.log("[usePageDocument] Persisted document invalid - rebuilding:", {
|
console.log(
|
||||||
sigMatch: persistedDocumentSignature === currentPagesSignature,
|
"[usePageDocument] Persisted document invalid - rebuilding:",
|
||||||
persistedFiles: persistedFileIds.length,
|
{
|
||||||
activeFiles: activeFileIds.length,
|
sigMatch: persistedDocumentSignature === currentPagesSignature,
|
||||||
});
|
persistedFiles: persistedFileIds.length,
|
||||||
|
activeFiles: activeFileIds.length,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!primaryStirlingFileStub?.processedFile && placeholderDocumentRef.current) {
|
if (
|
||||||
|
!primaryStirlingFileStub?.processedFile &&
|
||||||
|
placeholderDocumentRef.current
|
||||||
|
) {
|
||||||
return placeholderDocumentRef.current;
|
return placeholderDocumentRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,18 +199,29 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
|
|
||||||
// If we have file IDs but no file record, something is wrong - return null to show loading
|
// If we have file IDs but no file record, something is wrong - return null to show loading
|
||||||
if (!primaryStirlingFileStub) {
|
if (!primaryStirlingFileStub) {
|
||||||
console.log("🎬 PageEditor: No primary file record found, showing loading");
|
console.log(
|
||||||
|
"🎬 PageEditor: No primary file record found, showing loading",
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const namingFileIds = selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds;
|
const namingFileIds =
|
||||||
|
selectedActiveFileIds.length > 0 ? selectedActiveFileIds : activeFileIds;
|
||||||
|
|
||||||
const name =
|
const name =
|
||||||
namingFileIds.length <= 1
|
namingFileIds.length <= 1
|
||||||
? namingFileIds[0]
|
? namingFileIds[0]
|
||||||
? (selectors.getStirlingFileStub(namingFileIds[0])?.name ?? "document.pdf")
|
? (selectors.getStirlingFileStub(namingFileIds[0])?.name ??
|
||||||
|
"document.pdf")
|
||||||
: "document.pdf"
|
: "document.pdf"
|
||||||
: namingFileIds.map((id) => (selectors.getStirlingFileStub(id)?.name ?? "file").replace(/\.pdf$/i, "")).join(" + ");
|
: namingFileIds
|
||||||
|
.map((id) =>
|
||||||
|
(selectors.getStirlingFileStub(id)?.name ?? "file").replace(
|
||||||
|
/\.pdf$/i,
|
||||||
|
"",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.join(" + ");
|
||||||
|
|
||||||
// Build page insertion map from files with insertion positions
|
// Build page insertion map from files with insertion positions
|
||||||
const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds
|
const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds
|
||||||
@@ -218,7 +253,11 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
let pages: PDFPage[];
|
let pages: PDFPage[];
|
||||||
|
|
||||||
// Helper function to create pages from a file (or placeholder if deselected)
|
// Helper function to create pages from a file (or placeholder if deselected)
|
||||||
const createPagesFromFile = (fileId: FileId, startPageNumber: number, isSelected: boolean): PDFPage[] => {
|
const createPagesFromFile = (
|
||||||
|
fileId: FileId,
|
||||||
|
startPageNumber: number,
|
||||||
|
isSelected: boolean,
|
||||||
|
): PDFPage[] => {
|
||||||
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
|
||||||
if (!stirlingFileStub) {
|
if (!stirlingFileStub) {
|
||||||
return [];
|
return [];
|
||||||
@@ -262,17 +301,20 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
|
|
||||||
if (processedFile?.totalPages) {
|
if (processedFile?.totalPages) {
|
||||||
// Fallback: create pages without thumbnails but with correct count
|
// Fallback: create pages without thumbnails but with correct count
|
||||||
return Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({
|
return Array.from(
|
||||||
id: `${fileId}-${pageIndex + 1}`,
|
{ length: processedFile.totalPages },
|
||||||
pageNumber: startPageNumber + pageIndex,
|
(_, pageIndex) => ({
|
||||||
originalPageNumber: pageIndex + 1,
|
id: `${fileId}-${pageIndex + 1}`,
|
||||||
originalFileId: fileId,
|
pageNumber: startPageNumber + pageIndex,
|
||||||
rotation: 0,
|
originalPageNumber: pageIndex + 1,
|
||||||
thumbnail: null,
|
originalFileId: fileId,
|
||||||
selected: false,
|
rotation: 0,
|
||||||
splitAfter: false,
|
thumbnail: null,
|
||||||
isPlaceholder: false,
|
selected: false,
|
||||||
}));
|
splitAfter: false,
|
||||||
|
isPlaceholder: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No processedFile yet - create a single loading placeholder
|
// No processedFile yet - create a single loading placeholder
|
||||||
@@ -320,7 +362,9 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
|
|
||||||
// Process each insertion by finding the page ID and inserting after it
|
// Process each insertion by finding the page ID and inserting after it
|
||||||
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) {
|
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) {
|
||||||
const targetPageIndex = pages.findIndex((p) => p.id === insertAfterPageId);
|
const targetPageIndex = pages.findIndex(
|
||||||
|
(p) => p.id === insertAfterPageId,
|
||||||
|
);
|
||||||
|
|
||||||
if (targetPageIndex === -1) continue;
|
if (targetPageIndex === -1) continue;
|
||||||
|
|
||||||
@@ -352,14 +396,22 @@ export function usePageDocument(): PageDocumentHook {
|
|||||||
pageNumber: index + 1,
|
pageNumber: index + 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const currentPagesSet = currentPages ? new Set(currentPages.map((page) => page.id)) : null;
|
const currentPagesSet = currentPages
|
||||||
if (currentPagesSet && currentPages && currentPagesSet.size === pages.length) {
|
? new Set(currentPages.map((page) => page.id))
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
currentPagesSet &&
|
||||||
|
currentPages &&
|
||||||
|
currentPagesSet.size === pages.length
|
||||||
|
) {
|
||||||
const sameIds = pages.every((page) => currentPagesSet.has(page.id));
|
const sameIds = pages.every((page) => currentPagesSet.has(page.id));
|
||||||
if (sameIds) {
|
if (sameIds) {
|
||||||
const mergedById = new Map(pages.map((page) => [page.id, page]));
|
const mergedById = new Map(pages.map((page) => [page.id, page]));
|
||||||
pages = currentPages.map((currentPage, index) => {
|
pages = currentPages.map((currentPage, index) => {
|
||||||
const source = mergedById.get(currentPage.id);
|
const source = mergedById.get(currentPage.id);
|
||||||
const mergedPage = source ? { ...source, ...currentPage } : currentPage;
|
const mergedPage = source
|
||||||
|
? { ...source, ...currentPage }
|
||||||
|
: currentPage;
|
||||||
return {
|
return {
|
||||||
...mergedPage,
|
...mergedPage,
|
||||||
pageNumber: index + 1,
|
pageNumber: index + 1,
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export interface PageEditorDropdownState {
|
|||||||
fileColorMap: Map<FileId, number>;
|
fileColorMap: Map<FileId, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPdf = (name?: string | null) => typeof name === "string" && name.toLowerCase().endsWith(".pdf");
|
const isPdf = (name?: string | null) =>
|
||||||
|
typeof name === "string" && name.toLowerCase().endsWith(".pdf");
|
||||||
|
|
||||||
export function usePageEditorDropdownState(): PageEditorDropdownState {
|
export function usePageEditorDropdownState(): PageEditorDropdownState {
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
@@ -42,9 +43,14 @@ export function usePageEditorDropdownState(): PageEditorDropdownState {
|
|||||||
.filter((file): file is PageEditorDropdownFile => file !== null);
|
.filter((file): file is PageEditorDropdownFile => file !== null);
|
||||||
}, [fileOrder, selectors, state.ui.selectedFileIds]);
|
}, [fileOrder, selectors, state.ui.selectedFileIds]);
|
||||||
|
|
||||||
const fileColorMap = useFileColorMap(pageEditorFiles.map((file) => file.fileId));
|
const fileColorMap = useFileColorMap(
|
||||||
|
pageEditorFiles.map((file) => file.fileId),
|
||||||
|
);
|
||||||
|
|
||||||
const selectedCount = useMemo(() => pageEditorFiles.filter((file) => file.isSelected).length, [pageEditorFiles]);
|
const selectedCount = useMemo(
|
||||||
|
() => pageEditorFiles.filter((file) => file.isSelected).length,
|
||||||
|
[pageEditorFiles],
|
||||||
|
);
|
||||||
|
|
||||||
return useMemo<PageEditorDropdownState>(
|
return useMemo<PageEditorDropdownState>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -55,6 +61,12 @@ export function usePageEditorDropdownState(): PageEditorDropdownState {
|
|||||||
onReorder: reorderFiles,
|
onReorder: reorderFiles,
|
||||||
fileColorMap,
|
fileColorMap,
|
||||||
}),
|
}),
|
||||||
[pageEditorFiles, selectedCount, toggleFileSelection, reorderFiles, fileColorMap],
|
[
|
||||||
|
pageEditorFiles,
|
||||||
|
selectedCount,
|
||||||
|
toggleFileSelection,
|
||||||
|
reorderFiles,
|
||||||
|
fileColorMap,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,13 @@ const removePlaceholderPages = (document: PDFDocument): PDFDocument => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeProcessedDocuments = (processed: PDFDocument | PDFDocument[]): PDFDocument | PDFDocument[] => {
|
const normalizeProcessedDocuments = (
|
||||||
|
processed: PDFDocument | PDFDocument[],
|
||||||
|
): PDFDocument | PDFDocument[] => {
|
||||||
if (Array.isArray(processed)) {
|
if (Array.isArray(processed)) {
|
||||||
const normalized = processed.map(removePlaceholderPages).filter((doc) => doc.pages.length > 0);
|
const normalized = processed
|
||||||
|
.map(removePlaceholderPages)
|
||||||
|
.filter((doc) => doc.pages.length > 0);
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
return removePlaceholderPages(processed);
|
return removePlaceholderPages(processed);
|
||||||
@@ -104,17 +108,23 @@ export const usePageEditorExport = ({
|
|||||||
|
|
||||||
setExportLoading(true);
|
setExportLoading(true);
|
||||||
try {
|
try {
|
||||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
const processedDocuments =
|
||||||
displayDocument,
|
documentManipulationService.applyDOMChangesToDocument(
|
||||||
displayDocument,
|
displayDocument,
|
||||||
splitPositions,
|
displayDocument,
|
||||||
);
|
splitPositions,
|
||||||
|
);
|
||||||
|
|
||||||
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
const normalizedDocuments =
|
||||||
const documentWithDOMState = Array.isArray(normalizedDocuments) ? normalizedDocuments[0] : normalizedDocuments;
|
normalizeProcessedDocuments(processedDocuments);
|
||||||
|
const documentWithDOMState = Array.isArray(normalizedDocuments)
|
||||||
|
? normalizedDocuments[0]
|
||||||
|
: normalizedDocuments;
|
||||||
|
|
||||||
if (!documentWithDOMState || documentWithDOMState.pages.length === 0) {
|
if (!documentWithDOMState || documentWithDOMState.pages.length === 0) {
|
||||||
console.warn("Export skipped: no concrete pages available after filtering placeholders.");
|
console.warn(
|
||||||
|
"Export skipped: no concrete pages available after filtering placeholders.",
|
||||||
|
);
|
||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -126,14 +136,23 @@ export const usePageEditorExport = ({
|
|||||||
const sourceFiles = getSourceFiles();
|
const sourceFiles = getSourceFiles();
|
||||||
const exportFilename = getExportFilename();
|
const exportFilename = getExportFilename();
|
||||||
const result = sourceFiles
|
const result = sourceFiles
|
||||||
? await pdfExportService.exportPDFMultiFile(documentWithDOMState, sourceFiles, validSelectedPageIds, {
|
? await pdfExportService.exportPDFMultiFile(
|
||||||
selectedOnly: true,
|
documentWithDOMState,
|
||||||
filename: exportFilename,
|
sourceFiles,
|
||||||
})
|
validSelectedPageIds,
|
||||||
: await pdfExportService.exportPDF(documentWithDOMState, validSelectedPageIds, {
|
{
|
||||||
selectedOnly: true,
|
selectedOnly: true,
|
||||||
filename: exportFilename,
|
filename: exportFilename,
|
||||||
});
|
},
|
||||||
|
)
|
||||||
|
: await pdfExportService.exportPDF(
|
||||||
|
documentWithDOMState,
|
||||||
|
validSelectedPageIds,
|
||||||
|
{
|
||||||
|
selectedOnly: true,
|
||||||
|
filename: exportFilename,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
pdfExportService.downloadFile(result.blob, result.filename);
|
pdfExportService.downloadFile(result.blob, result.filename);
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
@@ -158,26 +177,36 @@ export const usePageEditorExport = ({
|
|||||||
|
|
||||||
setExportLoading(true);
|
setExportLoading(true);
|
||||||
try {
|
try {
|
||||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
const processedDocuments =
|
||||||
displayDocument,
|
documentManipulationService.applyDOMChangesToDocument(
|
||||||
displayDocument,
|
displayDocument,
|
||||||
splitPositions,
|
displayDocument,
|
||||||
);
|
splitPositions,
|
||||||
|
);
|
||||||
|
|
||||||
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
const normalizedDocuments =
|
||||||
|
normalizeProcessedDocuments(processedDocuments);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) ||
|
(Array.isArray(normalizedDocuments) &&
|
||||||
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0)
|
normalizedDocuments.length === 0) ||
|
||||||
|
(!Array.isArray(normalizedDocuments) &&
|
||||||
|
normalizedDocuments.pages.length === 0)
|
||||||
) {
|
) {
|
||||||
console.warn("Export skipped: no concrete pages available after filtering placeholders.");
|
console.warn(
|
||||||
|
"Export skipped: no concrete pages available after filtering placeholders.",
|
||||||
|
);
|
||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceFiles = getSourceFiles();
|
const sourceFiles = getSourceFiles();
|
||||||
const exportFilename = getExportFilename();
|
const exportFilename = getExportFilename();
|
||||||
const files = await exportProcessedDocumentsToFiles(normalizedDocuments, sourceFiles, exportFilename);
|
const files = await exportProcessedDocumentsToFiles(
|
||||||
|
normalizedDocuments,
|
||||||
|
sourceFiles,
|
||||||
|
exportFilename,
|
||||||
|
);
|
||||||
|
|
||||||
if (files.length > 1) {
|
if (files.length > 1) {
|
||||||
const JSZip = await import("jszip");
|
const JSZip = await import("jszip");
|
||||||
@@ -203,33 +232,50 @@ export const usePageEditorExport = ({
|
|||||||
console.error("Export failed:", error);
|
console.error("Export failed:", error);
|
||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
}
|
}
|
||||||
}, [displayDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges, setExportLoading]);
|
}, [
|
||||||
|
displayDocument,
|
||||||
|
splitPositions,
|
||||||
|
getSourceFiles,
|
||||||
|
getExportFilename,
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
setExportLoading,
|
||||||
|
]);
|
||||||
|
|
||||||
const applyChanges = useCallback(async () => {
|
const applyChanges = useCallback(async () => {
|
||||||
if (!displayDocument) return;
|
if (!displayDocument) return;
|
||||||
|
|
||||||
setExportLoading(true);
|
setExportLoading(true);
|
||||||
try {
|
try {
|
||||||
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
|
const processedDocuments =
|
||||||
displayDocument,
|
documentManipulationService.applyDOMChangesToDocument(
|
||||||
displayDocument,
|
displayDocument,
|
||||||
splitPositions,
|
displayDocument,
|
||||||
);
|
splitPositions,
|
||||||
|
);
|
||||||
|
|
||||||
const normalizedDocuments = normalizeProcessedDocuments(processedDocuments);
|
const normalizedDocuments =
|
||||||
|
normalizeProcessedDocuments(processedDocuments);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(Array.isArray(normalizedDocuments) && normalizedDocuments.length === 0) ||
|
(Array.isArray(normalizedDocuments) &&
|
||||||
(!Array.isArray(normalizedDocuments) && normalizedDocuments.pages.length === 0)
|
normalizedDocuments.length === 0) ||
|
||||||
|
(!Array.isArray(normalizedDocuments) &&
|
||||||
|
normalizedDocuments.pages.length === 0)
|
||||||
) {
|
) {
|
||||||
console.warn("Apply changes skipped: no concrete pages available after filtering placeholders.");
|
console.warn(
|
||||||
|
"Apply changes skipped: no concrete pages available after filtering placeholders.",
|
||||||
|
);
|
||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceFiles = getSourceFiles();
|
const sourceFiles = getSourceFiles();
|
||||||
const exportFilename = getExportFilename();
|
const exportFilename = getExportFilename();
|
||||||
const files = await exportProcessedDocumentsToFiles(normalizedDocuments, sourceFiles, exportFilename);
|
const files = await exportProcessedDocumentsToFiles(
|
||||||
|
normalizedDocuments,
|
||||||
|
sourceFiles,
|
||||||
|
exportFilename,
|
||||||
|
);
|
||||||
|
|
||||||
// Add "_multitool" suffix to filenames
|
// Add "_multitool" suffix to filenames
|
||||||
const renamedFiles = files.map((file) => {
|
const renamedFiles = files.map((file) => {
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ export function usePageEditorState(): PageEditorState {
|
|||||||
// Helper functions
|
// Helper functions
|
||||||
const togglePage = useCallback((pageId: string) => {
|
const togglePage = useCallback((pageId: string) => {
|
||||||
setSelectedPageIds((prev) => {
|
setSelectedPageIds((prev) => {
|
||||||
const newSelection = prev.includes(pageId) ? prev.filter((id) => id !== pageId) : [...prev, pageId];
|
const newSelection = prev.includes(pageId)
|
||||||
|
? prev.filter((id) => id !== pageId)
|
||||||
|
: [...prev, pageId];
|
||||||
return newSelection;
|
return newSelection;
|
||||||
});
|
});
|
||||||
}, []); // Empty deps - uses updater function so always has latest state
|
}, []); // Empty deps - uses updater function so always has latest state
|
||||||
@@ -60,7 +62,9 @@ export function usePageEditorState(): PageEditorState {
|
|||||||
const toggleSelectAll = useCallback((allPageIds: string[]) => {
|
const toggleSelectAll = useCallback((allPageIds: string[]) => {
|
||||||
if (!allPageIds.length) return;
|
if (!allPageIds.length) return;
|
||||||
|
|
||||||
setSelectedPageIds((prev) => (prev.length === allPageIds.length ? [] : allPageIds));
|
setSelectedPageIds((prev) =>
|
||||||
|
prev.length === allPageIds.length ? [] : allPageIds,
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const animateReorder = useCallback(() => {
|
const animateReorder = useCallback(() => {
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ export const usePageSelectionManager = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (displayDocument && displayDocument.pages.length > 0 && !hasInitializedSelection.current) {
|
if (
|
||||||
|
displayDocument &&
|
||||||
|
displayDocument.pages.length > 0 &&
|
||||||
|
!hasInitializedSelection.current
|
||||||
|
) {
|
||||||
const allPageIds = displayDocument.pages.map((page) => page.id);
|
const allPageIds = displayDocument.pages.map((page) => page.id);
|
||||||
setSelectedPageIds(allPageIds);
|
setSelectedPageIds(allPageIds);
|
||||||
setSelectionMode(true);
|
setSelectionMode(true);
|
||||||
@@ -128,4 +132,6 @@ export const usePageSelectionManager = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UsePageSelectionManagerReturn = ReturnType<typeof usePageSelectionManager>;
|
export type UsePageSelectionManagerReturn = ReturnType<
|
||||||
|
typeof usePageSelectionManager
|
||||||
|
>;
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ interface UseUndoManagerStateParams {
|
|||||||
setHasUnsavedChanges: (dirty: boolean) => void;
|
setHasUnsavedChanges: (dirty: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUndoManagerState = ({ setHasUnsavedChanges }: UseUndoManagerStateParams) => {
|
export const useUndoManagerState = ({
|
||||||
|
setHasUnsavedChanges,
|
||||||
|
}: UseUndoManagerStateParams) => {
|
||||||
const undoManagerRef = useRef(new UndoManager());
|
const undoManagerRef = useRef(new UndoManager());
|
||||||
const [canUndo, setCanUndo] = useState(false);
|
const [canUndo, setCanUndo] = useState(false);
|
||||||
const [canRedo, setCanRedo] = useState(false);
|
const [canRedo, setCanRedo] = useState(false);
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useRightRailButtons, RightRailButtonWithAction } from "@app/hooks/useRightRailButtons";
|
import {
|
||||||
|
useRightRailButtons,
|
||||||
|
RightRailButtonWithAction,
|
||||||
|
} from "@app/hooks/useRightRailButtons";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
|
import PageSelectByNumberButton from "@app/components/pageEditor/PageSelectByNumberButton";
|
||||||
|
|
||||||
@@ -22,7 +25,9 @@ interface PageEditorRightRailButtonsParams {
|
|||||||
closePdf: () => void;
|
closePdf: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePageEditorRightRailButtons(params: PageEditorRightRailButtonsParams) {
|
export function usePageEditorRightRailButtons(
|
||||||
|
params: PageEditorRightRailButtonsParams,
|
||||||
|
) {
|
||||||
const {
|
const {
|
||||||
totalPages,
|
totalPages,
|
||||||
selectedPageCount,
|
selectedPageCount,
|
||||||
@@ -46,9 +51,18 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
|||||||
// Lift i18n labels out of memo for clarity
|
// Lift i18n labels out of memo for clarity
|
||||||
const selectAllLabel = t("rightRail.selectAll", "Select All");
|
const selectAllLabel = t("rightRail.selectAll", "Select All");
|
||||||
const deselectAllLabel = t("rightRail.deselectAll", "Deselect All");
|
const deselectAllLabel = t("rightRail.deselectAll", "Deselect All");
|
||||||
const selectByNumberLabel = t("rightRail.selectByNumber", "Select by Page Numbers");
|
const selectByNumberLabel = t(
|
||||||
const deleteSelectedLabel = t("rightRail.deleteSelected", "Delete Selected Pages");
|
"rightRail.selectByNumber",
|
||||||
const exportSelectedLabel = t("rightRail.exportSelected", "Export Selected Pages");
|
"Select by Page Numbers",
|
||||||
|
);
|
||||||
|
const deleteSelectedLabel = t(
|
||||||
|
"rightRail.deleteSelected",
|
||||||
|
"Delete Selected Pages",
|
||||||
|
);
|
||||||
|
const exportSelectedLabel = t(
|
||||||
|
"rightRail.exportSelected",
|
||||||
|
"Export Selected Pages",
|
||||||
|
);
|
||||||
const saveChangesLabel = t("rightRail.saveChanges", "Save Changes");
|
const saveChangesLabel = t("rightRail.saveChanges", "Save Changes");
|
||||||
const closePdfLabel = t("rightRail.closePdf", "Close PDF");
|
const closePdfLabel = t("rightRail.closePdf", "Close PDF");
|
||||||
|
|
||||||
@@ -67,7 +81,13 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "page-deselect-all",
|
id: "page-deselect-all",
|
||||||
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
icon: (
|
||||||
|
<LocalIcon
|
||||||
|
icon="crop-square-outline"
|
||||||
|
width="1.5rem"
|
||||||
|
height="1.5rem"
|
||||||
|
/>
|
||||||
|
),
|
||||||
tooltip: deselectAllLabel,
|
tooltip: deselectAllLabel,
|
||||||
ariaLabel: deselectAllLabel,
|
ariaLabel: deselectAllLabel,
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
@@ -99,7 +119,13 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "page-delete-selected",
|
id: "page-delete-selected",
|
||||||
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
|
icon: (
|
||||||
|
<LocalIcon
|
||||||
|
icon="delete-outline-rounded"
|
||||||
|
width="1.5rem"
|
||||||
|
height="1.5rem"
|
||||||
|
/>
|
||||||
|
),
|
||||||
tooltip: deleteSelectedLabel,
|
tooltip: deleteSelectedLabel,
|
||||||
ariaLabel: deleteSelectedLabel,
|
ariaLabel: deleteSelectedLabel,
|
||||||
section: "top" as const,
|
section: "top" as const,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { PDFDocument } from "@app/types/pageEditor";
|
|||||||
/**
|
/**
|
||||||
* Build a map from page ID to its index in the provided document.
|
* Build a map from page ID to its index in the provided document.
|
||||||
*/
|
*/
|
||||||
export function buildPageIdIndexMap(document: PDFDocument | null): Map<string, number> {
|
export function buildPageIdIndexMap(
|
||||||
|
document: PDFDocument | null,
|
||||||
|
): Map<string, number> {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
if (!document) return map;
|
if (!document) return map;
|
||||||
document.pages.forEach((page, index) => {
|
document.pages.forEach((page, index) => {
|
||||||
@@ -16,7 +18,10 @@ export function buildPageIdIndexMap(document: PDFDocument | null): Map<string, n
|
|||||||
* Convert a set of split page IDs (the page preceding each split) into
|
* Convert a set of split page IDs (the page preceding each split) into
|
||||||
* the current index positions inside the document.
|
* the current index positions inside the document.
|
||||||
*/
|
*/
|
||||||
export function convertSplitPageIdsToIndexes(document: PDFDocument | null, splitPageIds: Set<string>): Set<number> {
|
export function convertSplitPageIdsToIndexes(
|
||||||
|
document: PDFDocument | null,
|
||||||
|
splitPageIds: Set<string>,
|
||||||
|
): Set<number> {
|
||||||
const indexes = new Set<number>();
|
const indexes = new Set<number>();
|
||||||
if (!document || !splitPageIds || splitPageIds.size === 0) {
|
if (!document || !splitPageIds || splitPageIds.size === 0) {
|
||||||
return indexes;
|
return indexes;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface QuickAccessBarFooterExtensionsProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuickAccessBarFooterExtensions(_props: QuickAccessBarFooterExtensionsProps) {
|
export function QuickAccessBarFooterExtensions(
|
||||||
|
_props: QuickAccessBarFooterExtensionsProps,
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ interface RightRailFooterExtensionsProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
export function RightRailFooterExtensions(
|
||||||
|
_props: RightRailFooterExtensionsProps,
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import React from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import AppsIcon from "@mui/icons-material/AppsRounded";
|
import AppsIcon from "@mui/icons-material/AppsRounded";
|
||||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||||
import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext";
|
import {
|
||||||
|
useNavigationState,
|
||||||
|
useNavigationActions,
|
||||||
|
} from "@app/contexts/NavigationContext";
|
||||||
import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation";
|
import { useSidebarNavigation } from "@app/hooks/useSidebarNavigation";
|
||||||
import { handleUnlessSpecialClick } from "@app/utils/clickHandlers";
|
import { handleUnlessSpecialClick } from "@app/utils/clickHandlers";
|
||||||
import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton";
|
import QuickAccessButton from "@app/components/shared/quickAccessBar/QuickAccessButton";
|
||||||
@@ -12,9 +15,17 @@ interface AllToolsNavButtonProps {
|
|||||||
setActiveButton: (id: string) => void;
|
setActiveButton: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, setActiveButton }) => {
|
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
||||||
|
activeButton,
|
||||||
|
setActiveButton,
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
const {
|
||||||
|
handleReaderToggle,
|
||||||
|
handleBackToTools,
|
||||||
|
selectedToolKey,
|
||||||
|
leftPanelView,
|
||||||
|
} = useToolWorkflow();
|
||||||
const { hasUnsavedChanges } = useNavigationState();
|
const { hasUnsavedChanges } = useNavigationState();
|
||||||
const { actions: navigationActions } = useNavigationActions();
|
const { actions: navigationActions } = useNavigationActions();
|
||||||
const { getHomeNavigation } = useSidebarNavigation();
|
const { getHomeNavigation } = useSidebarNavigation();
|
||||||
@@ -35,7 +46,10 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Do not highlight All Tools when a specific tool is open (indicator is shown)
|
// Do not highlight All Tools when a specific tool is open (indicator is shown)
|
||||||
const isActive = activeButton === "tools" && !selectedToolKey && leftPanelView === "toolPicker";
|
const isActive =
|
||||||
|
activeButton === "tools" &&
|
||||||
|
!selectedToolKey &&
|
||||||
|
leftPanelView === "toolPicker";
|
||||||
|
|
||||||
const navProps = getHomeNavigation();
|
const navProps = getHomeNavigation();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import React, { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
import React, {
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
|
import { Badge, Modal, Text, ActionIcon, Tooltip, Group } from "@mantine/core";
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||||
@@ -7,9 +13,15 @@ import { NavKey, VALID_NAV_KEYS } from "@app/components/shared/config/types";
|
|||||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
import "@app/components/shared/AppConfigModal.css";
|
import "@app/components/shared/AppConfigModal.css";
|
||||||
import { useIsMobile } from "@app/hooks/useIsMobile";
|
import { useIsMobile } from "@app/hooks/useIsMobile";
|
||||||
import { Z_INDEX_CONFIG_MODAL, Z_INDEX_OVER_CONFIG_MODAL } from "@app/styles/zIndex";
|
import {
|
||||||
|
Z_INDEX_CONFIG_MODAL,
|
||||||
|
Z_INDEX_OVER_CONFIG_MODAL,
|
||||||
|
} from "@app/styles/zIndex";
|
||||||
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
import { useLicenseAlert } from "@app/hooks/useLicenseAlert";
|
||||||
import { UnsavedChangesProvider, useUnsavedChanges } from "@app/contexts/UnsavedChangesContext";
|
import {
|
||||||
|
UnsavedChangesProvider,
|
||||||
|
useUnsavedChanges,
|
||||||
|
} from "@app/contexts/UnsavedChangesContext";
|
||||||
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
|
import { SettingsSearchBar } from "@app/components/shared/config/SettingsSearchBar";
|
||||||
|
|
||||||
interface AppConfigModalProps {
|
interface AppConfigModalProps {
|
||||||
@@ -17,7 +29,10 @@ interface AppConfigModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
const AppConfigModalInner: React.FC<AppConfigModalProps> = ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
}) => {
|
||||||
const [active, setActive] = useState<NavKey>("general");
|
const [active, setActive] = useState<NavKey>("general");
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -42,7 +57,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
const section = getSectionFromPath(location.pathname);
|
const section = getSectionFromPath(location.pathname);
|
||||||
if (opened && section) {
|
if (opened && section) {
|
||||||
setActive(section);
|
setActive(section);
|
||||||
} else if (opened && location.pathname.startsWith("/settings") && !section) {
|
} else if (
|
||||||
|
opened &&
|
||||||
|
location.pathname.startsWith("/settings") &&
|
||||||
|
!section
|
||||||
|
) {
|
||||||
// If at /settings without a section, redirect to general
|
// If at /settings without a section, redirect to general
|
||||||
navigate("/settings/general", { replace: true });
|
navigate("/settings/general", { replace: true });
|
||||||
}
|
}
|
||||||
@@ -64,7 +83,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener("appConfig:navigate", handler as EventListener);
|
window.addEventListener("appConfig:navigate", handler as EventListener);
|
||||||
return () => window.removeEventListener("appConfig:navigate", handler as EventListener);
|
return () =>
|
||||||
|
window.removeEventListener(
|
||||||
|
"appConfig:navigate",
|
||||||
|
handler as EventListener,
|
||||||
|
);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
const colors = useMemo(
|
const colors = useMemo(
|
||||||
@@ -86,7 +109,11 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
const loginEnabled = config?.enableLogin ?? false;
|
const loginEnabled = config?.enableLogin ?? false;
|
||||||
|
|
||||||
// Left navigation structure and icons
|
// Left navigation structure and icons
|
||||||
const configNavSections = useConfigNavSections(isAdmin, runningEE, loginEnabled);
|
const configNavSections = useConfigNavSections(
|
||||||
|
isAdmin,
|
||||||
|
runningEE,
|
||||||
|
loginEnabled,
|
||||||
|
);
|
||||||
|
|
||||||
const activeLabel = useMemo(() => {
|
const activeLabel = useMemo(() => {
|
||||||
for (const section of configNavSections) {
|
for (const section of configNavSections) {
|
||||||
@@ -151,7 +178,12 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
{configNavSections.map((section) => (
|
{configNavSections.map((section) => (
|
||||||
<div key={section.title} className="modal-nav-section">
|
<div key={section.title} className="modal-nav-section">
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Text size="xs" fw={600} c={colors.sectionTitle} style={{ textTransform: "uppercase", letterSpacing: 0.4 }}>
|
<Text
|
||||||
|
size="xs"
|
||||||
|
fw={600}
|
||||||
|
c={colors.sectionTitle}
|
||||||
|
style={{ textTransform: "uppercase", letterSpacing: 0.4 }}
|
||||||
|
>
|
||||||
{section.title}
|
{section.title}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -159,10 +191,14 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
{section.items.map((item) => {
|
{section.items.map((item) => {
|
||||||
const isActive = active === item.key;
|
const isActive = active === item.key;
|
||||||
const isDisabled = item.disabled ?? false;
|
const isDisabled = item.disabled ?? false;
|
||||||
const color = isActive ? colors.navItemActive : colors.navItem;
|
const color = isActive
|
||||||
|
? colors.navItemActive
|
||||||
|
: colors.navItem;
|
||||||
const iconSize = isMobile ? 28 : 18;
|
const iconSize = isMobile ? 28 : 18;
|
||||||
const showPlanWarning =
|
const showPlanWarning =
|
||||||
item.key === "adminPlan" && licenseAlert.active && licenseAlert.audience === "admin";
|
item.key === "adminPlan" &&
|
||||||
|
licenseAlert.active &&
|
||||||
|
licenseAlert.audience === "admin";
|
||||||
|
|
||||||
const navItemContent = (
|
const navItemContent = (
|
||||||
<div
|
<div
|
||||||
@@ -174,20 +210,32 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
}}
|
}}
|
||||||
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
|
className={`modal-nav-item ${isMobile ? "mobile" : ""}`}
|
||||||
style={{
|
style={{
|
||||||
background: isActive ? colors.navItemActiveBg : "transparent",
|
background: isActive
|
||||||
|
? colors.navItemActiveBg
|
||||||
|
: "transparent",
|
||||||
opacity: isDisabled ? 0.6 : 1,
|
opacity: isDisabled ? 0.6 : 1,
|
||||||
cursor: isDisabled ? "not-allowed" : "pointer",
|
cursor: isDisabled ? "not-allowed" : "pointer",
|
||||||
}}
|
}}
|
||||||
data-tour={`admin-${item.key}-nav`}
|
data-tour={`admin-${item.key}-nav`}
|
||||||
>
|
>
|
||||||
<LocalIcon icon={item.icon} width={iconSize} height={iconSize} style={{ color }} />
|
<LocalIcon
|
||||||
|
icon={item.icon}
|
||||||
|
width={iconSize}
|
||||||
|
height={iconSize}
|
||||||
|
style={{ color }}
|
||||||
|
/>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<Group gap={4} align="center" wrap="nowrap">
|
<Group gap={4} align="center" wrap="nowrap">
|
||||||
<Text size="sm" fw={500} style={{ color }}>
|
<Text size="sm" fw={500} style={{ color }}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
{item.badge && (
|
{item.badge && (
|
||||||
<Badge size="xs" variant="light" color={item.badgeColor ?? "orange"} style={{ flexShrink: 0 }}>
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color={item.badgeColor ?? "orange"}
|
||||||
|
style={{ flexShrink: 0 }}
|
||||||
|
>
|
||||||
{item.badge}
|
{item.badge}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -196,7 +244,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
icon="warning-rounded"
|
icon="warning-rounded"
|
||||||
width={14}
|
width={14}
|
||||||
height={14}
|
height={14}
|
||||||
style={{ color: "var(--mantine-color-orange-7)" }}
|
style={{
|
||||||
|
color: "var(--mantine-color-orange-7)",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -215,7 +265,9 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
{navItemContent}
|
{navItemContent}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<React.Fragment key={item.key}>{navItemContent}</React.Fragment>
|
<React.Fragment key={item.key}>
|
||||||
|
{navItemContent}
|
||||||
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -239,8 +291,18 @@ const AppConfigModalInner: React.FC<AppConfigModalProps> = ({ opened, onClose })
|
|||||||
{activeLabel}
|
{activeLabel}
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
<SettingsSearchBar configNavSections={configNavSections} onNavigate={handleNavigation} isMobile={isMobile} />
|
<SettingsSearchBar
|
||||||
<ActionIcon ref={closeButtonRef} variant="subtle" onClick={handleClose} aria-label="Close" data-autofocus>
|
configNavSections={configNavSections}
|
||||||
|
onNavigate={handleNavigation}
|
||||||
|
isMobile={isMobile}
|
||||||
|
/>
|
||||||
|
<ActionIcon
|
||||||
|
ref={closeButtonRef}
|
||||||
|
variant="subtle"
|
||||||
|
onClick={handleClose}
|
||||||
|
aria-label="Close"
|
||||||
|
data-autofocus
|
||||||
|
>
|
||||||
<LocalIcon icon="close-rounded" width={18} height={18} />
|
<LocalIcon icon="close-rounded" width={18} height={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user