mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 21:30:14 +03:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b220ee46b | ||
|
|
6177ccd333 | ||
|
|
25f7e6e0b5 | ||
|
|
c5a2d765ad | ||
|
|
815ad384d1 | ||
|
|
87bf7a5b7f | ||
|
|
a8ea0b60cf | ||
|
|
15b8447626 | ||
|
|
a7fc36586a | ||
|
|
a415c457e9 | ||
|
|
28eb9baa02 | ||
|
|
4b43693e29 | ||
|
|
5c9e590856 | ||
|
|
be78c72887 | ||
|
|
1117ce6164 |
@@ -0,0 +1,74 @@
|
||||
# Node modules and build artifacts
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
frontend/build
|
||||
frontend/.vite
|
||||
frontend/.tauri
|
||||
|
||||
# Gradle build artifacts
|
||||
.gradle
|
||||
build
|
||||
bin
|
||||
target
|
||||
out
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Java compiled files
|
||||
*.class
|
||||
*.jar
|
||||
*.war
|
||||
*.ear
|
||||
|
||||
# Test reports
|
||||
test-results
|
||||
coverage
|
||||
|
||||
# Docker
|
||||
docker-compose.override.yml
|
||||
.dockerignore
|
||||
|
||||
# Temporary files
|
||||
tmp
|
||||
temp
|
||||
*.tmp
|
||||
*.swp
|
||||
*~
|
||||
|
||||
# Runtime database and config files (locked by running app)
|
||||
app/core/configs/**
|
||||
stirling/**
|
||||
stirling-pdf-DB*.mv.db
|
||||
stirling-pdf-DB*.trace.db
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
docs
|
||||
|
||||
# CI/CD
|
||||
.github
|
||||
.gitlab-ci.yml
|
||||
@@ -17,18 +17,11 @@ on:
|
||||
branches: [main, V2]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/**'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- 'frontend/src/desktop/**'
|
||||
- 'frontend/tsconfig.desktop.json'
|
||||
- '.github/workflows/tauri-build.yml'
|
||||
push:
|
||||
branches: [main, V2]
|
||||
paths:
|
||||
- 'frontend/src-tauri/**'
|
||||
- 'frontend/src/**'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- '.github/workflows/tauri-build.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -344,4 +337,4 @@ jobs:
|
||||
echo "❌ Some Tauri builds failed."
|
||||
echo "Please check the logs and fix any issues."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package stirling.software.common.annotations.api;
|
||||
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
/**
|
||||
* Combined annotation for Invite management controllers.
|
||||
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invite")
|
||||
@Tag(
|
||||
name = "Invite",
|
||||
description =
|
||||
"""
|
||||
Invite-link generation and acceptance endpoints for onboarding new users.
|
||||
|
||||
Provides the ability to issue invitation tokens, send optional email invites,
|
||||
validate and accept invite links, and manage pending invitations for teams.
|
||||
|
||||
Typical use cases include:
|
||||
• Admin workflows for issuing time-limited invitations to external users
|
||||
• Self-service invite acceptance and team assignment
|
||||
• License limit enforcement when provisioning new accounts
|
||||
|
||||
Target users: administrators and automation scripts orchestrating user onboarding.
|
||||
""")
|
||||
public @interface InviteApi {}
|
||||
@@ -505,10 +505,19 @@ public class ApplicationProperties {
|
||||
public static class Ui {
|
||||
private String appNameNavbar;
|
||||
private List<String> languages;
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
}
|
||||
|
||||
public String getLogoStyle() {
|
||||
// Validate and return either "modern" or "classic"
|
||||
if ("modern".equalsIgnoreCase(logoStyle)) {
|
||||
return "modern";
|
||||
}
|
||||
return "classic"; // default
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -75,6 +75,9 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
|
||||
// enableLogin)
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/validate")
|
||||
|| trimmedUri.startsWith("/api/v1/invite/accept")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||
|
||||
/**
|
||||
* Filter to track browser IDs for Weekly Active Users (WAU) counting.
|
||||
* Only active when security is disabled (no-login mode).
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class WAUTrackingFilter implements Filter {
|
||||
|
||||
private final WeeklyActiveUsersService wauService;
|
||||
|
||||
@Override
|
||||
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException {
|
||||
|
||||
if (request instanceof HttpServletRequest httpRequest) {
|
||||
// Extract browser ID from header
|
||||
String browserId = httpRequest.getHeader("X-Browser-Id");
|
||||
|
||||
if (browserId != null && !browserId.trim().isEmpty()) {
|
||||
// Record browser access
|
||||
wauService.recordBrowserAccess(browserId);
|
||||
}
|
||||
}
|
||||
|
||||
// Continue the filter chain
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,24 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
"tauri://localhost",
|
||||
"http://tauri.localhost",
|
||||
"https://tauri.localhost")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else if (hasConfiguredOrigins) {
|
||||
@@ -63,13 +79,53 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
.toArray(new String[0]);
|
||||
|
||||
registry.addMapping("/**")
|
||||
.allowedOrigins(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
|
||||
.allowedHeaders("*")
|
||||
.allowedOriginPatterns(allowedOrigins)
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
} else {
|
||||
// Default to allowing all origins when nothing is configured
|
||||
logger.info(
|
||||
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
|
||||
registry.addMapping("/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
|
||||
.allowedHeaders(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN",
|
||||
"X-Browser-Id")
|
||||
.exposedHeaders(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
// If no origins are configured and not in Tauri mode, CORS is not enabled (secure by
|
||||
// default)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-6
@@ -29,7 +29,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.JsonDataResponse;
|
||||
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
|
||||
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
@@ -49,13 +48,12 @@ public class EditTableOfContentsController {
|
||||
@AutoJobPostMapping(
|
||||
value = "/extract-bookmarks",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@JsonDataResponse
|
||||
@Operation(
|
||||
summary = "Extract PDF Bookmarks",
|
||||
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
|
||||
@ResponseBody
|
||||
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
|
||||
throws Exception {
|
||||
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
|
||||
@RequestParam("file") MultipartFile file) throws Exception {
|
||||
PDDocument document = null;
|
||||
try {
|
||||
document = pdfDocumentFactory.load(file);
|
||||
@@ -63,10 +61,10 @@ public class EditTableOfContentsController {
|
||||
|
||||
if (outline == null) {
|
||||
log.info("No outline/bookmarks found in PDF");
|
||||
return new ArrayList<>();
|
||||
return ResponseEntity.ok(new ArrayList<>());
|
||||
}
|
||||
|
||||
return extractBookmarkItems(document, outline);
|
||||
return ResponseEntity.ok(extractBookmarkItems(document, outline));
|
||||
} finally {
|
||||
if (document != null) {
|
||||
document.close();
|
||||
|
||||
+29
-1
@@ -10,6 +10,8 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
@@ -20,6 +22,7 @@ import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
@ConfigApi
|
||||
@Hidden
|
||||
@Slf4j
|
||||
public class ConfigController {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@@ -59,9 +62,15 @@ public class ConfigController {
|
||||
// Extract values from ApplicationProperties
|
||||
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
|
||||
configData.put("languages", applicationProperties.getUi().getLanguages());
|
||||
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
|
||||
|
||||
// Security settings
|
||||
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
|
||||
// enableLogin requires both the config flag AND proprietary features to be loaded
|
||||
// If userService is null, proprietary module isn't loaded
|
||||
// (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false)
|
||||
boolean enableLogin =
|
||||
applicationProperties.getSecurity().getEnableLogin() && userService != null;
|
||||
configData.put("enableLogin", enableLogin);
|
||||
|
||||
// Mail settings - check both SMTP enabled AND invites enabled
|
||||
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
|
||||
@@ -145,6 +154,25 @@ public class ConfigController {
|
||||
// EE features not available, continue without them
|
||||
}
|
||||
|
||||
// Add version and machine info for update checking
|
||||
try {
|
||||
if (applicationContext.containsBean("appVersion")) {
|
||||
configData.put(
|
||||
"appVersion", applicationContext.getBean("appVersion", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("machineType")) {
|
||||
configData.put(
|
||||
"machineType", applicationContext.getBean("machineType", String.class));
|
||||
}
|
||||
if (applicationContext.containsBean("activeSecurity")) {
|
||||
configData.put(
|
||||
"activeSecurity",
|
||||
applicationContext.getBean("activeSecurity", Boolean.class));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Version/machine info not available
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(configData);
|
||||
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointInspector;
|
||||
import stirling.software.SPDF.config.StartupApplicationListener;
|
||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||
import stirling.software.common.annotations.api.InfoApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@@ -34,6 +35,7 @@ public class MetricsController {
|
||||
private final ApplicationProperties applicationProperties;
|
||||
private final MeterRegistry meterRegistry;
|
||||
private final EndpointInspector endpointInspector;
|
||||
private final Optional<WeeklyActiveUsersService> wauService;
|
||||
private boolean metricsEnabled;
|
||||
|
||||
@PostConstruct
|
||||
@@ -352,6 +354,35 @@ public class MetricsController {
|
||||
return ResponseEntity.ok(formatDuration(uptime));
|
||||
}
|
||||
|
||||
@GetMapping("/wau")
|
||||
@Operation(
|
||||
summary = "Weekly Active Users statistics",
|
||||
description =
|
||||
"Returns WAU (Weekly Active Users) count and total unique browsers. "
|
||||
+ "Only available when security is disabled (no-login mode). "
|
||||
+ "Tracks unique browsers via client-generated UUID in localStorage.")
|
||||
public ResponseEntity<?> getWeeklyActiveUsers() {
|
||||
if (!metricsEnabled) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
|
||||
}
|
||||
|
||||
// Check if WAU service is available (only when security.enableLogin=false)
|
||||
if (wauService.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body("WAU tracking is only available when security is disabled (no-login mode)");
|
||||
}
|
||||
|
||||
WeeklyActiveUsersService service = wauService.get();
|
||||
|
||||
Map<String, Object> wauStats = new HashMap<>();
|
||||
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
|
||||
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
|
||||
wauStats.put("daysOnline", service.getDaysOnline());
|
||||
wauStats.put("trackingSince", service.getStartTime().toString());
|
||||
|
||||
return ResponseEntity.ok(wauStats);
|
||||
}
|
||||
|
||||
private String formatDuration(Duration duration) {
|
||||
long days = duration.toDays();
|
||||
long hours = duration.toHoursPart();
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service for tracking Weekly Active Users (WAU) in no-login mode.
|
||||
* Uses in-memory storage with automatic cleanup of old entries.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class WeeklyActiveUsersService {
|
||||
|
||||
// Map of browser ID -> last seen timestamp
|
||||
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
|
||||
|
||||
// Track total unique browsers seen (overall)
|
||||
private long totalUniqueBrowsers = 0;
|
||||
|
||||
// Application start time
|
||||
private final Instant startTime = Instant.now();
|
||||
|
||||
/**
|
||||
* Records a browser access with the current timestamp
|
||||
* @param browserId Unique browser identifier from X-Browser-Id header
|
||||
*/
|
||||
public void recordBrowserAccess(String browserId) {
|
||||
if (browserId == null || browserId.trim().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
|
||||
activeBrowsers.put(browserId, Instant.now());
|
||||
|
||||
if (isNewBrowser) {
|
||||
totalUniqueBrowsers++;
|
||||
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the count of unique browsers seen in the last 7 days
|
||||
* @return Weekly Active Users count
|
||||
*/
|
||||
public long getWeeklyActiveUsers() {
|
||||
cleanupOldEntries();
|
||||
return activeBrowsers.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the total count of unique browsers ever seen
|
||||
* @return Total unique browsers count
|
||||
*/
|
||||
public long getTotalUniqueBrowsers() {
|
||||
return totalUniqueBrowsers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of days the service has been running
|
||||
* @return Days online
|
||||
*/
|
||||
public long getDaysOnline() {
|
||||
return ChronoUnit.DAYS.between(startTime, Instant.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the timestamp when tracking started
|
||||
* @return Start time
|
||||
*/
|
||||
public Instant getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes entries older than 7 days
|
||||
*/
|
||||
private void cleanupOldEntries() {
|
||||
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual cleanup trigger (can be called by scheduled task if needed)
|
||||
*/
|
||||
public void performCleanup() {
|
||||
int sizeBefore = activeBrowsers.size();
|
||||
cleanupOldEntries();
|
||||
int sizeAfter = activeBrowsers.size();
|
||||
|
||||
if (sizeBefore != sizeAfter) {
|
||||
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,6 +176,7 @@ system:
|
||||
|
||||
ui:
|
||||
appNameNavbar: '' # name displayed on the navigation bar
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
|
||||
|
||||
endpoints:
|
||||
|
||||
+21
-4
@@ -24,6 +24,7 @@ import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
@@ -86,9 +87,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.size());
|
||||
|
||||
@@ -108,9 +113,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockCatalog.getDocumentOutline()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isEmpty());
|
||||
verify(mockDocument).close();
|
||||
@@ -142,9 +151,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(childItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.size());
|
||||
|
||||
@@ -178,9 +191,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertNotNull(result);
|
||||
assertEquals(1, result.size());
|
||||
|
||||
|
||||
+5
@@ -116,6 +116,10 @@ public class ProprietaryUIDataController {
|
||||
LoginData data = new LoginData();
|
||||
Map<String, String> providerList = new HashMap<>();
|
||||
Security securityProps = applicationProperties.getSecurity();
|
||||
|
||||
// Add enableLogin flag so frontend doesn't need to call /app-config
|
||||
data.setEnableLogin(securityProps.getEnableLogin());
|
||||
|
||||
OAUTH2 oauth = securityProps.getOauth2();
|
||||
|
||||
if (oauth != null && oauth.getEnabled()) {
|
||||
@@ -448,6 +452,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
@Data
|
||||
public static class LoginData {
|
||||
private Boolean enableLogin;
|
||||
private Map<String, String> providerList;
|
||||
private String loginMethod;
|
||||
private boolean altLogin;
|
||||
|
||||
+41
-49
@@ -129,61 +129,53 @@ public class SecurityConfiguration {
|
||||
|
||||
@Bean
|
||||
public CorsConfigurationSource corsConfigurationSource() {
|
||||
// Read CORS allowed origins from settings
|
||||
if (applicationProperties.getSystem() != null
|
||||
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
|
||||
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
|
||||
List<String> configuredOrigins = null;
|
||||
if (applicationProperties.getSystem() != null) {
|
||||
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
|
||||
}
|
||||
|
||||
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
|
||||
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
|
||||
// Use setAllowedOriginPatterns for better wildcard and port support
|
||||
cfg.setAllowedOriginPatterns(allowedOrigins);
|
||||
CorsConfiguration cfg = new CorsConfiguration();
|
||||
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
|
||||
cfg.setAllowedOriginPatterns(configuredOrigins);
|
||||
log.debug(
|
||||
"CORS configured with allowed origin patterns from settings.yml: {}",
|
||||
allowedOrigins);
|
||||
|
||||
// Set allowed methods explicitly (including OPTIONS for preflight)
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
|
||||
// Set allowed headers explicitly
|
||||
cfg.setAllowedHeaders(
|
||||
List.of(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN"));
|
||||
|
||||
// Set exposed headers (headers that the browser can access)
|
||||
cfg.setExposedHeaders(
|
||||
List.of(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type"));
|
||||
|
||||
// Allow credentials (cookies, authorization headers)
|
||||
cfg.setAllowCredentials(true);
|
||||
|
||||
// Set max age for preflight cache
|
||||
cfg.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
configuredOrigins);
|
||||
} else {
|
||||
// No CORS origins configured - return null to disable CORS processing entirely
|
||||
// This avoids empty CORS policy that unexpectedly rejects preflights
|
||||
// Default to allowing all origins when nothing is configured
|
||||
cfg.setAllowedOriginPatterns(List.of("*"));
|
||||
log.info(
|
||||
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
|
||||
return null;
|
||||
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
|
||||
}
|
||||
|
||||
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
|
||||
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
|
||||
cfg.setAllowedHeaders(
|
||||
List.of(
|
||||
"Authorization",
|
||||
"Content-Type",
|
||||
"X-Requested-With",
|
||||
"Accept",
|
||||
"Origin",
|
||||
"X-API-KEY",
|
||||
"X-CSRF-TOKEN",
|
||||
"X-XSRF-TOKEN"));
|
||||
|
||||
cfg.setExposedHeaders(
|
||||
List.of(
|
||||
"WWW-Authenticate",
|
||||
"X-Total-Count",
|
||||
"X-Page-Number",
|
||||
"X-Page-Size",
|
||||
"Content-Disposition",
|
||||
"Content-Type"));
|
||||
|
||||
cfg.setAllowCredentials(true);
|
||||
cfg.setMaxAge(3600L);
|
||||
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", cfg);
|
||||
return source;
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
+2
-4
@@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.annotations.api.UserApi;
|
||||
import stirling.software.common.annotations.api.InviteApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.enumeration.Role;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
@@ -26,11 +26,9 @@ import stirling.software.proprietary.security.service.EmailService;
|
||||
import stirling.software.proprietary.security.service.TeamService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@UserApi
|
||||
@InviteApi
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/invite")
|
||||
public class InviteLinkController {
|
||||
|
||||
private final InviteTokenRepository inviteTokenRepository;
|
||||
|
||||
+2
-1
@@ -223,7 +223,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|
||||
|| trimmedUri.startsWith("/saml2")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/login")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout");
|
||||
|| trimmedUri.startsWith("/api/v1/auth/logout")
|
||||
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
|
||||
}
|
||||
|
||||
private enum UserLoginType {
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ repositories {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '1.4.0'
|
||||
version = '2.0.0'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Run nginx as non-root user
|
||||
pid /tmp/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,30 @@
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Extract"
|
||||
},
|
||||
"defaultApp": {
|
||||
"title": "Set as Default PDF App",
|
||||
"message": "Would you like to set Stirling PDF as your default PDF editor?",
|
||||
"description": "You can change this later in your system settings.",
|
||||
"notNow": "Not Now",
|
||||
"setDefault": "Set Default",
|
||||
"dismiss": "Dismiss",
|
||||
"prompt": {
|
||||
"title": "Set as Default PDF Editor",
|
||||
"message": "Make Stirling PDF your default application for opening PDF files."
|
||||
},
|
||||
"success": {
|
||||
"title": "Default App Set",
|
||||
"message": "Stirling PDF is now your default PDF editor"
|
||||
},
|
||||
"settingsOpened": {
|
||||
"title": "Settings Opened",
|
||||
"message": "Please select Stirling PDF in your system settings"
|
||||
},
|
||||
"error": {
|
||||
"title": "Error",
|
||||
"message": "Failed to set default PDF handler"
|
||||
}
|
||||
},
|
||||
"language": {
|
||||
"direction": "ltr"
|
||||
},
|
||||
@@ -332,6 +356,20 @@
|
||||
"mode": {
|
||||
"fullscreen": "Fullscreen",
|
||||
"sidebar": "Sidebar"
|
||||
},
|
||||
"defaultPdfEditor": "Default PDF editor",
|
||||
"defaultPdfEditorActive": "Stirling PDF is your default PDF editor",
|
||||
"defaultPdfEditorInactive": "Another application is set as default",
|
||||
"defaultPdfEditorChecking": "Checking...",
|
||||
"defaultPdfEditorSet": "Already Default",
|
||||
"setAsDefault": "Set as Default",
|
||||
"updates": {
|
||||
"title": "Software Updates",
|
||||
"description": "Check for updates and view version information",
|
||||
"currentVersion": "Current Version",
|
||||
"latestVersion": "Latest Version",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"viewDetails": "View Details"
|
||||
}
|
||||
},
|
||||
"hotkeys": {
|
||||
@@ -353,6 +391,37 @@
|
||||
"searchPlaceholder": "Search tools..."
|
||||
}
|
||||
},
|
||||
"update": {
|
||||
"modalTitle": "Update Available",
|
||||
"current": "Current Version",
|
||||
"latest": "Latest Version",
|
||||
"latestStable": "Latest Stable",
|
||||
"priorityLabel": "Priority",
|
||||
"recommendedAction": "Recommended Action",
|
||||
"breakingChangesDetected": "Breaking Changes Detected",
|
||||
"breakingChangesMessage": "Some versions contain breaking changes. Please review the migration guides below before updating.",
|
||||
"migrationGuides": "Migration Guides",
|
||||
"viewGuide": "View Guide",
|
||||
"loadingDetailedInfo": "Loading detailed information...",
|
||||
"close": "Close",
|
||||
"viewAllReleases": "View All Releases",
|
||||
"downloadLatest": "Download Latest",
|
||||
"availableUpdates": "Available Updates",
|
||||
"unableToLoadDetails": "Unable to load detailed information.",
|
||||
"version": "Version",
|
||||
"urgentUpdateAvailable": "Urgent Update",
|
||||
"updateAvailable": "Update Available",
|
||||
"releaseNotes": "Release Notes",
|
||||
"priority": {
|
||||
"urgent": "Urgent",
|
||||
"normal": "Normal",
|
||||
"minor": "Minor",
|
||||
"low": "Low"
|
||||
},
|
||||
"breakingChanges": "Breaking Changes",
|
||||
"breakingChangesDefault": "This version contains breaking changes.",
|
||||
"migrationGuide": "Migration Guide"
|
||||
},
|
||||
"changeCreds": {
|
||||
"title": "Change Credentials",
|
||||
"header": "Update Your Account Details",
|
||||
@@ -1397,6 +1466,93 @@
|
||||
},
|
||||
"submit": "Change"
|
||||
},
|
||||
"editTableOfContents": {
|
||||
"settings": {
|
||||
"title": "Bookmarks & outline",
|
||||
"replaceExisting": "Replace existing bookmarks (uncheck to append)",
|
||||
"replaceExistingHint": "When disabled, the new outline is appended after the current bookmarks."
|
||||
},
|
||||
"actions": {
|
||||
"source": "Load bookmarks",
|
||||
"selectedFile": "Loaded from {{file}}",
|
||||
"noFile": "Select a PDF to extract existing bookmarks.",
|
||||
"loadFromPdf": "Load from selected PDF",
|
||||
"importJson": "Import JSON",
|
||||
"importClipboard": "Paste JSON from clipboard",
|
||||
"export": "Export bookmarks",
|
||||
"exportJson": "Download JSON",
|
||||
"exportClipboard": "Copy JSON to clipboard",
|
||||
"clipboardUnavailable": "Clipboard access is not available in this browser."
|
||||
},
|
||||
"info": {
|
||||
"line1": "Each bookmark needs a descriptive title and the page it should open.",
|
||||
"line2": "Use child bookmarks to build a hierarchy for chapters, sections, or subsections.",
|
||||
"line3": "Import bookmarks from the selected PDF or from a JSON file to save time."
|
||||
},
|
||||
"workbench": {
|
||||
"empty": {
|
||||
"title": "Open the tool to start editing",
|
||||
"description": "Select the Edit Table of Contents tool to load its workspace."
|
||||
},
|
||||
"tabTitle": "Outline workspace",
|
||||
"subtitle": "Import bookmarks, build hierarchies, and apply the outline without cramped side panels.",
|
||||
"noFile": "No PDF selected",
|
||||
"fileLabel": "Changes will be applied to the currently selected PDF.",
|
||||
"filePrompt": "Select a PDF from your library or upload a new one to begin.",
|
||||
"changeFile": "Change PDF",
|
||||
"selectFile": "Select PDF"
|
||||
},
|
||||
"editor": {
|
||||
"heading": "Bookmark editor",
|
||||
"description": "Add, nest, and reorder bookmarks to craft your PDF outline.",
|
||||
"addTopLevel": "Add top-level bookmark",
|
||||
"empty": {
|
||||
"title": "No bookmarks yet",
|
||||
"description": "Import existing bookmarks or start by adding your first entry.",
|
||||
"action": "Add first bookmark"
|
||||
},
|
||||
"defaultTitle": "New bookmark",
|
||||
"defaultChildTitle": "Child bookmark",
|
||||
"defaultSiblingTitle": "New bookmark",
|
||||
"untitled": "Untitled bookmark",
|
||||
"childBadge": "Child",
|
||||
"pagePreview": "Page {{page}}",
|
||||
"field": {
|
||||
"title": "Bookmark title",
|
||||
"page": "Target page number"
|
||||
},
|
||||
"actions": {
|
||||
"toggle": "Toggle children",
|
||||
"addChild": "Add child bookmark",
|
||||
"addSibling": "Add sibling bookmark",
|
||||
"remove": "Remove bookmark"
|
||||
},
|
||||
"confirmRemove": "Remove this bookmark and all of its children?"
|
||||
},
|
||||
"messages": {
|
||||
"loadedTitle": "Bookmarks extracted",
|
||||
"loadedBody": "Existing bookmarks from the PDF were loaded into the editor.",
|
||||
"noBookmarks": "No bookmarks were found in the selected PDF.",
|
||||
"loadFailed": "Unable to extract bookmarks from the selected PDF.",
|
||||
"imported": "Bookmarks imported",
|
||||
"importedBody": "Your JSON outline replaced the current editor contents.",
|
||||
"importedClipboard": "Clipboard data replaced the current bookmark list.",
|
||||
"invalidJson": "Invalid JSON structure",
|
||||
"invalidJsonBody": "Please provide a valid bookmark JSON file and try again.",
|
||||
"exported": "JSON download ready",
|
||||
"copied": "Copied to clipboard",
|
||||
"copiedBody": "Bookmark JSON copied successfully.",
|
||||
"copyFailed": "Copy failed"
|
||||
},
|
||||
"error": {
|
||||
"failed": "Failed to update the table of contents"
|
||||
},
|
||||
"submit": "Apply table of contents",
|
||||
"results": {
|
||||
"title": "Updated PDF with bookmarks",
|
||||
"subtitle": "Download the processed file or undo the operation below."
|
||||
}
|
||||
},
|
||||
"removePages": {
|
||||
"tags": "Remove pages,delete pages",
|
||||
"title": "Remove Pages",
|
||||
@@ -3177,6 +3333,7 @@
|
||||
"rememberme": "Remember me",
|
||||
"invalid": "Invalid username or password.",
|
||||
"locked": "Your account has been locked.",
|
||||
"sessionExpired": "Your session has expired. Please sign in again.",
|
||||
"signinTitle": "Please sign in",
|
||||
"ssoSignIn": "Login via Single Sign-on",
|
||||
"oAuth2AutoCreateDisabled": "OAUTH2 Auto-Create User Disabled",
|
||||
@@ -3654,8 +3811,10 @@
|
||||
"help": "Help",
|
||||
"account": "Account",
|
||||
"config": "Config",
|
||||
"settings": "Settings",
|
||||
"adminSettings": "Admin Settings",
|
||||
"allTools": "All Tools",
|
||||
"allTools": "Tools",
|
||||
"reader": "Reader",
|
||||
"helpMenu": {
|
||||
"toolsTour": "Tools Tour",
|
||||
"toolsTourDesc": "Learn what the tools can do",
|
||||
@@ -3681,6 +3840,12 @@
|
||||
"saveSuccess": "Settings saved successfully",
|
||||
"save": "Save Changes",
|
||||
"restartRequired": "Restart Required",
|
||||
"loginRequired": "Login mode must be enabled to modify admin settings",
|
||||
"loginDisabled": {
|
||||
"title": "Login Mode Required",
|
||||
"message": "Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.",
|
||||
"readOnly": "The settings below show example values for reference. Enable login mode to view and edit actual configuration."
|
||||
},
|
||||
"restart": {
|
||||
"title": "Restart Required",
|
||||
"message": "Settings have been saved successfully. A server restart is required for the changes to take effect.",
|
||||
@@ -3751,6 +3916,12 @@
|
||||
"description": "Default producer for PDF metadata"
|
||||
}
|
||||
},
|
||||
"logoStyle": {
|
||||
"label": "Logo Style",
|
||||
"description": "Choose between the modern minimalist logo or the classic S icon",
|
||||
"classic": "Classic",
|
||||
"modern": "Modern"
|
||||
},
|
||||
"customPaths": {
|
||||
"label": "Custom Paths",
|
||||
"description": "Configure custom file system paths for pipeline processing and external tools",
|
||||
@@ -4784,7 +4955,7 @@
|
||||
"maybeLater": "Maybe Later",
|
||||
"dontShowAgain": "Don't Show Again"
|
||||
},
|
||||
"allTools": "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.",
|
||||
"allTools": "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
|
||||
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
|
||||
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
|
||||
"filesButton": "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
|
||||
@@ -4844,7 +5015,8 @@
|
||||
"admin": "Admin",
|
||||
"roleDescriptions": {
|
||||
"admin": "Can manage settings and invite members, with full administrative access.",
|
||||
"member": "Can view and edit shared files, but cannot manage workspace settings or members."
|
||||
"member": "Can view and edit shared files, but cannot manage workspace settings or members.",
|
||||
"user": "User"
|
||||
},
|
||||
"editRole": "Edit Role",
|
||||
"enable": "Enable",
|
||||
@@ -4925,6 +5097,7 @@
|
||||
"copied": "Link copied to clipboard",
|
||||
"success": "Invite link generated successfully",
|
||||
"successWithEmail": "Invite link generated and sent via email",
|
||||
"emailSent": "Invite link generated and sent via email",
|
||||
"emailFailed": "Invite link generated, but email failed",
|
||||
"emailFailedDetails": "Error: {0}. Please share the invite link manually.",
|
||||
"error": "Failed to generate invite link",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+11
@@ -643,6 +643,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-services"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0aa845ab21b847ee46954be761815f18f16469b29ef3ba250241b1b8bab659a"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -3941,6 +3950,8 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
name = "stirling-pdf"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-services",
|
||||
"log",
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
|
||||
@@ -31,3 +31,7 @@ tauri-plugin-fs = "2.4.4"
|
||||
tauri-plugin-single-instance = "2.0.1"
|
||||
tokio = { version = "1.0", features = ["time"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
core-services = "1.0"
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
use crate::utils::add_log;
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use std::process::Command;
|
||||
|
||||
/// Check if Stirling PDF is the default PDF handler
|
||||
#[tauri::command]
|
||||
pub fn is_default_pdf_handler() -> Result<bool, String> {
|
||||
add_log("🔍 Checking if app is default PDF handler".to_string());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
check_default_windows()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
check_default_macos()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
check_default_linux()
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to set/prompt for Stirling PDF as default PDF handler
|
||||
#[tauri::command]
|
||||
pub fn set_as_default_pdf_handler() -> Result<String, String> {
|
||||
add_log("⚙️ Attempting to set as default PDF handler".to_string());
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
set_default_windows()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
set_default_macos()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
set_default_linux()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Windows Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn check_default_windows() -> Result<bool, String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
// Query the default handler for .pdf extension
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", "assoc .pdf"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check default app: {}", e))?;
|
||||
|
||||
let assoc = String::from_utf8_lossy(&output.stdout);
|
||||
add_log(format!("Windows PDF association: {}", assoc.trim()));
|
||||
|
||||
// Get the ProgID for .pdf files
|
||||
if let Some(prog_id) = assoc.trim().strip_prefix(".pdf=") {
|
||||
// Query what application handles this ProgID
|
||||
let output = Command::new("cmd")
|
||||
.args(["/C", &format!("ftype {}", prog_id)])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to query file type: {}", e))?;
|
||||
|
||||
let ftype = String::from_utf8_lossy(&output.stdout);
|
||||
add_log(format!("Windows file type: {}", ftype.trim()));
|
||||
|
||||
// Check if it contains "Stirling" or our app name
|
||||
let is_default = ftype.to_lowercase().contains("stirling");
|
||||
Ok(is_default)
|
||||
} else {
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn set_default_windows() -> Result<String, String> {
|
||||
// On Windows 10+, we need to open the Default Apps settings
|
||||
// as programmatic setting requires a signed installer
|
||||
Command::new("cmd")
|
||||
.args(["/C", "start", "ms-settings:defaultapps"])
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open default apps settings: {}", e))?;
|
||||
|
||||
add_log("Opened Windows Default Apps settings".to_string());
|
||||
Ok("opened_settings".to_string())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// macOS Implementation (using LaunchServices framework)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn check_default_macos() -> Result<bool, String> {
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::string::{CFString, CFStringRef};
|
||||
use std::os::raw::c_int;
|
||||
|
||||
// Define the LSCopyDefaultRoleHandlerForContentType function
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
extern "C" {
|
||||
fn LSCopyDefaultRoleHandlerForContentType(
|
||||
content_type: CFStringRef,
|
||||
role: c_int,
|
||||
) -> CFStringRef;
|
||||
}
|
||||
|
||||
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
|
||||
|
||||
unsafe {
|
||||
// Query the default handler for "com.adobe.pdf" (PDF UTI - standard macOS identifier)
|
||||
let pdf_uti = CFString::new("com.adobe.pdf");
|
||||
let handler_ref = LSCopyDefaultRoleHandlerForContentType(pdf_uti.as_concrete_TypeRef(), K_LS_ROLES_ALL);
|
||||
|
||||
if handler_ref.is_null() {
|
||||
add_log("No default PDF handler found".to_string());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let handler = CFString::wrap_under_create_rule(handler_ref);
|
||||
let handler_str = handler.to_string();
|
||||
add_log(format!("macOS PDF handler: {}", handler_str));
|
||||
|
||||
// Check if it's our bundle identifier
|
||||
let is_default = handler_str == "stirling.pdf.dev";
|
||||
Ok(is_default)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_default_macos() -> Result<String, String> {
|
||||
use core_foundation::base::TCFType;
|
||||
use core_foundation::string::{CFString, CFStringRef};
|
||||
use std::os::raw::c_int;
|
||||
|
||||
// Define the LSSetDefaultRoleHandlerForContentType function
|
||||
#[link(name = "CoreServices", kind = "framework")]
|
||||
extern "C" {
|
||||
fn LSSetDefaultRoleHandlerForContentType(
|
||||
content_type: CFStringRef,
|
||||
role: c_int,
|
||||
handler_bundle_id: CFStringRef,
|
||||
) -> c_int; // OSStatus
|
||||
}
|
||||
|
||||
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
|
||||
|
||||
unsafe {
|
||||
// Set our app as the default handler for PDF files
|
||||
let pdf_uti = CFString::new("com.adobe.pdf");
|
||||
let our_bundle_id = CFString::new("stirling.pdf.dev");
|
||||
|
||||
let status = LSSetDefaultRoleHandlerForContentType(
|
||||
pdf_uti.as_concrete_TypeRef(),
|
||||
K_LS_ROLES_ALL,
|
||||
our_bundle_id.as_concrete_TypeRef(),
|
||||
);
|
||||
|
||||
if status == 0 {
|
||||
add_log("Successfully triggered default app dialog".to_string());
|
||||
Ok("set_successfully".to_string())
|
||||
} else {
|
||||
let error_msg = format!("LaunchServices returned status: {}", status);
|
||||
add_log(error_msg.clone());
|
||||
Err(error_msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Linux Implementation
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn check_default_linux() -> Result<bool, String> {
|
||||
// Use xdg-mime to check the default application for PDF files
|
||||
let output = Command::new("xdg-mime")
|
||||
.args(["query", "default", "application/pdf"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to check default app: {}", e))?;
|
||||
|
||||
let handler = String::from_utf8_lossy(&output.stdout);
|
||||
add_log(format!("Linux PDF handler: {}", handler.trim()));
|
||||
|
||||
// Check if it's our .desktop file
|
||||
let is_default = handler.trim() == "stirling-pdf.desktop";
|
||||
Ok(is_default)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn set_default_linux() -> Result<String, String> {
|
||||
// Use xdg-mime to set the default application for PDF files
|
||||
let result = Command::new("xdg-mime")
|
||||
.args(["default", "stirling-pdf.desktop", "application/pdf"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to set default app: {}", e))?;
|
||||
|
||||
if result.status.success() {
|
||||
add_log("Set as default PDF handler on Linux".to_string());
|
||||
Ok("set_successfully".to_string())
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&result.stderr);
|
||||
add_log(format!("Failed to set default: {}", error));
|
||||
Err(format!("Failed to set as default: {}", error))
|
||||
}
|
||||
}
|
||||
@@ -14,23 +14,11 @@ pub fn add_opened_file(file_path: String) {
|
||||
// Command to get opened file paths (if app was launched with files)
|
||||
#[tauri::command]
|
||||
pub async fn get_opened_files() -> Result<Vec<String>, String> {
|
||||
let mut all_files: Vec<String> = Vec::new();
|
||||
|
||||
// Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour)
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let pdf_files: Vec<String> = args.iter()
|
||||
.skip(1)
|
||||
.filter(|arg| std::path::Path::new(arg).exists())
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
all_files.extend(pdf_files);
|
||||
|
||||
// Add any files sent via events or other instances (macOS 'Open With Stirling' behaviour, also Windows/Linux extra files)
|
||||
{
|
||||
let opened_files = OPENED_FILES.lock().unwrap();
|
||||
all_files.extend(opened_files.clone());
|
||||
}
|
||||
// Get all files from the OPENED_FILES store
|
||||
// Command line args are processed in setup() callback and added to this store
|
||||
// Additional files from second instances or events are also added here
|
||||
let opened_files = OPENED_FILES.lock().unwrap();
|
||||
let all_files = opened_files.clone();
|
||||
|
||||
add_log(format!("📂 Returning {} opened file(s)", all_files.len()));
|
||||
Ok(all_files)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod backend;
|
||||
pub mod health;
|
||||
pub mod files;
|
||||
pub mod default_app;
|
||||
|
||||
pub use backend::{start_backend, cleanup_backend};
|
||||
pub use health::check_backend_health;
|
||||
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
|
||||
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
|
||||
|
||||
@@ -3,7 +3,16 @@ use tauri::{RunEvent, WindowEvent, Emitter, Manager};
|
||||
mod utils;
|
||||
mod commands;
|
||||
|
||||
use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file};
|
||||
use commands::{
|
||||
start_backend,
|
||||
check_backend_health,
|
||||
get_opened_files,
|
||||
clear_opened_files,
|
||||
cleanup_backend,
|
||||
add_opened_file,
|
||||
is_default_pdf_handler,
|
||||
set_as_default_pdf_handler,
|
||||
};
|
||||
use utils::{add_log, get_tauri_logs};
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -23,9 +32,6 @@ pub fn run() {
|
||||
// Store file for later retrieval (in case frontend isn't ready yet)
|
||||
add_opened_file(arg.clone());
|
||||
|
||||
// Also emit event for immediate handling if frontend is ready
|
||||
let _ = app.emit("file-opened", arg.clone());
|
||||
|
||||
// Bring the existing window to front
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
@@ -33,13 +39,34 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
||||
let _ = app.emit("files-changed", ());
|
||||
}))
|
||||
.setup(|_app| {
|
||||
add_log("🚀 Tauri app setup started".to_string());
|
||||
|
||||
// Process command line arguments on first launch
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
for arg in args.iter().skip(1) {
|
||||
if std::path::Path::new(arg).exists() {
|
||||
add_log(format!("📂 Initial file from command line: {}", arg));
|
||||
add_opened_file(arg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
add_log("🔍 DEBUG: Setup completed".to_string());
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
start_backend,
|
||||
check_backend_health,
|
||||
get_opened_files,
|
||||
clear_opened_files,
|
||||
get_tauri_logs,
|
||||
is_default_pdf_handler,
|
||||
set_as_default_pdf_handler,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
@@ -58,6 +85,7 @@ pub fn run() {
|
||||
#[cfg(target_os = "macos")]
|
||||
RunEvent::Opened { urls } => {
|
||||
add_log(format!("📂 Tauri file opened event: {:?}", urls));
|
||||
let mut added_files = false;
|
||||
for url in urls {
|
||||
let url_str = url.as_str();
|
||||
if url_str.starts_with("file://") {
|
||||
@@ -65,11 +93,14 @@ pub fn run() {
|
||||
if file_path.ends_with(".pdf") {
|
||||
add_log(format!("📂 Processing opened PDF: {}", file_path));
|
||||
add_opened_file(file_path.to_string());
|
||||
// Use unified event name for consistency across platforms
|
||||
let _ = app_handle.emit("file-opened", file_path.to_string());
|
||||
added_files = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Emit a generic notification that files were added (frontend will re-read storage)
|
||||
if added_files {
|
||||
let _ = app_handle.emit("files-changed", ());
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Only log unhandled events in debug mode to reduce noise
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
import { AppProviders } from "@app/components/AppProviders";
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
|
||||
@@ -16,8 +17,10 @@ export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<AppProviders>
|
||||
<HomePage />
|
||||
<OnboardingTour />
|
||||
<AppLayout>
|
||||
<HomePage />
|
||||
<OnboardingTour />
|
||||
</AppLayout>
|
||||
</AppProviders>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useBanner } from '@app/contexts/BannerContext';
|
||||
|
||||
interface AppLayoutProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* App layout wrapper that handles banner rendering and viewport sizing
|
||||
* Automatically adjusts child components to fit remaining space after banner
|
||||
*/
|
||||
export function AppLayout({ children }: AppLayoutProps) {
|
||||
const { banner } = useBanner();
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
.h-screen,
|
||||
.right-rail {
|
||||
height: 100% !important;
|
||||
}
|
||||
`}</style>
|
||||
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{banner}
|
||||
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext";
|
||||
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
|
||||
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
|
||||
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
|
||||
import { BannerProvider } from "@app/contexts/BannerContext";
|
||||
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
|
||||
import { useScarfTracking } from "@app/hooks/useScarfTracking";
|
||||
import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||
@@ -50,42 +51,44 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<PreferencesProvider>
|
||||
<RainbowThemeProvider>
|
||||
<ErrorBoundary>
|
||||
<OnboardingProvider>
|
||||
<AppConfigProvider
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<ScarfTrackingInitializer />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<BannerProvider>
|
||||
<OnboardingProvider>
|
||||
<AppConfigProvider
|
||||
retryOptions={appConfigRetryOptions}
|
||||
{...appConfigProviderProps}
|
||||
>
|
||||
<ScarfTrackingInitializer />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
<HotkeyProvider>
|
||||
<SidebarProvider>
|
||||
<ViewerProvider>
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
<TourOrchestrationProvider>
|
||||
<AdminTourOrchestrationProvider>
|
||||
{children}
|
||||
</AdminTourOrchestrationProvider>
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</OnboardingProvider>
|
||||
</ViewerProvider>
|
||||
</SidebarProvider>
|
||||
</HotkeyProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</OnboardingProvider>
|
||||
</BannerProvider>
|
||||
</ErrorBoundary>
|
||||
</RainbowThemeProvider>
|
||||
</PreferencesProvider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useMemo } from "react";
|
||||
import { TourProvider, useTour, type StepType } from '@reactour/tour';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -10,6 +10,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
|
||||
import '@app/components/onboarding/OnboardingTour.css';
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
// Enum case order defines order steps will appear
|
||||
enum TourStep {
|
||||
@@ -120,10 +121,10 @@ export default function OnboardingTour() {
|
||||
} = useAdminTourOrchestration();
|
||||
|
||||
// Define steps as object keyed by enum - TypeScript ensures all keys are present
|
||||
const stepsConfig: Record<TourStep, StepType> = {
|
||||
const stepsConfig: Record<TourStep, StepType> = useMemo(() => ({
|
||||
[TourStep.ALL_TOOLS]: {
|
||||
selector: '[data-tour="tool-panel"]',
|
||||
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
|
||||
position: 'center',
|
||||
padding: 0,
|
||||
action: () => {
|
||||
@@ -248,10 +249,10 @@ export default function OnboardingTour() {
|
||||
position: 'right',
|
||||
padding: 10,
|
||||
},
|
||||
};
|
||||
}), [t]);
|
||||
|
||||
// Define admin tour steps
|
||||
const adminStepsConfig: Record<AdminTourStep, StepType> = {
|
||||
const adminStepsConfig: Record<AdminTourStep, StepType> = useMemo(() => ({
|
||||
[AdminTourStep.WELCOME]: {
|
||||
selector: '[data-tour="config-button"]',
|
||||
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
|
||||
@@ -363,7 +364,7 @@ export default function OnboardingTour() {
|
||||
removeAllGlows();
|
||||
},
|
||||
},
|
||||
};
|
||||
}), [t]);
|
||||
|
||||
// Select steps based on tour type
|
||||
const steps = tourType === 'admin'
|
||||
@@ -416,7 +417,7 @@ export default function OnboardingTour() {
|
||||
}}
|
||||
/>
|
||||
<TourProvider
|
||||
key={tourType}
|
||||
key={`${tourType}-${i18n.language}`}
|
||||
steps={steps}
|
||||
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
|
||||
onClickClose={handleCloseTour}
|
||||
|
||||
@@ -35,20 +35,20 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
||||
|
||||
const iconNode = (
|
||||
<span className="iconContainer">
|
||||
<AppsIcon sx={{ fontSize: '2rem' }} />
|
||||
<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
||||
<Tooltip content={t("quickAccess.allTools", "Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
|
||||
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
|
||||
<ActionIcon
|
||||
component="a"
|
||||
href={navProps.href}
|
||||
onClick={handleNavClick}
|
||||
size={'lg'}
|
||||
size={isActive ? 'lg' : 'md'}
|
||||
variant="subtle"
|
||||
aria-label={t("quickAccess.allTools", "All Tools")}
|
||||
aria-label={t("quickAccess.allTools", "Tools")}
|
||||
style={{
|
||||
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
|
||||
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
|
||||
@@ -61,7 +61,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
|
||||
{iconNode}
|
||||
</ActionIcon>
|
||||
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
|
||||
{t("quickAccess.allTools", "All Tools")}
|
||||
{t("quickAccess.allTools", "Tools")}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@@ -143,16 +143,15 @@ const AppConfigModal: React.FC<AppConfigModalProps> = ({ opened, onClose }) => {
|
||||
<div
|
||||
key={item.key}
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
setActive(item.key);
|
||||
navigate(`/settings/${item.key}`);
|
||||
}
|
||||
// Allow navigation even when disabled - the content inside will be disabled
|
||||
setActive(item.key);
|
||||
navigate(`/settings/${item.key}`);
|
||||
}}
|
||||
className={`modal-nav-item ${isMobile ? 'mobile' : ''}`}
|
||||
style={{
|
||||
background: isActive ? colors.navItemActiveBg : 'transparent',
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
cursor: isDisabled ? 'not-allowed' : 'pointer',
|
||||
opacity: isDisabled ? 0.6 : 1,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
data-tour={`admin-${item.key}-nav`}
|
||||
>
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function FirstLoginModal({ opened, onPasswordChanged, username }:
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
await accountService.changePassword(currentPassword, newPassword);
|
||||
await accountService.changePasswordOnLogin(currentPassword, newPassword);
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface InfoBannerProps {
|
||||
icon: string;
|
||||
message: string;
|
||||
buttonText: string;
|
||||
buttonIcon?: string;
|
||||
onButtonClick: () => void;
|
||||
onDismiss: () => void;
|
||||
loading?: boolean;
|
||||
show?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic info banner component for displaying dismissible messages at the top of the app
|
||||
*/
|
||||
export const InfoBanner: React.FC<InfoBannerProps> = ({
|
||||
icon,
|
||||
message,
|
||||
buttonText,
|
||||
buttonIcon = 'check-circle-rounded',
|
||||
onButtonClick,
|
||||
onDismiss,
|
||||
loading = false,
|
||||
show = true,
|
||||
}) => {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
p="sm"
|
||||
radius={0}
|
||||
style={{
|
||||
background: 'var(--mantine-color-blue-0)',
|
||||
borderBottom: '1px solid var(--mantine-color-blue-2)',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Group gap="sm" align="center" wrap="nowrap">
|
||||
<LocalIcon icon={icon} width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)', flexShrink: 0 }} />
|
||||
<Text fw={500} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
|
||||
{message}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="xs"
|
||||
onClick={onButtonClick}
|
||||
loading={loading}
|
||||
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
aria-label="Dismiss"
|
||||
style={{ position: 'absolute', top: '50%', right: '0.5rem', transform: 'translateY(-50%)' }}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
|
||||
</ActionIcon>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
@@ -6,6 +6,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
import { useFileManager } from '@app/hooks/useFileManager';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addFiles } = useFileHandler();
|
||||
@@ -14,6 +16,9 @@ const LandingPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal } = useFilesModalContext();
|
||||
const [isUploadHover, setIsUploadHover] = React.useState(false);
|
||||
const logoPath = useLogoPath();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
|
||||
|
||||
const handleFileDrop = async (files: File[]) => {
|
||||
await addFiles(files);
|
||||
@@ -36,6 +41,22 @@ const LandingPage = () => {
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
// Determine if the user has any recent files (same source as File Manager)
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
(async () => {
|
||||
try {
|
||||
const files = await loadRecentFiles();
|
||||
if (isMounted) {
|
||||
setHasRecents((files?.length || 0) > 0);
|
||||
}
|
||||
} catch (_err) {
|
||||
if (isMounted) setHasRecents(false);
|
||||
}
|
||||
})();
|
||||
return () => { isMounted = false; };
|
||||
}, [loadRecentFiles]);
|
||||
|
||||
return (
|
||||
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
|
||||
{/* White PDF Page Background */}
|
||||
@@ -72,7 +93,7 @@ const LandingPage = () => {
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={colorScheme === 'dark' ? `${BASE_PATH}/branding/StirlingPDFLogoNoTextDark.svg` : `${BASE_PATH}/branding/StirlingPDFLogoNoTextLight.svg`}
|
||||
src={logoPath}
|
||||
alt="Stirling PDF Logo"
|
||||
style={{
|
||||
height: 'auto',
|
||||
@@ -117,59 +138,89 @@ const LandingPage = () => {
|
||||
}}
|
||||
onMouseLeave={() => setIsUploadHover(false)}
|
||||
>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
{/* Show both buttons only when recents exist; otherwise show a single Upload button */}
|
||||
{hasRecents && (
|
||||
<>
|
||||
<Button
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '2rem',
|
||||
height: '38px',
|
||||
paddingLeft: isUploadHover ? 0 : '1rem',
|
||||
paddingRight: isUploadHover ? 0 : '1rem',
|
||||
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
|
||||
minWidth: isUploadHover ? '58px' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleOpenFilesModal}
|
||||
onMouseEnter={() => setIsUploadHover(false)}
|
||||
>
|
||||
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
|
||||
{!isUploadHover && (
|
||||
<span>
|
||||
{t('landing.addFiles', 'Add Files')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
|
||||
minWidth: '58px',
|
||||
paddingLeft: isUploadHover ? '1rem' : 0,
|
||||
paddingRight: isUploadHover ? '1rem' : 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'width .5s ease, padding .5s ease'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
onMouseEnter={() => setIsUploadHover(true)}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
{isUploadHover && (
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!hasRecents && (
|
||||
<Button
|
||||
aria-label="Upload"
|
||||
style={{
|
||||
backgroundColor: 'var(--landing-button-bg)',
|
||||
color: 'var(--landing-button-color)',
|
||||
border: '1px solid var(--landing-button-border)',
|
||||
borderRadius: '1rem',
|
||||
height: '38px',
|
||||
width: '100%',
|
||||
minWidth: '58px',
|
||||
paddingLeft: '1rem',
|
||||
paddingRight: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={handleNativeUploadClick}
|
||||
>
|
||||
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
|
||||
<span style={{ marginLeft: '.5rem' }}>
|
||||
{t('landing.uploadFromComputer', 'Upload from computer')}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hidden file input for native file picker */}
|
||||
|
||||
@@ -272,8 +272,13 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
|
||||
<ScrollArea h={190} type="scroll">
|
||||
<div className={styles.languageGrid}>
|
||||
{languageOptions.map((option, index) => {
|
||||
// Enable languages with >90% translation completion
|
||||
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
|
||||
const enabledLanguages = [
|
||||
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
|
||||
'ru-RU', 'ja-JP', 'ko-KR', 'hu-HU', 'ga-IE', 'bg-BG', 'es-ES', 'hi-IN', 'hr-HR',
|
||||
'el-GR', 'ml-ML', 'pt-BR', 'pl-PL', 'pt-PT', 'sk-SK', 'sr-LATN-RS', 'no-NB',
|
||||
'th-TH', 'vi-VN', 'az-AZ', 'eu-ES', 'de-DE', 'sv-SE', 'it-IT', 'ca-CA', 'id-ID',
|
||||
'ro-RO', 'fr-FR', 'nl-NL', 'da-DK', 'cs-CZ'
|
||||
];
|
||||
const isDisabled = !enabledLanguages.includes(option.value);
|
||||
|
||||
return (
|
||||
|
||||
@@ -16,6 +16,7 @@ import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolBu
|
||||
import AppConfigModal from '@app/components/shared/AppConfigModal';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useOnboarding } from '@app/contexts/OnboardingContext';
|
||||
|
||||
import {
|
||||
isNavButtonActive,
|
||||
getNavButtonStyle,
|
||||
@@ -88,7 +89,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
onClick: () => handleClick(),
|
||||
'aria-label': config.name
|
||||
})}
|
||||
size={isActive ? (config.size || 'lg') : 'lg'}
|
||||
size={isActive ? 'lg' : 'md'}
|
||||
variant="subtle"
|
||||
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
|
||||
className={isActive ? 'activeIconScale' : ''}
|
||||
@@ -108,9 +109,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const mainButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'read',
|
||||
name: t("quickAccess.read", "Read"),
|
||||
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
|
||||
size: 'lg',
|
||||
name: t("quickAccess.reader", "Reader"),
|
||||
icon: <LocalIcon icon="menu-book-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'md',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
@@ -118,23 +119,11 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
handleReaderToggle();
|
||||
}
|
||||
},
|
||||
// {
|
||||
// id: 'sign',
|
||||
// name: t("quickAccess.sign", "Sign"),
|
||||
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
|
||||
// size: 'lg',
|
||||
// isRound: false,
|
||||
// type: 'navigation',
|
||||
// onClick: () => {
|
||||
// setActiveButton('sign');
|
||||
// handleToolSelect('sign');
|
||||
// }
|
||||
// },
|
||||
{
|
||||
id: 'automate',
|
||||
name: t("quickAccess.automate", "Automate"),
|
||||
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />,
|
||||
size: 'lg',
|
||||
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
|
||||
size: 'md',
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
onClick: () => {
|
||||
@@ -147,37 +136,36 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
const middleButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'files',
|
||||
name: t("quickAccess.files", "Files"),
|
||||
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />,
|
||||
icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
size: 'md',
|
||||
type: 'modal',
|
||||
onClick: handleFilesButtonClick
|
||||
},
|
||||
//TODO: Activity
|
||||
//{
|
||||
// id: 'activity',
|
||||
// name: t("quickAccess.activity", "Activity"),
|
||||
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
|
||||
// isRound: true,
|
||||
// size: 'lg',
|
||||
// type: 'navigation',
|
||||
// onClick: () => setActiveButton('activity')
|
||||
//},
|
||||
];
|
||||
|
||||
const middleButtons: ButtonConfig[] = [];
|
||||
//TODO: Activity
|
||||
//{
|
||||
// id: 'activity',
|
||||
// name: t("quickAccess.activity", "Activity"),
|
||||
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
|
||||
// isRound: true,
|
||||
// size: 'lg',
|
||||
// type: 'navigation',
|
||||
// onClick: () => setActiveButton('activity')
|
||||
//},
|
||||
|
||||
const bottomButtons: ButtonConfig[] = [
|
||||
{
|
||||
id: 'help',
|
||||
name: t("quickAccess.help", "Help"),
|
||||
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
|
||||
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
|
||||
isRound: true,
|
||||
size: 'lg',
|
||||
size: 'md',
|
||||
type: 'action',
|
||||
onClick: () => {
|
||||
// This will be overridden by the wrapper logic
|
||||
@@ -185,9 +173,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
|
||||
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'lg',
|
||||
name: t("quickAccess.settings", "Settings"),
|
||||
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'md',
|
||||
type: 'modal',
|
||||
onClick: () => {
|
||||
navigate('/settings/overview');
|
||||
@@ -200,7 +188,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
<div
|
||||
ref={ref}
|
||||
data-sidebar="quick-access"
|
||||
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
|
||||
style={{
|
||||
borderRight: '1px solid var(--border-default)'
|
||||
}}
|
||||
@@ -239,20 +227,30 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{/* Divider after main buttons */}
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
{/* Divider after main buttons (creates gap) */}
|
||||
{middleButtons.length === 0 && (
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Middle section */}
|
||||
<Stack gap="lg" align="center">
|
||||
{middleButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
{middleButtons.length > 0 && (
|
||||
<>
|
||||
<Divider
|
||||
size="xs"
|
||||
className="content-divider"
|
||||
/>
|
||||
<Stack gap="lg" align="center">
|
||||
{middleButtons.map((config, index) => (
|
||||
<React.Fragment key={config.id}>
|
||||
{renderNavButton(config, index)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spacer to push bottom buttons to bottom */}
|
||||
<div className="spacer" />
|
||||
|
||||
@@ -14,6 +14,8 @@ import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
||||
@@ -39,7 +41,7 @@ export default function RightRail() {
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { t } = useTranslation();
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
const { toggleTheme } = useRainbowThemeContext();
|
||||
const { toggleTheme, themeMode } = useRainbowThemeContext();
|
||||
const { buttons, actions, allButtonsDisabled } = useRightRail();
|
||||
|
||||
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
|
||||
@@ -195,7 +197,11 @@ export default function RightRail() {
|
||||
className="right-rail-icon"
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
|
||||
{themeMode === 'dark' ? (
|
||||
<LightModeIcon sx={{ fontSize: '1.5rem' }} />
|
||||
) : (
|
||||
<DarkModeIcon sx={{ fontSize: '1.5rem' }} />
|
||||
)}
|
||||
</ActionIcon>,
|
||||
t('rightRail.toggleTheme', 'Toggle Theme')
|
||||
)}
|
||||
|
||||
@@ -2,10 +2,10 @@ import React, { useState, useCallback, useMemo } from "react";
|
||||
import { SegmentedControl, Loader } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import rainbowStyles from '@app/styles/rainbow.module.css';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
||||
import GridViewIcon from "@mui/icons-material/GridView";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
|
||||
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
|
||||
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
||||
@@ -55,7 +55,7 @@ const createViewOptions = (
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="medium" />
|
||||
<InsertDriveFileIcon fontSize="medium" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
@@ -84,7 +84,7 @@ const createViewOptions = (
|
||||
{switchingTo === "pageEditor" ? (
|
||||
<Loader size="sm" />
|
||||
) : (
|
||||
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />
|
||||
<GridViewIcon fontSize="medium" />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { updateService, UpdateSummary, FullUpdateInfo, MachineInfo } from '@app/services/updateService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
|
||||
interface UpdateModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
currentVersion: string;
|
||||
updateSummary: UpdateSummary;
|
||||
machineInfo: MachineInfo;
|
||||
}
|
||||
|
||||
const UpdateModal: React.FC<UpdateModalProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
currentVersion,
|
||||
updateSummary,
|
||||
machineInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [fullUpdateInfo, setFullUpdateInfo] = useState<FullUpdateInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedVersions, setExpandedVersions] = useState<Set<number>>(new Set([0]));
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setLoading(true);
|
||||
setExpandedVersions(new Set([0]));
|
||||
updateService.getFullUpdateInfo(currentVersion, machineInfo).then((info) => {
|
||||
setFullUpdateInfo(info);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [opened, currentVersion, machineInfo]);
|
||||
|
||||
const toggleVersion = (index: number) => {
|
||||
setExpandedVersions((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(index)) {
|
||||
newSet.delete(index);
|
||||
} else {
|
||||
newSet.add(index);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority: string): string => {
|
||||
switch (priority?.toLowerCase()) {
|
||||
case 'urgent':
|
||||
return 'red';
|
||||
case 'normal':
|
||||
return 'blue';
|
||||
case 'minor':
|
||||
return 'cyan';
|
||||
case 'low':
|
||||
return 'gray';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityLabel = (priority: string): string => {
|
||||
const key = priority?.toLowerCase();
|
||||
return t(`update.priority.${key}`, priority || 'Normal');
|
||||
};
|
||||
|
||||
const downloadUrl = updateService.getDownloadUrl(machineInfo);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={
|
||||
<Text fw={600} size="lg">
|
||||
{t('update.modalTitle', 'Update Available')}
|
||||
</Text>
|
||||
}
|
||||
centered
|
||||
size="xl"
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: '75vh',
|
||||
overflowY: 'auto',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack gap="lg" pt="md">
|
||||
{/* Version Summary Section */}
|
||||
<Box>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
|
||||
<Stack gap={4} style={{ flex: 1 }}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
|
||||
{t('update.current', 'Current Version')}
|
||||
</Text>
|
||||
<Text fw={600} size="xl">
|
||||
{currentVersion}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4} style={{ flex: 1 }} ta="center">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
|
||||
{t('update.priorityLabel', 'Priority')}
|
||||
</Text>
|
||||
<Badge
|
||||
color={getPriorityColor(updateSummary.max_priority)}
|
||||
size="lg"
|
||||
variant="filled"
|
||||
style={{ alignSelf: 'center' }}
|
||||
>
|
||||
{getPriorityLabel(updateSummary.max_priority)}
|
||||
</Badge>
|
||||
</Stack>
|
||||
|
||||
<Stack gap={4} style={{ flex: 1 }} ta="right">
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
|
||||
{t('update.latest', 'Latest Version')}
|
||||
</Text>
|
||||
<Text fw={600} size="xl" c="blue">
|
||||
{updateSummary.latest_version}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{updateSummary.latest_stable_version && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-green-0)',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--mantine-color-green-2)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" justify="center">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('update.latestStable', 'Latest Stable')}:
|
||||
</Text>
|
||||
<Text size="sm" fw={600} c="green">
|
||||
{updateSummary.latest_stable_version}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Recommended action */}
|
||||
{updateSummary.recommended_action && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-blue-light)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--mantine-color-blue-outline)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<InfoOutlinedIcon style={{ fontSize: 18, color: 'var(--mantine-color-blue-filled)', marginTop: 2 }} />
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={600} mb={4} tt="uppercase">
|
||||
{t('update.recommendedAction', 'Recommended Action')}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{updateSummary.recommended_action}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Breaking changes warning */}
|
||||
{updateSummary.any_breaking && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-orange-light)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--mantine-color-orange-outline)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
<WarningAmberIcon style={{ fontSize: 18, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text size="xs" fw={600} mb={4} tt="uppercase">
|
||||
{t('update.breakingChangesDetected', 'Breaking Changes Detected')}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'update.breakingChangesMessage',
|
||||
'Some versions contain breaking changes. Please review the migration guides below before updating.'
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Migration guides */}
|
||||
{updateSummary.migration_guides && updateSummary.migration_guides.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Stack gap="xs">
|
||||
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
|
||||
{t('update.migrationGuides', 'Migration Guides')}
|
||||
</Text>
|
||||
{updateSummary.migration_guides.map((guide, idx) => (
|
||||
<Box
|
||||
key={idx}
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
background: 'var(--mantine-color-gray-0)',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Text fw={600} size="sm">
|
||||
{t('update.version', 'Version')} {guide.version}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{guide.notes}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
component="a"
|
||||
href={guide.url}
|
||||
target="_blank"
|
||||
variant="light"
|
||||
size="xs"
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('update.viewGuide', 'View Guide')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Version details */}
|
||||
<Divider />
|
||||
{loading ? (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<Loader size="md" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('update.loadingDetailedInfo', 'Loading detailed information...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
) : fullUpdateInfo && fullUpdateInfo.new_versions && fullUpdateInfo.new_versions.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
|
||||
{t('update.availableUpdates', 'Available Updates')}
|
||||
</Text>
|
||||
<Badge variant="light" color="gray">
|
||||
{fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{fullUpdateInfo.new_versions.map((version, index) => {
|
||||
const isExpanded = expandedVersions.has(index);
|
||||
return (
|
||||
<Box
|
||||
key={index}
|
||||
style={{
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
p="md"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: isExpanded ? 'var(--mantine-color-gray-0)' : 'transparent',
|
||||
transition: 'background 0.15s ease',
|
||||
}}
|
||||
onClick={() => toggleVersion(index)}
|
||||
>
|
||||
<Group gap="md" style={{ flex: 1 }}>
|
||||
<Box>
|
||||
<Text fw={600} size="sm" c="dimmed" mb={2}>
|
||||
{t('update.version', 'Version')}
|
||||
</Text>
|
||||
<Text fw={700} size="lg">
|
||||
{version.version}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge color={getPriorityColor(version.priority)} size="md">
|
||||
{getPriorityLabel(version.priority)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v${version.version}`}
|
||||
target="_blank"
|
||||
variant="light"
|
||||
size="xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('update.releaseNotes', 'Release Notes')}
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<ExpandLessIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||
) : (
|
||||
<ExpandMoreIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Box p="md" pt={0} style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}>
|
||||
<Stack gap="md" mt="md">
|
||||
<Box>
|
||||
<Text fw={600} size="sm" mb={6}>
|
||||
{version.announcement.title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
|
||||
{version.announcement.message}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{version.compatibility.breaking_changes && (
|
||||
<Box
|
||||
style={{
|
||||
background: 'var(--mantine-color-orange-light)',
|
||||
padding: '12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid var(--mantine-color-orange-outline)',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="flex-start" wrap="nowrap" mb="xs">
|
||||
<WarningAmberIcon style={{ fontSize: 16, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
|
||||
<Text size="xs" fw={600} tt="uppercase">
|
||||
{t('update.breakingChanges', 'Breaking Changes')}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" mb="xs">
|
||||
{version.compatibility.breaking_description ||
|
||||
t('update.breakingChangesDefault', 'This version contains breaking changes.')}
|
||||
</Text>
|
||||
{version.compatibility.migration_guide_url && (
|
||||
<Button
|
||||
component="a"
|
||||
href={version.compatibility.migration_guide_url}
|
||||
target="_blank"
|
||||
variant="light"
|
||||
color="orange"
|
||||
size="xs"
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
|
||||
>
|
||||
{t('update.migrationGuide', 'Migration Guide')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{/* Action buttons */}
|
||||
<Divider />
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('update.close', 'Close')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
component="a"
|
||||
href="https://github.com/Stirling-Tools/Stirling-PDF/releases"
|
||||
target="_blank"
|
||||
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t('update.viewAllReleases', 'View All Releases')}
|
||||
</Button>
|
||||
{downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl}
|
||||
target="_blank"
|
||||
color="green"
|
||||
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||
>
|
||||
{t('update.downloadLatest', 'Download Latest')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateModal;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Alert, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface LoginRequiredBannerProps {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner component that displays when login mode is required but not enabled
|
||||
* Shows prominent warning that settings are read-only
|
||||
*/
|
||||
export default function LoginRequiredBanner({ show }: LoginRequiredBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<Alert
|
||||
icon={<LocalIcon icon="lock-rounded" width={20} height={20} />}
|
||||
title={t('admin.settings.loginDisabled.title', 'Login Mode Required')}
|
||||
color="blue"
|
||||
variant="light"
|
||||
styles={{
|
||||
root: {
|
||||
borderLeft: '4px solid var(--mantine-color-blue-6)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('admin.settings.loginDisabled.message', 'Login mode must be enabled to modify admin settings. Please set SECURITY_ENABLELOGIN=true in your environment or security.enableLogin: true in settings.yml, then restart the server.')}
|
||||
</Text>
|
||||
<Text size="sm" fw={600} mt="xs" c="dimmed">
|
||||
{t('admin.settings.loginDisabled.readOnly', 'The settings below show example values for reference. Enable login mode to view and edit actual configuration.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -2,18 +2,6 @@ import React from 'react';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
|
||||
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
|
||||
import AdminGeneralSection from '@app/components/shared/config/configSections/AdminGeneralSection';
|
||||
import AdminSecuritySection from '@app/components/shared/config/configSections/AdminSecuritySection';
|
||||
import AdminConnectionsSection from '@app/components/shared/config/configSections/AdminConnectionsSection';
|
||||
import AdminPrivacySection from '@app/components/shared/config/configSections/AdminPrivacySection';
|
||||
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
|
||||
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
|
||||
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
|
||||
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
|
||||
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
|
||||
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
|
||||
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
|
||||
import AdminUsageSection from '@app/components/shared/config/configSections/AdminUsageSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -40,8 +28,8 @@ export interface ConfigColors {
|
||||
}
|
||||
|
||||
export const createConfigNavSections = (
|
||||
isAdmin: boolean = false,
|
||||
runningEE: boolean = false,
|
||||
_isAdmin: boolean = false,
|
||||
_runningEE: boolean = false,
|
||||
_loginEnabled: boolean = false
|
||||
): ConfigNavSection[] => {
|
||||
const sections: ConfigNavSection[] = [
|
||||
@@ -64,112 +52,5 @@ export const createConfigNavSections = (
|
||||
},
|
||||
];
|
||||
|
||||
// Add Admin sections if user is admin
|
||||
if (isAdmin) {
|
||||
// Configuration
|
||||
sections.push({
|
||||
title: 'Configuration',
|
||||
items: [
|
||||
{
|
||||
key: 'adminGeneral',
|
||||
label: 'System Settings',
|
||||
icon: 'settings-rounded',
|
||||
component: <AdminGeneralSection />
|
||||
},
|
||||
{
|
||||
key: 'adminFeatures',
|
||||
label: 'Features',
|
||||
icon: 'extension-rounded',
|
||||
component: <AdminFeaturesSection />
|
||||
},
|
||||
{
|
||||
key: 'adminEndpoints',
|
||||
label: 'Endpoints',
|
||||
icon: 'api-rounded',
|
||||
component: <AdminEndpointsSection />
|
||||
},
|
||||
{
|
||||
key: 'adminDatabase',
|
||||
label: 'Database',
|
||||
icon: 'storage-rounded',
|
||||
component: <AdminDatabaseSection />
|
||||
},
|
||||
{
|
||||
key: 'adminAdvanced',
|
||||
label: 'Advanced',
|
||||
icon: 'tune-rounded',
|
||||
component: <AdminAdvancedSection />
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Security & Authentication
|
||||
sections.push({
|
||||
title: 'Security & Authentication',
|
||||
items: [
|
||||
{
|
||||
key: 'adminSecurity',
|
||||
label: 'Security',
|
||||
icon: 'shield-rounded',
|
||||
component: <AdminSecuritySection />
|
||||
},
|
||||
{
|
||||
key: 'adminConnections',
|
||||
label: 'Connections',
|
||||
icon: 'link-rounded',
|
||||
component: <AdminConnectionsSection />
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Licensing & Analytics
|
||||
sections.push({
|
||||
title: 'Licensing & Analytics',
|
||||
items: [
|
||||
{
|
||||
key: 'adminPremium',
|
||||
label: 'Premium',
|
||||
icon: 'star-rounded',
|
||||
component: <AdminPremiumSection />
|
||||
},
|
||||
{
|
||||
key: 'adminAudit',
|
||||
label: 'Audit',
|
||||
icon: 'fact-check-rounded',
|
||||
component: <AdminAuditSection />,
|
||||
disabled: !runningEE,
|
||||
disabledTooltip: 'Requires Enterprise license'
|
||||
},
|
||||
{
|
||||
key: 'adminUsage',
|
||||
label: 'Usage Analytics',
|
||||
icon: 'analytics-rounded',
|
||||
component: <AdminUsageSection />,
|
||||
disabled: !runningEE,
|
||||
disabledTooltip: 'Requires Enterprise license'
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Policies & Privacy
|
||||
sections.push({
|
||||
title: 'Policies & Privacy',
|
||||
items: [
|
||||
{
|
||||
key: 'adminLegal',
|
||||
label: 'Legal',
|
||||
icon: 'gavel-rounded',
|
||||
component: <AdminLegalSection />
|
||||
},
|
||||
{
|
||||
key: 'adminPrivacy',
|
||||
label: 'Privacy',
|
||||
icon: 'visibility-rounded',
|
||||
component: <AdminPrivacySection />
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon } from '@mantine/core';
|
||||
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon, Button, Badge, Alert } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import type { ToolPanelMode } from '@app/constants/toolPanel';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { updateService, UpdateSummary } from '@app/services/updateService';
|
||||
import UpdateModal from '@app/components/shared/UpdateModal';
|
||||
|
||||
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
|
||||
const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed';
|
||||
@@ -22,12 +24,44 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
// Check localStorage on mount
|
||||
return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
|
||||
});
|
||||
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(null);
|
||||
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||
const [checkingUpdate, setCheckingUpdate] = useState(false);
|
||||
|
||||
// Sync local state with preference changes
|
||||
useEffect(() => {
|
||||
setFileLimitInput(preferences.autoUnzipFileLimit);
|
||||
}, [preferences.autoUnzipFileLimit]);
|
||||
|
||||
// Check for updates on mount
|
||||
useEffect(() => {
|
||||
if (config?.appVersion && config?.machineType) {
|
||||
checkForUpdate();
|
||||
}
|
||||
}, [config?.appVersion, config?.machineType]);
|
||||
|
||||
const checkForUpdate = async () => {
|
||||
if (!config?.appVersion || !config?.machineType) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCheckingUpdate(true);
|
||||
const machineInfo = {
|
||||
machineType: config.machineType,
|
||||
activeSecurity: config.activeSecurity ?? false,
|
||||
licenseType: config.license ?? 'NORMAL',
|
||||
};
|
||||
|
||||
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
|
||||
if (summary) {
|
||||
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
|
||||
if (isNewerVersion) {
|
||||
setUpdateSummary(summary);
|
||||
}
|
||||
}
|
||||
setCheckingUpdate(false);
|
||||
};
|
||||
|
||||
// Check if login is disabled
|
||||
const loginDisabled = !config?.enableLogin;
|
||||
|
||||
@@ -170,6 +204,108 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Update Check Section */}
|
||||
{config?.appVersion && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text fw={600} size="sm">
|
||||
{t('settings.general.updates.title', 'Software Updates')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.updates.description', 'Check for updates and view version information')}
|
||||
</Text>
|
||||
</div>
|
||||
{updateSummary && (
|
||||
<Badge
|
||||
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
|
||||
variant="filled"
|
||||
>
|
||||
{updateSummary.max_priority === 'urgent'
|
||||
? t('update.urgentUpdateAvailable', 'Urgent Update')
|
||||
: t('update.updateAvailable', 'Update Available')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<div>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('settings.general.updates.currentVersion', 'Current Version')}:{' '}
|
||||
<Text component="span" fw={500}>
|
||||
{config.appVersion}
|
||||
</Text>
|
||||
</Text>
|
||||
{updateSummary && (
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{t('settings.general.updates.latestVersion', 'Latest Version')}:{' '}
|
||||
<Text component="span" fw={500} c="blue">
|
||||
{updateSummary.latest_version}
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
onClick={checkForUpdate}
|
||||
loading={checkingUpdate}
|
||||
leftSection={<LocalIcon icon="refresh-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('settings.general.updates.checkForUpdates', 'Check for Updates')}
|
||||
</Button>
|
||||
{updateSummary && (
|
||||
<Button
|
||||
size="sm"
|
||||
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
|
||||
onClick={() => setUpdateModalOpened(true)}
|
||||
leftSection={<LocalIcon icon="system-update-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('settings.general.updates.viewDetails', 'View Details')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{updateSummary?.any_breaking && (
|
||||
<Alert
|
||||
color="orange"
|
||||
title={t('update.breakingChangesDetected', 'Breaking Changes Detected')}
|
||||
styles={{
|
||||
title: { fontWeight: 600 }
|
||||
}}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'update.breakingChangesMessage',
|
||||
'Some versions contain breaking changes. Please review the migration guides before updating.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{/* Update Modal */}
|
||||
{updateSummary && config?.appVersion && config?.machineType && (
|
||||
<UpdateModal
|
||||
opened={updateModalOpened}
|
||||
onClose={() => setUpdateModalOpened(false)}
|
||||
currentVersion={config.appVersion}
|
||||
updateSummary={updateSummary}
|
||||
machineInfo={{
|
||||
machineType: config.machineType,
|
||||
activeSecurity: config.activeSecurity ?? false,
|
||||
licenseType: config.license ?? 'NORMAL',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ProviderCardProps {
|
||||
settings?: Record<string, any>;
|
||||
onSave?: (settings: Record<string, any>) => void;
|
||||
onDisconnect?: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ProviderCard({
|
||||
@@ -18,6 +19,7 @@ export default function ProviderCard({
|
||||
settings = {},
|
||||
onSave,
|
||||
onDisconnect,
|
||||
disabled = false,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
@@ -39,6 +41,7 @@ export default function ProviderCard({
|
||||
};
|
||||
|
||||
const handleFieldChange = (key: string, value: any) => {
|
||||
if (disabled) return; // Block changes when disabled
|
||||
setLocalSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
@@ -63,6 +66,7 @@ export default function ProviderCard({
|
||||
<Switch
|
||||
checked={value || false}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -76,6 +80,7 @@ export default function ProviderCard({
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -88,6 +93,7 @@ export default function ProviderCard({
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -100,6 +106,7 @@ export default function ProviderCard({
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={(e) => handleFieldChange(field.key, e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -174,11 +181,12 @@ export default function ProviderCard({
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={onDisconnect}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('admin.settings.connections.disconnect', 'Disconnect')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
<Button size="sm" onClick={handleSave} disabled={disabled}>
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
|
||||
*
|
||||
* When a user selects a tool from the All Tools list, this component displays the tool's
|
||||
* When a user selects a tool from the Tools list, this component displays the tool's
|
||||
* icon and name at the top of the navigation bar. It provides a quick way to see which
|
||||
* tool is currently active and offers a back button to return to the All Tools list.
|
||||
* tool is currently active and offers a back button to return to the Tools list.
|
||||
*
|
||||
* Features:
|
||||
* - Shows tool icon and name when a tool is selected
|
||||
* - Hover to reveal back arrow for returning to All Tools
|
||||
* - Hover to reveal back arrow for returning to Tools
|
||||
* - Smooth slide-down/slide-up animations
|
||||
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
|
||||
*/
|
||||
@@ -149,7 +149,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
||||
handleBackToTools();
|
||||
});
|
||||
}}
|
||||
size={'xl'}
|
||||
size={'lg'}
|
||||
variant="subtle"
|
||||
onMouseEnter={() => setIsBackHover(true)}
|
||||
onMouseLeave={() => setIsBackHover(false)}
|
||||
@@ -165,7 +165,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
|
||||
>
|
||||
<span className="iconContainer">
|
||||
{isBackHover ? (
|
||||
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} />
|
||||
<ArrowBackRoundedIcon sx={{ fontSize: '1.875rem' }} />
|
||||
) : (
|
||||
indicatorTool.icon
|
||||
)}
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
.activeIconScale {
|
||||
transform: scale(1.3);
|
||||
transform: scale(1);
|
||||
transition: transform 0.2s;
|
||||
z-index: 1;
|
||||
width: calc(1.75rem + 10px) !important;
|
||||
height: calc(1.75rem + 10px) !important;
|
||||
}
|
||||
|
||||
.activeIconScale .iconContainer {
|
||||
width: calc(1.5rem + 10px);
|
||||
height: calc(1.5rem + 10px);
|
||||
}
|
||||
|
||||
.activeIconScale .iconContainer svg,
|
||||
.activeIconScale .iconContainer img {
|
||||
width: calc(1.25rem + 10px) !important;
|
||||
height: calc(1.25rem + 10px) !important;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
transition: width 0.2s, height 0.2s;
|
||||
}
|
||||
|
||||
/* Action icon styles */
|
||||
@@ -24,9 +38,9 @@
|
||||
/* Main container styles */
|
||||
.quick-access-bar-main {
|
||||
background-color: var(--bg-muted);
|
||||
width: 5rem;
|
||||
min-width: 5rem;
|
||||
max-width: 5rem;
|
||||
width: 4rem;
|
||||
min-width: 4rem;
|
||||
max-width: 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -34,9 +48,9 @@
|
||||
/* Rainbow mode container */
|
||||
.quick-access-bar-main.rainbow-mode {
|
||||
background-color: var(--bg-muted);
|
||||
width: 5rem;
|
||||
min-width: 5rem;
|
||||
max-width: 5rem;
|
||||
width: 4rem;
|
||||
min-width: 4rem;
|
||||
max-width: 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -57,7 +71,7 @@
|
||||
|
||||
/* Nav header divider */
|
||||
.nav-header-divider {
|
||||
width: 3.75rem;
|
||||
width: 3rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
@@ -85,7 +99,7 @@
|
||||
|
||||
/* Overflow divider */
|
||||
.overflow-divider {
|
||||
width: 3.75rem;
|
||||
width: 3rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
@@ -143,7 +157,7 @@
|
||||
|
||||
/* Content divider */
|
||||
.content-divider {
|
||||
width: 3.75rem;
|
||||
width: 3rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
@@ -241,7 +255,7 @@
|
||||
|
||||
/* Divider that animates growing from top */
|
||||
.current-tool-divider {
|
||||
width: 3.75rem;
|
||||
width: 3rem;
|
||||
border-color: var(--color-gray-300);
|
||||
margin: 0.5rem auto 0.5rem auto;
|
||||
transform-origin: top;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import { useFocusTrap } from '@app/hooks/useFocusTrap';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import '@app/components/tools/ToolPanel.css';
|
||||
import { ToolPanelGeometry } from '@app/hooks/tools/useToolPanelGeometry';
|
||||
@@ -51,9 +52,7 @@ const FullscreenToolSurface = ({
|
||||
useFocusTrap(surfaceRef, !isExiting);
|
||||
|
||||
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
|
||||
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
|
||||
colorScheme === "dark" ? "Dark" : "Light"
|
||||
}.svg`;
|
||||
const brandIconSrc = useLogoPath();
|
||||
const brandTextSrc = `${BASE_PATH}/branding/StirlingPDFLogo${
|
||||
colorScheme === "dark" ? "White" : "Black"
|
||||
}Text.svg`;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
@@ -8,11 +8,10 @@ import type { SubcategoryGroup } from "@app/hooks/useToolSections";
|
||||
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
|
||||
import NoToolsFound from "@app/components/tools/shared/NoToolsFound";
|
||||
import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons";
|
||||
import Badge from "@app/components/shared/Badge";
|
||||
import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader";
|
||||
import ToolButton from "@app/components/tools/toolPicker/ToolButton";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
@@ -23,49 +22,8 @@ interface ToolPickerProps {
|
||||
|
||||
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
|
||||
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
|
||||
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
const quickHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const allHeaderRef = useRef<HTMLDivElement>(null);
|
||||
const quickAccessRef = useRef<HTMLDivElement>(null);
|
||||
const allToolsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Keep header heights in sync with any dynamic size changes
|
||||
useLayoutEffect(() => {
|
||||
const update = () => {
|
||||
if (quickHeaderRef.current) {
|
||||
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight || 0);
|
||||
}
|
||||
if (allHeaderRef.current) {
|
||||
setAllHeaderHeight(allHeaderRef.current.offsetHeight || 0);
|
||||
}
|
||||
};
|
||||
|
||||
update();
|
||||
|
||||
// Update on window resize
|
||||
window.addEventListener("resize", update);
|
||||
|
||||
// Update on element resize (e.g., font load, badge count change, zoom)
|
||||
const observers: ResizeObserver[] = [];
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
const observe = (el: HTMLDivElement | null, cb: () => void) => {
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => cb());
|
||||
ro.observe(el);
|
||||
observers.push(ro);
|
||||
};
|
||||
observe(quickHeaderRef.current, update);
|
||||
observe(allHeaderRef.current, update);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
observers.forEach(o => o.disconnect());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const { sections: visibleSections } = useToolSections(filteredTools);
|
||||
const { favoriteTools, toolRegistry } = useToolWorkflow();
|
||||
@@ -84,31 +42,25 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
return items;
|
||||
}, [quickSection]);
|
||||
|
||||
const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]);
|
||||
const allSection = useMemo(
|
||||
() => visibleSections.find(s => s.key === 'all'),
|
||||
[visibleSections]
|
||||
);
|
||||
|
||||
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
|
||||
const container = scrollableRef.current;
|
||||
const target = ref.current;
|
||||
if (container && target) {
|
||||
const stackedOffset = ref === allToolsRef
|
||||
? (quickHeaderHeight + allHeaderHeight)
|
||||
: quickHeaderHeight;
|
||||
const top = target.offsetTop - container.offsetTop - (stackedOffset || 0);
|
||||
container.scrollTo({
|
||||
top: Math.max(0, top),
|
||||
behavior: "smooth"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Build flat list by subcategory for search mode
|
||||
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
|
||||
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
|
||||
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
|
||||
const headerTextStyle: React.CSSProperties = {
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 500,
|
||||
padding: "0.5rem 0 0.25rem 0.5rem",
|
||||
textTransform: "none",
|
||||
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
|
||||
opacity: 0.7
|
||||
};
|
||||
const toTitleCase = (s: string) =>
|
||||
s.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase());
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -141,110 +93,55 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{quickSection && (
|
||||
<>
|
||||
<div
|
||||
ref={quickHeaderRef}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--tool-header-bg)",
|
||||
color: "var(--tool-header-text)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
onClick={() => scrollTo(quickAccessRef)}
|
||||
>
|
||||
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
|
||||
<Badge>
|
||||
{recommendedCount}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Box ref={quickAccessRef} w="100%" my="sm">
|
||||
<Stack p="sm" gap="xs">
|
||||
{favoriteToolItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
<SubcategoryHeader label={t('toolPanel.fullscreen.favorites', 'Favourites')} mt={0} />
|
||||
<div>
|
||||
{favoriteToolItems.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`fav-${id}`}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{recommendedItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
<SubcategoryHeader label={t('toolPanel.fullscreen.recommended', 'Recommended')} />
|
||||
<div>
|
||||
{recommendedItems.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`rec-${id}`}
|
||||
id={id as ToolId}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
{/* Flat list: favorites and recommended first, then all subcategories */}
|
||||
<Stack p="sm" gap="xs">
|
||||
{favoriteToolItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
<div style={headerTextStyle}>
|
||||
{t('toolPanel.fullscreen.favorites', 'Favourites')}
|
||||
</div>
|
||||
<div>
|
||||
{favoriteToolItems.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`fav-${id}`}
|
||||
id={id}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{allSection && (
|
||||
<>
|
||||
<div
|
||||
ref={allHeaderRef}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: quickSection ? quickHeaderHeight -1 : 0,
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 600,
|
||||
background: "var(--tool-header-bg)",
|
||||
color: "var(--tool-header-text)",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
}}
|
||||
onClick={() => scrollTo(allToolsRef)}
|
||||
>
|
||||
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
|
||||
<Badge>
|
||||
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Box ref={allToolsRef} w="100%">
|
||||
<Stack p="sm" gap="xs">
|
||||
{allSection?.subcategories.map((sc: SubcategoryGroup) =>
|
||||
renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true)
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
{recommendedItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
<div style={headerTextStyle}>
|
||||
{t('toolPanel.fullscreen.recommended', 'Recommended')}
|
||||
</div>
|
||||
<div>
|
||||
{recommendedItems.map(({ id, tool }) => (
|
||||
<ToolButton
|
||||
key={`rec-${id}`}
|
||||
id={id as ToolId}
|
||||
tool={tool}
|
||||
isSelected={selectedToolKey === id}
|
||||
onSelect={onSelect}
|
||||
hasStars
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
{allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
|
||||
<Box key={sc.subcategoryId} w="100%">
|
||||
<div style={headerTextStyle}>
|
||||
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
|
||||
</div>
|
||||
{renderToolButtons(t, sc, selectedToolKey, onSelect, false, false, undefined, true)}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{!quickSection && !allSection && <NoToolsFound />}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export default function ToolSelector({
|
||||
// If no sections, create a simple group from filtered tools
|
||||
if (baseFilteredTools.length > 0) {
|
||||
return [{
|
||||
name: 'All Tools',
|
||||
name: 'Tools',
|
||||
subcategoryId: 'all' as any,
|
||||
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
|
||||
}];
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Flex,
|
||||
Group,
|
||||
NumberInput,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BookmarkNode, createBookmarkNode } from '@app/utils/editTableOfContents';
|
||||
|
||||
interface BookmarkEditorProps {
|
||||
bookmarks: BookmarkNode[];
|
||||
onChange: (bookmarks: BookmarkNode[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const updateTree = (
|
||||
nodes: BookmarkNode[],
|
||||
targetId: string,
|
||||
updater: (bookmark: BookmarkNode) => BookmarkNode,
|
||||
): BookmarkNode[] => {
|
||||
return nodes.map(node => {
|
||||
if (node.id === targetId) {
|
||||
return updater(node);
|
||||
}
|
||||
|
||||
if (node.children.length === 0) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const updatedChildren = updateTree(node.children, targetId, updater);
|
||||
if (updatedChildren !== node.children) {
|
||||
return { ...node, children: updatedChildren };
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromTree = (nodes: BookmarkNode[], targetId: string): BookmarkNode[] => {
|
||||
return nodes
|
||||
.filter(node => node.id !== targetId)
|
||||
.map(node => ({
|
||||
...node,
|
||||
children: removeFromTree(node.children, targetId),
|
||||
}));
|
||||
};
|
||||
|
||||
const addChildToTree = (
|
||||
nodes: BookmarkNode[],
|
||||
parentId: string,
|
||||
child: BookmarkNode,
|
||||
): { nodes: BookmarkNode[]; added: boolean } => {
|
||||
let added = false;
|
||||
const next = nodes.map(node => {
|
||||
if (node.id === parentId) {
|
||||
added = true;
|
||||
return { ...node, expanded: true, children: [...node.children, child] };
|
||||
}
|
||||
|
||||
if (node.children.length === 0) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const result = addChildToTree(node.children, parentId, child);
|
||||
if (result.added) {
|
||||
added = true;
|
||||
return { ...node, children: result.nodes };
|
||||
}
|
||||
|
||||
return node;
|
||||
});
|
||||
|
||||
return { nodes: added ? next : nodes, added };
|
||||
};
|
||||
|
||||
const addSiblingInTree = (
|
||||
nodes: BookmarkNode[],
|
||||
targetId: string,
|
||||
sibling: BookmarkNode,
|
||||
): { nodes: BookmarkNode[]; added: boolean } => {
|
||||
let added = false;
|
||||
const result: BookmarkNode[] = [];
|
||||
|
||||
nodes.forEach(node => {
|
||||
let currentNode = node;
|
||||
|
||||
if (!added && node.children.length > 0) {
|
||||
const childResult = addSiblingInTree(node.children, targetId, sibling);
|
||||
if (childResult.added) {
|
||||
added = true;
|
||||
currentNode = { ...node, children: childResult.nodes };
|
||||
}
|
||||
}
|
||||
|
||||
result.push(currentNode);
|
||||
|
||||
if (!added && node.id === targetId) {
|
||||
result.push(sibling);
|
||||
added = true;
|
||||
}
|
||||
});
|
||||
|
||||
return { nodes: added ? result : nodes, added };
|
||||
};
|
||||
|
||||
export default function BookmarkEditor({ bookmarks, onChange, disabled }: BookmarkEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAddTopLevel = () => {
|
||||
const newBookmark = createBookmarkNode({ title: t('editTableOfContents.editor.defaultTitle', 'New bookmark') });
|
||||
onChange([...bookmarks, newBookmark]);
|
||||
};
|
||||
|
||||
const handleTitleChange = (id: string, value: string) => {
|
||||
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, title: value })));
|
||||
};
|
||||
|
||||
const handlePageChange = (id: string, value: number | string) => {
|
||||
const page = typeof value === 'number' ? value : parseInt(value, 10);
|
||||
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, pageNumber: Number.isFinite(page) && page > 0 ? page : 1 })));
|
||||
};
|
||||
|
||||
const handleToggle = (id: string) => {
|
||||
onChange(updateTree(bookmarks, id, bookmark => ({ ...bookmark, expanded: !bookmark.expanded })));
|
||||
};
|
||||
|
||||
const handleRemove = (id: string) => {
|
||||
const confirmation = t(
|
||||
'editTableOfContents.editor.confirmRemove',
|
||||
'Remove this bookmark and all of its children?'
|
||||
);
|
||||
if (window.confirm(confirmation)) {
|
||||
onChange(removeFromTree(bookmarks, id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddChild = (parentId: string) => {
|
||||
const child = createBookmarkNode({ title: t('editTableOfContents.editor.defaultChildTitle', 'Child bookmark') });
|
||||
const { nodes, added } = addChildToTree(bookmarks, parentId, child);
|
||||
onChange(added ? nodes : [...bookmarks, child]);
|
||||
};
|
||||
|
||||
const handleAddSibling = (targetId: string) => {
|
||||
const sibling = createBookmarkNode({ title: t('editTableOfContents.editor.defaultSiblingTitle', 'New bookmark') });
|
||||
const { nodes, added } = addSiblingInTree(bookmarks, targetId, sibling);
|
||||
onChange(added ? nodes : [...bookmarks, sibling]);
|
||||
};
|
||||
|
||||
const renderBookmark = (bookmark: BookmarkNode, level = 0) => {
|
||||
const hasChildren = bookmark.children.length > 0;
|
||||
const chevronIcon = bookmark.expanded ? 'expand-more-rounded' : 'chevron-right-rounded';
|
||||
|
||||
return (
|
||||
<Paper
|
||||
key={bookmark.id}
|
||||
radius="md"
|
||||
withBorder
|
||||
p="md"
|
||||
style={{
|
||||
borderColor: 'var(--border-default)',
|
||||
background: level === 0 ? 'var(--bg-surface)' : 'var(--bg-muted)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="sm">
|
||||
<Flex align="flex-start" justify="space-between" gap="md">
|
||||
<Group gap="sm" align="flex-start">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => hasChildren && handleToggle(bookmark.id)}
|
||||
disabled={disabled || !hasChildren}
|
||||
aria-label={t('editTableOfContents.editor.actions.toggle', 'Toggle children')}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
<LocalIcon icon={chevronIcon} />
|
||||
</ActionIcon>
|
||||
<Stack gap={2}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text fw={600}>{bookmark.title || t('editTableOfContents.editor.untitled', 'Untitled bookmark')}</Text>
|
||||
{level > 0 && (
|
||||
<Badge size="xs" variant="light" color="blue">
|
||||
{t('editTableOfContents.editor.childBadge', 'Child')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('editTableOfContents.editor.pagePreview', { page: bookmark.pageNumber })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Tooltip label={t('editTableOfContents.editor.actions.addChild', 'Add child bookmark')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="green"
|
||||
onClick={() => handleAddChild(bookmark.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="subdirectory-arrow-right-rounded" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('editTableOfContents.editor.actions.addSibling', 'Add sibling bookmark')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
onClick={() => handleAddSibling(bookmark.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="add-rounded" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('editTableOfContents.editor.actions.remove', 'Remove bookmark')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => handleRemove(bookmark.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="delete-rounded" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Flex>
|
||||
|
||||
{bookmark.expanded && (
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
size="sm"
|
||||
label={t('editTableOfContents.editor.field.title', 'Bookmark title')}
|
||||
value={bookmark.title}
|
||||
onChange={event => handleTitleChange(bookmark.id, event.currentTarget.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<NumberInput
|
||||
size="sm"
|
||||
label={t('editTableOfContents.editor.field.page', 'Target page number')}
|
||||
min={1}
|
||||
clampBehavior="strict"
|
||||
value={bookmark.pageNumber}
|
||||
onChange={value => handlePageChange(bookmark.id, value ?? 1)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{bookmark.expanded && hasChildren && (
|
||||
<Stack gap="sm" pl="lg" style={{ borderLeft: '1px solid var(--border-default)' }}>
|
||||
{bookmark.children.map(child => (
|
||||
<Fragment key={child.id}>{renderBookmark(child, level + 1)}</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('editTableOfContents.editor.description', 'Add, nest, and reorder bookmarks to craft your PDF outline.')}
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
color="blue"
|
||||
leftSection={<LocalIcon icon="bookmark-add-rounded" />}
|
||||
onClick={handleAddTopLevel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('editTableOfContents.editor.addTopLevel', 'Add top-level bookmark')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{bookmarks.length === 0 ? (
|
||||
<Paper withBorder radius="md" ta="center" py="xl">
|
||||
<Stack gap="xs" align="center" px="lg">
|
||||
<LocalIcon icon="bookmark-add-rounded" style={{ fontSize: '2.25rem' }} />
|
||||
<Text fw={600}>{t('editTableOfContents.editor.empty.title', 'No bookmarks yet')}</Text>
|
||||
<Text size="sm" c="dimmed" maw={420}>
|
||||
{t('editTableOfContents.editor.empty.description', 'Import existing bookmarks or start by adding your first entry.')}
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
leftSection={<LocalIcon icon="add-rounded" />}
|
||||
onClick={handleAddTopLevel}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('editTableOfContents.editor.empty.action', 'Add first bookmark')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{bookmarks.map(bookmark => renderBookmark(bookmark))}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
FileButton,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BookmarkNode } from '@app/utils/editTableOfContents';
|
||||
|
||||
interface EditTableOfContentsSettingsProps {
|
||||
bookmarks: BookmarkNode[];
|
||||
replaceExisting: boolean;
|
||||
onReplaceExistingChange: (value: boolean) => void;
|
||||
onSelectFiles: () => void;
|
||||
onLoadFromPdf: () => void;
|
||||
onImportJson: (file: File) => void;
|
||||
onImportClipboard: () => void;
|
||||
onExportJson: () => void;
|
||||
onExportClipboard: () => void;
|
||||
isLoading: boolean;
|
||||
loadError?: string | null;
|
||||
canReadClipboard: boolean;
|
||||
canWriteClipboard: boolean;
|
||||
disabled?: boolean;
|
||||
selectedFileName?: string;
|
||||
}
|
||||
|
||||
export default function EditTableOfContentsSettings({
|
||||
bookmarks,
|
||||
replaceExisting,
|
||||
onReplaceExistingChange,
|
||||
onSelectFiles,
|
||||
onLoadFromPdf,
|
||||
onImportJson,
|
||||
onImportClipboard,
|
||||
onExportJson,
|
||||
onExportClipboard,
|
||||
isLoading,
|
||||
loadError,
|
||||
canReadClipboard,
|
||||
canWriteClipboard,
|
||||
disabled,
|
||||
selectedFileName,
|
||||
}: EditTableOfContentsSettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const infoLines = useMemo(() => ([
|
||||
t('editTableOfContents.info.line1', 'Each bookmark needs a descriptive title and the page it should open.'),
|
||||
t('editTableOfContents.info.line2', 'Use child bookmarks to build a hierarchy for chapters, sections, or subsections.'),
|
||||
t('editTableOfContents.info.line3', 'Import bookmarks from the selected PDF or from a JSON file to save time.'),
|
||||
]), [t]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>{t('editTableOfContents.actions.source', 'Load bookmarks')}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{selectedFileName
|
||||
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
|
||||
: t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<LocalIcon icon="folder-rounded" />}
|
||||
onClick={onSelectFiles}
|
||||
fullWidth
|
||||
>
|
||||
{selectedFileName
|
||||
? t('editTableOfContents.workbench.changeFile', 'Change PDF')
|
||||
: t('editTableOfContents.workbench.selectFile', 'Select PDF')}
|
||||
</Button>
|
||||
|
||||
<Tooltip label={!selectedFileName ? t('editTableOfContents.actions.noFile', 'Select a PDF to extract existing bookmarks.') : ''} disabled={Boolean(selectedFileName)}>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<LocalIcon icon="picture-as-pdf-rounded" />}
|
||||
onClick={onLoadFromPdf}
|
||||
loading={isLoading}
|
||||
disabled={disabled || !selectedFileName}
|
||||
fullWidth
|
||||
>
|
||||
{t('editTableOfContents.actions.loadFromPdf', 'Load from PDF')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<FileButton
|
||||
onChange={file => file && onImportJson(file)}
|
||||
accept="application/json"
|
||||
disabled={disabled}
|
||||
>
|
||||
{(props) => (
|
||||
<Button
|
||||
{...props}
|
||||
variant="default"
|
||||
leftSection={<LocalIcon icon="upload-rounded" />}
|
||||
disabled={disabled}
|
||||
fullWidth
|
||||
>
|
||||
{t('editTableOfContents.actions.importJson', 'Import JSON')}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
|
||||
<Tooltip
|
||||
label={canReadClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
|
||||
disabled={canReadClipboard}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<LocalIcon icon="content-paste-rounded" />}
|
||||
onClick={onImportClipboard}
|
||||
disabled={disabled || !canReadClipboard}
|
||||
fullWidth
|
||||
>
|
||||
{t('editTableOfContents.actions.importClipboard', 'Paste from clipboard')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
{loadError && (
|
||||
<Alert color="red" radius="md" icon={<LocalIcon icon="error-outline-rounded" />}>
|
||||
{loadError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>{t('editTableOfContents.actions.export', 'Export bookmarks')}</Text>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<LocalIcon icon="download-rounded" />}
|
||||
onClick={onExportJson}
|
||||
disabled={disabled || bookmarks.length === 0}
|
||||
fullWidth
|
||||
>
|
||||
{t('editTableOfContents.actions.exportJson', 'Download JSON')}
|
||||
</Button>
|
||||
|
||||
<Tooltip
|
||||
label={canWriteClipboard ? '' : t('editTableOfContents.actions.clipboardUnavailable', 'Clipboard access is not available in this browser.')}
|
||||
disabled={canWriteClipboard}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<LocalIcon icon="content-copy-rounded" />}
|
||||
onClick={onExportClipboard}
|
||||
disabled={disabled || bookmarks.length === 0 || !canWriteClipboard}
|
||||
fullWidth
|
||||
>
|
||||
{t('editTableOfContents.actions.exportClipboard', 'Copy to clipboard')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Switch
|
||||
checked={replaceExisting}
|
||||
onChange={(event) => onReplaceExistingChange(event.currentTarget.checked)}
|
||||
label={t('editTableOfContents.settings.replaceExisting', 'Replace existing bookmarks')}
|
||||
description={t('editTableOfContents.settings.replaceExistingHint', 'When disabled, the new outline is appended after the current bookmarks.')}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
<Stack gap="xs">
|
||||
{infoLines.map((line, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { BookmarkNode } from '@app/utils/editTableOfContents';
|
||||
import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
|
||||
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
|
||||
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
|
||||
|
||||
export interface EditTableOfContentsWorkbenchViewData {
|
||||
bookmarks: BookmarkNode[];
|
||||
selectedFileName?: string;
|
||||
disabled: boolean;
|
||||
files: File[];
|
||||
thumbnails: (string | undefined)[];
|
||||
downloadUrl: string | null;
|
||||
downloadFilename: string | null;
|
||||
errorMessage: string | null;
|
||||
isGeneratingThumbnails: boolean;
|
||||
isExecuteDisabled: boolean;
|
||||
isExecuting: boolean;
|
||||
onClearError: () => void;
|
||||
onBookmarksChange: (bookmarks: BookmarkNode[]) => void;
|
||||
onExecute: () => void;
|
||||
onUndo: () => void;
|
||||
onFileClick: (file: File) => void;
|
||||
}
|
||||
|
||||
interface EditTableOfContentsWorkbenchViewProps {
|
||||
data: EditTableOfContentsWorkbenchViewData | null;
|
||||
}
|
||||
|
||||
const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Box p="xl">
|
||||
<Card withBorder radius="md">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>{t('editTableOfContents.workbench.empty.title', 'Open the tool to start editing')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('editTableOfContents.workbench.empty.description', 'Select the Edit Table of Contents tool to load its workspace.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
bookmarks,
|
||||
selectedFileName,
|
||||
disabled,
|
||||
files,
|
||||
thumbnails,
|
||||
downloadUrl,
|
||||
downloadFilename,
|
||||
errorMessage,
|
||||
isGeneratingThumbnails,
|
||||
isExecuteDisabled,
|
||||
isExecuting,
|
||||
onClearError,
|
||||
onBookmarksChange,
|
||||
onExecute,
|
||||
onUndo,
|
||||
onFileClick,
|
||||
} = data;
|
||||
|
||||
const previewFiles = useMemo(
|
||||
() =>
|
||||
files?.map((file, index) => ({
|
||||
file,
|
||||
thumbnail: thumbnails[index],
|
||||
})) ?? [],
|
||||
[files, thumbnails]
|
||||
);
|
||||
|
||||
const showResults = Boolean(
|
||||
previewFiles.length > 0 || downloadUrl || errorMessage
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
p="lg"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflowY: 'auto',
|
||||
background: 'var(--bg-raised)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="xl" maw={1200} mx="auto">
|
||||
<Stack gap={4}>
|
||||
<Text size="xl" fw={700}>
|
||||
{t('home.editTableOfContents.title', 'Edit Table of Contents')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('editTableOfContents.workbench.subtitle', 'Import bookmarks, build hierarchies, and apply the outline without cramped side panels.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xl"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-default)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{t('editTableOfContents.editor.heading', 'Bookmark editor')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{selectedFileName
|
||||
? t('editTableOfContents.actions.selectedFile', { file: selectedFileName })
|
||||
: t('editTableOfContents.workbench.filePrompt', 'Select a PDF from your library or upload a new one to begin.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
<BookmarkEditor bookmarks={bookmarks} onChange={onBookmarksChange} disabled={disabled} />
|
||||
<Divider />
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<LocalIcon icon="menu-book-rounded" />}
|
||||
color="blue"
|
||||
onClick={onExecute}
|
||||
disabled={isExecuteDisabled}
|
||||
loading={isExecuting}
|
||||
>
|
||||
{t('editTableOfContents.submit', 'Apply table of contents')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{showResults && (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xl"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-surface)',
|
||||
borderColor: 'var(--border-default)',
|
||||
boxShadow: 'var(--shadow-md)',
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Stack gap={4}>
|
||||
<Text fw={600}>{t('editTableOfContents.results.title', 'Updated PDF with bookmarks')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('editTableOfContents.results.subtitle', 'Download the processed file or undo the operation below.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<ErrorNotification error={errorMessage} onClose={onClearError} />
|
||||
|
||||
{previewFiles.length > 0 && (
|
||||
<ResultsPreview
|
||||
files={previewFiles}
|
||||
onFileClick={onFileClick}
|
||||
isGeneratingThumbnails={isGeneratingThumbnails}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
{downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl}
|
||||
download={downloadFilename ?? undefined}
|
||||
leftSection={<LocalIcon icon='download-rounded' />}
|
||||
>
|
||||
{t('download', 'Download')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
leftSection={<LocalIcon icon="rotate-left" />}
|
||||
onClick={onUndo}
|
||||
disabled={isExecuting}
|
||||
>
|
||||
{t('undo', 'Undo')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditTableOfContentsWorkbenchView;
|
||||
@@ -191,7 +191,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
@@ -287,8 +286,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
contain: 'strict',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Scroller
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
determineAutoZoom,
|
||||
DEFAULT_FALLBACK_ZOOM,
|
||||
DEFAULT_VISIBILITY_THRESHOLD,
|
||||
measureRenderedPageRect,
|
||||
useFitWidthResize,
|
||||
ZoomViewport,
|
||||
} from '@app/utils/viewerZoom';
|
||||
import { getFirstPageAspectRatioFromStub } from '@app/utils/pageMetadata';
|
||||
|
||||
@@ -73,18 +71,6 @@ export function ZoomAPIBridge() {
|
||||
}
|
||||
}, [spreadMode, zoomState?.zoomLevel, scheduleAutoZoom, requestFitWidth]);
|
||||
|
||||
const getViewportSnapshot = useCallback((): ZoomViewport | null => {
|
||||
if (!zoomState || typeof zoomState !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('viewport' in zoomState) {
|
||||
const candidate = (zoomState as { viewport?: ZoomViewport | null }).viewport;
|
||||
return candidate ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [zoomState]);
|
||||
|
||||
const isManagedZoom =
|
||||
!!zoom &&
|
||||
@@ -119,7 +105,7 @@ export function ZoomAPIBridge() {
|
||||
}
|
||||
|
||||
const fitWidthZoom = zoomState.currentZoomLevel;
|
||||
if (!fitWidthZoom || fitWidthZoom <= 0) {
|
||||
if (!fitWidthZoom || fitWidthZoom <= 0 || fitWidthZoom === 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,37 +123,23 @@ export function ZoomAPIBridge() {
|
||||
const pagesPerSpread = currentSpreadMode !== SpreadMode.None ? 2 : 1;
|
||||
const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub);
|
||||
|
||||
const viewport = getViewportSnapshot();
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = viewport ?? {};
|
||||
const viewportWidth =
|
||||
metrics.clientWidth ?? metrics.width ?? window.innerWidth ?? 0;
|
||||
const viewportHeight =
|
||||
metrics.clientHeight ?? metrics.height ?? window.innerHeight ?? 0;
|
||||
const viewportWidth = window.innerWidth ?? 0;
|
||||
const viewportHeight = window.innerHeight ?? 0;
|
||||
|
||||
if (viewportWidth <= 0 || viewportHeight <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageRect = await measureRenderedPageRect({
|
||||
shouldCancel: () => cancelled,
|
||||
});
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decision = determineAutoZoom({
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
fitWidthZoom,
|
||||
pagesPerSpread,
|
||||
pageRect: pageRect
|
||||
? { width: pageRect.width, height: pageRect.height }
|
||||
: undefined,
|
||||
pageRect: undefined,
|
||||
metadataAspectRatio: metadataAspectRatio ?? null,
|
||||
visibilityThreshold: DEFAULT_VISIBILITY_THRESHOLD,
|
||||
fallbackZoom: DEFAULT_FALLBACK_ZOOM,
|
||||
@@ -197,7 +169,6 @@ export function ZoomAPIBridge() {
|
||||
firstFileId,
|
||||
firstFileStub,
|
||||
requestFitWidth,
|
||||
getViewportSnapshot,
|
||||
autoZoomTick,
|
||||
spreadMode,
|
||||
triggerImmediateZoomUpdate,
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface AppConfig {
|
||||
serverPort?: number;
|
||||
appNameNavbar?: string;
|
||||
languages?: string[];
|
||||
logoStyle?: 'modern' | 'classic';
|
||||
enableLogin?: boolean;
|
||||
enableEmailInvites?: boolean;
|
||||
isAdmin?: boolean;
|
||||
@@ -38,6 +39,9 @@ export interface AppConfig {
|
||||
license?: string;
|
||||
SSOAutoLogin?: boolean;
|
||||
serverCertificateEnabled?: boolean;
|
||||
appVersion?: string;
|
||||
machineType?: string;
|
||||
activeSecurity?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { createContext, useContext, useState, ReactNode } from 'react';
|
||||
|
||||
interface BannerContextType {
|
||||
banner: ReactNode;
|
||||
setBanner: (banner: ReactNode) => void;
|
||||
}
|
||||
|
||||
const BannerContext = createContext<BannerContextType | undefined>(undefined);
|
||||
|
||||
export function BannerProvider({ children }: { children: ReactNode }) {
|
||||
const [banner, setBanner] = useState<ReactNode>(null);
|
||||
|
||||
return (
|
||||
<BannerContext.Provider value={{ banner, setBanner }}>
|
||||
{children}
|
||||
</BannerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useBanner() {
|
||||
const context = useContext(BannerContext);
|
||||
if (!context) {
|
||||
throw new Error('useBanner must be used within BannerProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
|
||||
const restoreWorkbenchState = useCallback(async () => {
|
||||
console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length);
|
||||
|
||||
// Go back to All Tools
|
||||
// Go back to Tools
|
||||
handleBackToTools();
|
||||
|
||||
// Clear all files (including tour sample)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user