Merge branch 'V2' into feature/v2/add-text
This commit is contained in:
@@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
@Slf4j
|
||||
public class EndpointConfiguration {
|
||||
|
||||
public enum DisableReason {
|
||||
CONFIG,
|
||||
DEPENDENCY,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
public static class EndpointAvailability {
|
||||
private final boolean enabled;
|
||||
private final DisableReason reason;
|
||||
|
||||
public EndpointAvailability(boolean enabled, DisableReason reason) {
|
||||
this.enabled = enabled;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public DisableReason getReason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
private static final String REMOVE_BLANKS = "remove-blanks";
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
|
||||
private Set<String> disabledGroups = new HashSet<>();
|
||||
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
|
||||
private final boolean runningProOrHigher;
|
||||
|
||||
@@ -35,16 +61,31 @@ public class EndpointConfiguration {
|
||||
processEnvironmentConfigs();
|
||||
}
|
||||
|
||||
private String normalizeEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
|
||||
}
|
||||
|
||||
public void enableEndpoint(String endpoint) {
|
||||
endpointStatuses.put(endpoint, true);
|
||||
log.debug("Enabled endpoint: {}", endpoint);
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
endpointStatuses.put(normalized, true);
|
||||
endpointDisableReasons.remove(normalized);
|
||||
log.debug("Enabled endpoint: {}", normalized);
|
||||
}
|
||||
|
||||
public void disableEndpoint(String endpoint) {
|
||||
if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) {
|
||||
log.debug("Disabling endpoint: {}", endpoint);
|
||||
disableEndpoint(endpoint, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
public void disableEndpoint(String endpoint, DisableReason reason) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
|
||||
log.debug("Disabling endpoint: {}", normalized);
|
||||
}
|
||||
endpointStatuses.put(endpoint, false);
|
||||
endpointStatuses.put(normalized, false);
|
||||
endpointDisableReasons.put(normalized, reason);
|
||||
}
|
||||
|
||||
public boolean isEndpointEnabled(String endpoint) {
|
||||
@@ -150,6 +191,10 @@ public class EndpointConfiguration {
|
||||
}
|
||||
|
||||
public void disableGroup(String group) {
|
||||
disableGroup(group, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
public void disableGroup(String group, DisableReason reason) {
|
||||
if (disabledGroups.add(group)) {
|
||||
if (isToolGroup(group)) {
|
||||
log.debug(
|
||||
@@ -161,11 +206,12 @@ public class EndpointConfiguration {
|
||||
group);
|
||||
}
|
||||
}
|
||||
groupDisableReasons.put(group, reason);
|
||||
// Only cascade to endpoints for *functional* groups
|
||||
if (!isToolGroup(group)) {
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints != null) {
|
||||
endpoints.forEach(this::disableEndpoint);
|
||||
endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,12 +220,39 @@ public class EndpointConfiguration {
|
||||
if (disabledGroups.remove(group)) {
|
||||
log.debug("Enabling group: {}", group);
|
||||
}
|
||||
groupDisableReasons.remove(group);
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints != null) {
|
||||
endpoints.forEach(this::enableEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
public EndpointAvailability getEndpointAvailability(String endpoint) {
|
||||
boolean enabled = isEndpointEnabled(endpoint);
|
||||
DisableReason reason = enabled ? null : determineDisableReason(endpoint);
|
||||
return new EndpointAvailability(enabled, reason);
|
||||
}
|
||||
|
||||
private DisableReason determineDisableReason(String endpoint) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
|
||||
return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
Set<String> endpoints = entry.getValue();
|
||||
if (!disabledGroups.contains(group) || endpoints == null) {
|
||||
continue;
|
||||
}
|
||||
if (endpoints.contains(normalized)) {
|
||||
return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG);
|
||||
}
|
||||
}
|
||||
|
||||
return DisableReason.UNKNOWN;
|
||||
}
|
||||
|
||||
public Set<String> getDisabledGroups() {
|
||||
return new HashSet<>(disabledGroups);
|
||||
}
|
||||
@@ -261,6 +334,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "pdf-to-csv");
|
||||
addEndpointToGroup("Convert", "pdf-to-markdown");
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
addEndpointToGroup("Convert", "cbz-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-cbz");
|
||||
|
||||
// Adding endpoints to "Security" group
|
||||
addEndpointToGroup("Security", "add-password");
|
||||
@@ -394,6 +469,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
addEndpointToGroup("Java", "add-attachments");
|
||||
addEndpointToGroup("Java", "compress-pdf");
|
||||
addEndpointToGroup("Java", "cbz-to-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-cbz");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
|
||||
// Javascript
|
||||
|
||||
@@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@@ -97,7 +98,7 @@ public class ExternalAppDepConfig {
|
||||
if (affectedGroups != null) {
|
||||
for (String group : affectedGroups) {
|
||||
List<String> affectedFeatures = getAffectedFeatures(group);
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
|
||||
command,
|
||||
@@ -127,8 +128,8 @@ public class ExternalAppDepConfig {
|
||||
if (!pythonAvailable) {
|
||||
List<String> pythonFeatures = getAffectedFeatures("Python");
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
|
||||
String.join(", ", pythonFeatures),
|
||||
@@ -146,14 +147,14 @@ public class ExternalAppDepConfig {
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"OpenCV not available in Python - Disabling OpenCV features: {}",
|
||||
String.join(", ", openCVFeatures));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
|
||||
e.getMessage(),
|
||||
|
||||
+20
@@ -1,6 +1,7 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
@@ -10,9 +11,13 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
@@ -200,4 +205,19 @@ public class ConfigController {
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/endpoints-availability")
|
||||
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
|
||||
@RequestParam(name = "endpoints")
|
||||
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
|
||||
List<@NotBlank String> endpoints) {
|
||||
Map<String, EndpointAvailability> result = new HashMap<>();
|
||||
for (String endpoint : endpoints) {
|
||||
String trimmedEndpoint = endpoint.trim();
|
||||
result.put(
|
||||
trimmedEndpoint,
|
||||
endpointConfiguration.getEndpointAvailability(trimmedEndpoint));
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+17
@@ -14,6 +14,7 @@
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
"@embedpdf/plugin-bookmark": "^1.4.1",
|
||||
"@embedpdf/plugin-export": "^1.4.1",
|
||||
"@embedpdf/plugin-history": "^1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||
@@ -633,6 +634,22 @@
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-bookmark": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-1.4.1.tgz",
|
||||
"integrity": "sha512-WnfBJdv+Eq5zsMfwDZ5RlXZMGpvKm/ccL6jlTVwtELBhu3wvhjjbBmZdheEOzHMC3VXMNYDMjCeaXkUG4nWoDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.4.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.4.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-export": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.4.1.tgz",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"@embedpdf/core": "^1.4.1",
|
||||
"@embedpdf/engines": "^1.4.1",
|
||||
"@embedpdf/plugin-annotation": "^1.4.1",
|
||||
"@embedpdf/plugin-bookmark": "^1.4.1",
|
||||
"@embedpdf/plugin-export": "^1.4.1",
|
||||
"@embedpdf/plugin-history": "^1.4.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.4.1",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"comingSoon": "Coming soon:",
|
||||
"favorite": "Add to favourites",
|
||||
"favorites": "Favourites",
|
||||
"unavailable": "Disabled by server administrator:",
|
||||
"unavailableDependency": "Unavailable - required tool missing on server:",
|
||||
"heading": "All tools (fullscreen view)",
|
||||
"noResults": "Try adjusting your search or toggle descriptions to find what you need.",
|
||||
"recommended": "Recommended",
|
||||
@@ -5730,7 +5732,11 @@
|
||||
"latestVersion": "Latest Version",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"viewDetails": "View Details"
|
||||
}
|
||||
},
|
||||
"hideUnavailableTools": "Hide unavailable tools",
|
||||
"hideUnavailableToolsDescription": "Remove tools that have been disabled by your server instead of showing them greyed out.",
|
||||
"hideUnavailableConversions": "Hide unavailable conversions",
|
||||
"hideUnavailableConversionsDescription": "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
|
||||
},
|
||||
"hotkeys": {
|
||||
"errorConflict": "Shortcut already used by {{tool}}.",
|
||||
|
||||
@@ -53,11 +53,17 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
};
|
||||
|
||||
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
|
||||
if (summary) {
|
||||
if (summary && summary.latest_version) {
|
||||
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
|
||||
if (isNewerVersion) {
|
||||
setUpdateSummary(summary);
|
||||
} else {
|
||||
// Clear any existing update summary if user is on latest version
|
||||
setUpdateSummary(null);
|
||||
}
|
||||
} else {
|
||||
// No update available (latest_version is null) - clear any existing update summary
|
||||
setUpdateSummary(null);
|
||||
}
|
||||
setCheckingUpdate(false);
|
||||
};
|
||||
@@ -128,83 +134,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
|
||||
</Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={preferences.defaultToolPanelMode}
|
||||
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
|
||||
data={[
|
||||
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
|
||||
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.autoUnzip}
|
||||
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={fileLimitInput}
|
||||
onChange={setFileLimitInput}
|
||||
onBlur={() => {
|
||||
const numValue = Number(fileLimitInput);
|
||||
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
|
||||
setFileLimitInput(finalValue);
|
||||
updatePreference('autoUnzipFileLimit', finalValue);
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!preferences.autoUnzip}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Update Check Section */}
|
||||
{config?.appVersion && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
@@ -292,6 +221,111 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
|
||||
</Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={preferences.defaultToolPanelMode}
|
||||
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
|
||||
data={[
|
||||
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
|
||||
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.hideUnavailableTools', 'Hide unavailable tools')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.hideUnavailableToolsDescription', 'Remove tools that have been disabled by your server instead of showing them greyed out.')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.hideUnavailableTools}
|
||||
onChange={(event) => updatePreference('hideUnavailableTools', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.hideUnavailableConversions', 'Hide unavailable conversions')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.hideUnavailableConversionsDescription', 'Remove disabled conversion options in the Convert tool instead of showing them greyed out.')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.hideUnavailableConversions}
|
||||
onChange={(event) => updatePreference('hideUnavailableConversions', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.autoUnzip}
|
||||
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={fileLimitInput}
|
||||
onChange={setFileLimitInput}
|
||||
onBlur={() => {
|
||||
const numValue = Number(fileLimitInput);
|
||||
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
|
||||
setFileLimitInput(finalValue);
|
||||
updatePreference('autoUnzipFileLimit', finalValue);
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!preferences.autoUnzip}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Update Modal */}
|
||||
{updateSummary && config?.appVersion && config?.machineType && (
|
||||
<UpdateModal
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getConversionEndpoints } from "@app/data/toolsTaxonomy";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown";
|
||||
import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings";
|
||||
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
|
||||
@@ -47,8 +48,12 @@ const ConvertSettings = ({
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
const { state, selectors } = useFileState();
|
||||
const activeFiles = state.files.ids;
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
|
||||
const allEndpoints = useMemo(() => {
|
||||
const endpoints = getConversionEndpoints(EXTENSION_TO_ENDPOINT);
|
||||
return endpoints;
|
||||
}, []);
|
||||
|
||||
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
@@ -56,7 +61,8 @@ const ConvertSettings = ({
|
||||
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
|
||||
if (!endpointKey) return false;
|
||||
|
||||
return endpointStatus[endpointKey] === true;
|
||||
const isAvailable = endpointStatus[endpointKey] === true;
|
||||
return isAvailable;
|
||||
};
|
||||
|
||||
// Enhanced FROM options with endpoint availability
|
||||
@@ -74,6 +80,12 @@ const ConvertSettings = ({
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out unavailable source formats if preference is enabled
|
||||
let filteredOptions = baseOptions;
|
||||
if (preferences.hideUnavailableConversions) {
|
||||
filteredOptions = baseOptions.filter(opt => opt.enabled !== false);
|
||||
}
|
||||
|
||||
// Add dynamic format option if current selection is a file-<extension> format
|
||||
if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) {
|
||||
const extension = parameters.fromExtension.replace('file-', '');
|
||||
@@ -85,22 +97,32 @@ const ConvertSettings = ({
|
||||
};
|
||||
|
||||
// Add the dynamic option at the beginning
|
||||
return [dynamicOption, ...baseOptions];
|
||||
return [dynamicOption, ...filteredOptions];
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
return filteredOptions;
|
||||
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
|
||||
|
||||
// Enhanced TO options with endpoint availability
|
||||
const enhancedToOptions = useMemo(() => {
|
||||
if (!parameters.fromExtension) return [];
|
||||
|
||||
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
|
||||
return availableOptions.map(option => ({
|
||||
...option,
|
||||
enabled: isConversionAvailable(parameters.fromExtension, option.value)
|
||||
}));
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
const enhanced = availableOptions.map(option => {
|
||||
const enabled = isConversionAvailable(parameters.fromExtension, option.value);
|
||||
return {
|
||||
...option,
|
||||
enabled
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out unavailable conversions if preference is enabled
|
||||
if (preferences.hideUnavailableConversions) {
|
||||
return enhanced.filter(opt => opt.enabled !== false);
|
||||
}
|
||||
|
||||
return enhanced;
|
||||
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
|
||||
|
||||
const resetParametersToDefaults = () => {
|
||||
onParameterChange('imageOptions', {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
|
||||
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
|
||||
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
|
||||
|
||||
interface CompactToolItemProps {
|
||||
id: string;
|
||||
@@ -17,7 +17,7 @@ interface CompactToolItemProps {
|
||||
|
||||
const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => {
|
||||
const { t } = useTranslation();
|
||||
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
|
||||
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
|
||||
const categoryColor = getSubcategoryColor(tool.subcategoryId);
|
||||
const iconBg = getIconBackground(categoryColor, false);
|
||||
const iconClasses = 'tool-panel__fullscreen-list-icon';
|
||||
@@ -73,9 +73,12 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected,
|
||||
</button>
|
||||
);
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
const tooltipContent = disabled
|
||||
? (
|
||||
<span><strong>{t('toolPanel.fullscreen.comingSoon', 'Coming soon:')}</strong> {tool.description}</span>
|
||||
<span><strong>{disabledMessage}</strong> {tool.description}</span>
|
||||
)
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
|
||||
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
|
||||
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
|
||||
|
||||
interface DetailedToolItemProps {
|
||||
id: string;
|
||||
@@ -15,7 +15,7 @@ interface DetailedToolItemProps {
|
||||
|
||||
const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
|
||||
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
|
||||
|
||||
const categoryColor = getSubcategoryColor(tool.subcategoryId);
|
||||
const iconBg = getIconBackground(categoryColor, true);
|
||||
@@ -34,6 +34,9 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
|
||||
iconNode = tool.icon;
|
||||
}
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -60,7 +63,12 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
|
||||
{tool.name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
|
||||
{tool.description}
|
||||
{disabled ? (
|
||||
<>
|
||||
<strong>{disabledMessage} </strong>
|
||||
{tool.description}
|
||||
</>
|
||||
) : tool.description}
|
||||
</Text>
|
||||
{binding && (
|
||||
<div className="tool-panel__fullscreen-shortcut">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useHotkeys } from '@app/contexts/HotkeyContext';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import type { ToolAvailabilityMap } from '@app/hooks/useToolManagement';
|
||||
|
||||
export const getItemClasses = (isDetailed: boolean): string => {
|
||||
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
|
||||
@@ -22,23 +23,67 @@ export const getIconStyle = (): Record<string, string> => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => {
|
||||
return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | null;
|
||||
|
||||
export const getToolDisabledReason = (
|
||||
id: string,
|
||||
tool: ToolRegistryEntry,
|
||||
toolAvailability?: ToolAvailabilityMap
|
||||
): ToolDisabledReason => {
|
||||
if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') {
|
||||
return 'comingSoon';
|
||||
}
|
||||
|
||||
const availabilityInfo = toolAvailability?.[id as ToolId];
|
||||
if (availabilityInfo && availabilityInfo.available === false) {
|
||||
if (availabilityInfo.reason === 'missingDependency') {
|
||||
return 'missingDependency';
|
||||
}
|
||||
if (availabilityInfo.reason === 'disabledByAdmin') {
|
||||
return 'disabledByAdmin';
|
||||
}
|
||||
return 'unknownUnavailable';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getDisabledLabel = (
|
||||
disabledReason: ToolDisabledReason
|
||||
): { key: string; fallback: string } => {
|
||||
if (disabledReason === 'missingDependency') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.unavailableDependency',
|
||||
fallback: 'Unavailable - required tool missing on server:'
|
||||
};
|
||||
}
|
||||
if (disabledReason === 'disabledByAdmin' || disabledReason === 'unknownUnavailable') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.unavailable',
|
||||
fallback: 'Disabled by server administrator:'
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.comingSoon',
|
||||
fallback: 'Coming soon:'
|
||||
};
|
||||
};
|
||||
|
||||
export function useToolMeta(id: string, tool: ToolRegistryEntry) {
|
||||
const { hotkeys } = useHotkeys();
|
||||
const { isFavorite, toggleFavorite } = useToolWorkflow();
|
||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||
|
||||
const isFav = isFavorite(id as ToolId);
|
||||
const binding = hotkeys[id as ToolId];
|
||||
const disabled = isToolDisabled(id, tool);
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
|
||||
const disabled = disabledReason !== null;
|
||||
|
||||
return {
|
||||
binding,
|
||||
isFav,
|
||||
toggleFavorite: () => toggleFavorite(id as ToolId),
|
||||
disabled,
|
||||
disabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
|
||||
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: ToolId;
|
||||
@@ -26,12 +27,12 @@ interface ToolButtonProps {
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
|
||||
const { t } = useTranslation();
|
||||
// Special case: read and multiTool are navigational tools that are always available
|
||||
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
|
||||
const isUnavailable = disabledReason !== null;
|
||||
const { hotkeys } = useHotkeys();
|
||||
const binding = hotkeys[id];
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
const { isFavorite, toggleFavorite } = useToolWorkflow();
|
||||
const fav = isFavorite(id as ToolId);
|
||||
|
||||
const handleClick = (id: ToolId) => {
|
||||
@@ -48,8 +49,11 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
// Get navigation props for URL support (only if navigation is not disabled)
|
||||
const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null;
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
const tooltipContent = isUnavailable
|
||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||
? (<span><strong>{disabledMessage}</strong> {tool.description}</span>)
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span>{tool.description}</span>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useBookmarkCapability } from '@embedpdf/plugin-bookmark/react';
|
||||
import { BookmarkCapability } from '@embedpdf/plugin-bookmark';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { BookmarkState, BookmarkAPIWrapper } from '@app/contexts/viewer/viewerBridges';
|
||||
|
||||
export function BookmarkAPIBridge() {
|
||||
const { provides: bookmarkCapability } = useBookmarkCapability();
|
||||
const { registerBridge } = useViewer();
|
||||
const [state, setState] = useState<BookmarkState>({
|
||||
bookmarks: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const fetchBookmarks = useCallback(
|
||||
async (capability: BookmarkCapability) => {
|
||||
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||
try {
|
||||
const task = capability.getBookmarks();
|
||||
const result = await task.toPromise();
|
||||
setState({
|
||||
bookmarks: result.bookmarks ?? [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
return result.bookmarks ?? [];
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to load bookmarks';
|
||||
setState({
|
||||
bookmarks: null,
|
||||
isLoading: false,
|
||||
error: message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const api = useMemo<BookmarkAPIWrapper | null>(() => {
|
||||
if (!bookmarkCapability) return null;
|
||||
|
||||
return {
|
||||
fetchBookmarks: () => fetchBookmarks(bookmarkCapability),
|
||||
clearBookmarks: () => {
|
||||
setState({
|
||||
bookmarks: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
setLocalBookmarks: (bookmarks, error = null) => {
|
||||
setState({
|
||||
bookmarks,
|
||||
isLoading: false,
|
||||
error,
|
||||
});
|
||||
},
|
||||
};
|
||||
}, [bookmarkCapability, fetchBookmarks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
registerBridge('bookmark', {
|
||||
state,
|
||||
api,
|
||||
});
|
||||
}, [api, state, registerBridge]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/* Bookmark Sidebar - Modern styling to match AllTools section */
|
||||
|
||||
.bookmark-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in srgb, var(--bg-toolbar) 96%, transparent),
|
||||
color-mix(in srgb, var(--bg-background) 90%, transparent)
|
||||
);
|
||||
border-left: 1px solid color-mix(in srgb, var(--border-subtle) 75%, transparent);
|
||||
box-shadow: -2px 0 16px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 20%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Header Section */
|
||||
.bookmark-sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0.875rem;
|
||||
background: var(--bg-toolbar);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.bookmark-sidebar__header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.bookmark-sidebar__header-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--mantine-color-blue-6);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Search Section */
|
||||
.bookmark-sidebar__search {
|
||||
background: var(--tool-panel-search-bg, var(--bg-toolbar));
|
||||
border-bottom: 1px solid var(--tool-panel-search-border-bottom, var(--border-subtle));
|
||||
padding-top: 0.75rem !important;
|
||||
}
|
||||
|
||||
/* Content Section */
|
||||
.bookmark-sidebar__content {
|
||||
padding-top: 0.5rem !important;
|
||||
}
|
||||
|
||||
.bookmark-sidebar__empty-state,
|
||||
.bookmark-sidebar__error {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.bookmark-sidebar__loading {
|
||||
padding: 2rem 1rem !important;
|
||||
}
|
||||
|
||||
/* Bookmark List */
|
||||
.bookmark-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Bookmark Items */
|
||||
.bookmark-item-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 0.65rem;
|
||||
cursor: default;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bookmark-item--clickable {
|
||||
cursor: pointer;
|
||||
background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
|
||||
}
|
||||
|
||||
.bookmark-item--clickable:hover {
|
||||
background: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar));
|
||||
border-color: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.bookmark-item--clickable:active {
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.bookmark-item--clickable:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Expand Icon */
|
||||
.bookmark-item__expand-icon {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.2s ease;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.bookmark-item:hover .bookmark-item__expand-icon {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Dash for items without children */
|
||||
.bookmark-item__dash {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.5;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.bookmark-item__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bookmark-item__title {
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bookmark-item--clickable:hover .bookmark-item__title {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.bookmark-item__page {
|
||||
line-height: 1.3;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Children Container */
|
||||
.bookmark-item__children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Smooth expand/collapse animation */
|
||||
@keyframes bookmark-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.bookmark-item__children {
|
||||
animation: bookmark-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
/* Accessibility & Reduced Motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bookmark-item,
|
||||
.bookmark-item__expand-icon,
|
||||
.bookmark-item__children {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
import { useEffect, useMemo, useRef, useState, useCallback } from 'react';
|
||||
import { Box, ScrollArea, Text, ActionIcon, Loader, Stack, TextInput, Button } from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { PdfBookmarkObject, PdfActionType } from '@embedpdf/models';
|
||||
import BookmarksIcon from '@mui/icons-material/BookmarksRounded';
|
||||
import '@app/components/viewer/BookmarkSidebar.css';
|
||||
|
||||
interface BookmarkSidebarProps {
|
||||
visible: boolean;
|
||||
thumbnailVisible: boolean;
|
||||
documentCacheKey?: string;
|
||||
preloadCacheKeys?: string[];
|
||||
}
|
||||
|
||||
const SIDEBAR_WIDTH = '15rem';
|
||||
|
||||
type BookmarkNode = PdfBookmarkObject & { id: string };
|
||||
|
||||
type BookmarkCacheStatus = 'idle' | 'loading' | 'success' | 'error';
|
||||
|
||||
interface BookmarkCacheEntry {
|
||||
status: BookmarkCacheStatus;
|
||||
bookmarks: PdfBookmarkObject[] | null;
|
||||
error: string | null;
|
||||
lastFetched: number | null;
|
||||
}
|
||||
|
||||
const createEntry = (overrides: Partial<BookmarkCacheEntry> = {}): BookmarkCacheEntry => ({
|
||||
status: 'idle',
|
||||
bookmarks: null,
|
||||
error: null,
|
||||
lastFetched: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const resolvePageNumber = (bookmark: PdfBookmarkObject): number | null => {
|
||||
const target = bookmark.target;
|
||||
if (!target) return null;
|
||||
|
||||
if (target.type === 'destination') {
|
||||
return target.destination.pageIndex + 1;
|
||||
}
|
||||
|
||||
if (target.type === 'action') {
|
||||
const action = target.action;
|
||||
if (
|
||||
action.type === PdfActionType.Goto ||
|
||||
action.type === PdfActionType.RemoteGoto
|
||||
) {
|
||||
return action.destination?.pageIndex !== undefined
|
||||
? action.destination.pageIndex + 1
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const BookmarkSidebar = ({ visible, thumbnailVisible, documentCacheKey, preloadCacheKeys = [] }: BookmarkSidebarProps) => {
|
||||
const { bookmarkActions, scrollActions, hasBookmarkSupport } = useViewer();
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [bookmarkSupport, setBookmarkSupport] = useState(() => hasBookmarkSupport());
|
||||
const [activeEntry, setActiveEntry] = useState<BookmarkCacheEntry>(() => createEntry());
|
||||
const cacheRef = useRef<Map<string, BookmarkCacheEntry>>(new Map());
|
||||
const [fetchNonce, setFetchNonce] = useState(0);
|
||||
const currentKeyRef = useRef<string | null>(documentCacheKey ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
currentKeyRef.current = documentCacheKey ?? null;
|
||||
}, [documentCacheKey]);
|
||||
|
||||
// Poll once until the bookmark bridge registers
|
||||
useEffect(() => {
|
||||
if (bookmarkSupport) return;
|
||||
let cancelled = false;
|
||||
const id = setInterval(() => {
|
||||
if (!cancelled && hasBookmarkSupport()) {
|
||||
setBookmarkSupport(true);
|
||||
clearInterval(id);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [bookmarkSupport, hasBookmarkSupport]);
|
||||
|
||||
// Reset UI and load cached entry (if any) when switching documents
|
||||
useEffect(() => {
|
||||
setExpanded({});
|
||||
setSearchTerm('');
|
||||
|
||||
if (!documentCacheKey) {
|
||||
setActiveEntry(createEntry());
|
||||
bookmarkActions.clearBookmarks();
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = cacheRef.current.get(documentCacheKey);
|
||||
if (cached) {
|
||||
setActiveEntry(cached);
|
||||
if (cached.status === 'success') {
|
||||
bookmarkActions.setLocalBookmarks(cached.bookmarks ?? [], null);
|
||||
} else if (cached.status === 'error') {
|
||||
bookmarkActions.setLocalBookmarks(cached.bookmarks ?? null, cached.error);
|
||||
} else {
|
||||
bookmarkActions.clearBookmarks();
|
||||
}
|
||||
} else {
|
||||
setActiveEntry(createEntry());
|
||||
bookmarkActions.clearBookmarks();
|
||||
}
|
||||
}, [documentCacheKey, bookmarkActions]);
|
||||
|
||||
// Keep cache bounded to the currently relevant keys
|
||||
useEffect(() => {
|
||||
const allowed = new Set<string>();
|
||||
if (documentCacheKey) {
|
||||
allowed.add(documentCacheKey);
|
||||
}
|
||||
preloadCacheKeys.forEach(key => {
|
||||
if (key) {
|
||||
allowed.add(key);
|
||||
}
|
||||
});
|
||||
|
||||
cacheRef.current.forEach((_entry, key) => {
|
||||
if (!allowed.has(key)) {
|
||||
cacheRef.current.delete(key);
|
||||
}
|
||||
});
|
||||
}, [documentCacheKey, preloadCacheKeys]);
|
||||
|
||||
// Fetch bookmarks for the active document when needed
|
||||
useEffect(() => {
|
||||
if (!bookmarkSupport || !documentCacheKey) return;
|
||||
|
||||
const key = documentCacheKey;
|
||||
const cached = cacheRef.current.get(key);
|
||||
if (cached && (cached.status === 'loading' || cached.status === 'success')) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const updateEntry = (entry: BookmarkCacheEntry) => {
|
||||
cacheRef.current.set(key, entry);
|
||||
if (!cancelled && currentKeyRef.current === key) {
|
||||
setActiveEntry(entry);
|
||||
}
|
||||
};
|
||||
|
||||
updateEntry(createEntry({
|
||||
status: 'loading',
|
||||
bookmarks: cached?.bookmarks ?? null,
|
||||
lastFetched: cached?.lastFetched ?? null,
|
||||
}));
|
||||
|
||||
const fetchWithRetry = async () => {
|
||||
const maxAttempts = 10;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
const result = await bookmarkActions.fetchBookmarks();
|
||||
return Array.isArray(result) ? result : [];
|
||||
} catch (error: any) {
|
||||
const message = typeof error?.message === 'string' ? error.message.toLowerCase() : '';
|
||||
const notReady =
|
||||
message.includes('document') &&
|
||||
message.includes('not') &&
|
||||
message.includes('open');
|
||||
|
||||
if (!notReady || attempt === maxAttempts - 1) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
fetchWithRetry()
|
||||
.then(bookmarks => {
|
||||
if (cancelled) return;
|
||||
const entry = createEntry({
|
||||
status: 'success',
|
||||
bookmarks,
|
||||
lastFetched: Date.now(),
|
||||
});
|
||||
updateEntry(entry);
|
||||
if (currentKeyRef.current === key) {
|
||||
bookmarkActions.setLocalBookmarks(bookmarks, null);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
if (cancelled) return;
|
||||
const message = error instanceof Error ? error.message : 'Failed to load bookmarks';
|
||||
const fallback = cacheRef.current.get(key);
|
||||
const entry = createEntry({
|
||||
status: 'error',
|
||||
bookmarks: fallback?.bookmarks ?? null,
|
||||
error: message,
|
||||
lastFetched: fallback?.lastFetched ?? null,
|
||||
});
|
||||
updateEntry(entry);
|
||||
if (currentKeyRef.current === key) {
|
||||
bookmarkActions.setLocalBookmarks(null, message);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [bookmarkSupport, documentCacheKey, fetchNonce, bookmarkActions]);
|
||||
|
||||
const requestReload = useCallback(() => {
|
||||
if (!documentCacheKey) return;
|
||||
cacheRef.current.delete(documentCacheKey);
|
||||
setActiveEntry(createEntry());
|
||||
bookmarkActions.clearBookmarks();
|
||||
setFetchNonce(value => value + 1);
|
||||
}, [documentCacheKey, bookmarkActions]);
|
||||
|
||||
const bookmarksWithIds = useMemo(() => {
|
||||
const assignIds = (nodes: PdfBookmarkObject[], prefix = 'root'): BookmarkNode[] => {
|
||||
if (!Array.isArray(nodes)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return nodes.map((node, index) => {
|
||||
const id = `${prefix}-${index}`;
|
||||
return {
|
||||
...node,
|
||||
id,
|
||||
children: node.children ? assignIds(node.children, id) : undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const bookmarks = Array.isArray(activeEntry.bookmarks) ? activeEntry.bookmarks : [];
|
||||
return assignIds(bookmarks);
|
||||
}, [activeEntry.bookmarks]);
|
||||
|
||||
const currentStatus = activeEntry.status;
|
||||
const isLocalLoading = bookmarkSupport && currentStatus === 'loading';
|
||||
const currentError = bookmarkSupport && currentStatus === 'error' ? activeEntry.error : null;
|
||||
|
||||
const toggleNode = (nodeId: string) => {
|
||||
setExpanded(prev => ({
|
||||
...prev,
|
||||
[nodeId]: !(prev[nodeId] ?? true),
|
||||
}));
|
||||
};
|
||||
|
||||
const expandAll = useCallback(() => {
|
||||
const allExpanded: Record<string, boolean> = {};
|
||||
const expandRecursive = (nodes: BookmarkNode[]) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
allExpanded[node.id] = true;
|
||||
expandRecursive(node.children as BookmarkNode[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
expandRecursive(bookmarksWithIds);
|
||||
setExpanded(allExpanded);
|
||||
}, [bookmarksWithIds]);
|
||||
|
||||
const collapseAll = useCallback(() => {
|
||||
const allCollapsed: Record<string, boolean> = {};
|
||||
const collapseRecursive = (nodes: BookmarkNode[]) => {
|
||||
nodes.forEach(node => {
|
||||
if (node.children && node.children.length > 0) {
|
||||
allCollapsed[node.id] = false;
|
||||
collapseRecursive(node.children as BookmarkNode[]);
|
||||
}
|
||||
});
|
||||
};
|
||||
collapseRecursive(bookmarksWithIds);
|
||||
setExpanded(allCollapsed);
|
||||
}, [bookmarksWithIds]);
|
||||
|
||||
const handleBookmarkClick = (bookmark: PdfBookmarkObject, event: React.MouseEvent) => {
|
||||
const target = bookmark.target;
|
||||
if (target?.type === 'action') {
|
||||
const action = target.action;
|
||||
if (action.type === PdfActionType.URI && action.uri) {
|
||||
event.preventDefault();
|
||||
window.open(action.uri, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
if (action.type === PdfActionType.LaunchAppOrOpenFile && action.path) {
|
||||
event.preventDefault();
|
||||
window.open(action.path, '_blank', 'noopener');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const pageNumber = resolvePageNumber(bookmark);
|
||||
if (pageNumber) {
|
||||
scrollActions.scrollToPage(pageNumber);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredBookmarks = useMemo(() => {
|
||||
if (!searchTerm.trim()) return bookmarksWithIds;
|
||||
const term = searchTerm.trim().toLowerCase();
|
||||
|
||||
const applyFilter = (nodeList: BookmarkNode[]): BookmarkNode[] => {
|
||||
const results: BookmarkNode[] = [];
|
||||
|
||||
for (const node of nodeList) {
|
||||
const childMatches = node.children ? applyFilter(node.children as BookmarkNode[]) : [];
|
||||
const matchesSelf = node.title?.toLowerCase().includes(term) ?? false;
|
||||
|
||||
if (matchesSelf || childMatches.length > 0) {
|
||||
results.push({ ...node, children: childMatches.length > 0 ? childMatches : node.children });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
return applyFilter(bookmarksWithIds);
|
||||
}, [bookmarksWithIds, searchTerm]);
|
||||
|
||||
const renderBookmarks = (nodes: BookmarkNode[], depth = 0) => {
|
||||
if (!nodes || !Array.isArray(nodes)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return nodes.map((node, _index) => {
|
||||
if (!node || !node.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasChildren = Array.isArray(node.children) && node.children.length > 0;
|
||||
const isNodeExpanded = expanded[node.id] ?? true;
|
||||
|
||||
const pageNumber = resolvePageNumber(node);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="bookmark-item-wrapper"
|
||||
style={{
|
||||
marginLeft: depth > 0 ? `${depth * 0.75}rem` : '0',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`bookmark-item ${pageNumber ? 'bookmark-item--clickable' : ''}`}
|
||||
onClick={(event) => handleBookmarkClick(node, event)}
|
||||
role={pageNumber ? "button" : undefined}
|
||||
tabIndex={pageNumber ? 0 : undefined}
|
||||
onKeyDown={pageNumber ? (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleBookmarkClick(node, event as any);
|
||||
}
|
||||
} : undefined}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
className="bookmark-item__expand-icon"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
toggleNode(node.id);
|
||||
}}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={isNodeExpanded ? 'keyboard-arrow-up' : 'keyboard-arrow-down'}
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
/>
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<span className="bookmark-item__dash">-</span>
|
||||
)}
|
||||
<div className="bookmark-item__content">
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
className="bookmark-item__title"
|
||||
>
|
||||
{node.title || 'Untitled'}
|
||||
</Text>
|
||||
{pageNumber && (
|
||||
<Text size="xs" c="dimmed" className="bookmark-item__page">
|
||||
Page {pageNumber}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{hasChildren && isNodeExpanded && (
|
||||
<div className="bookmark-item__children">
|
||||
{renderBookmarks(node.children as BookmarkNode[], depth + 1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const isSearchActive = searchTerm.trim().length > 0;
|
||||
const hasBookmarks = bookmarksWithIds.length > 0;
|
||||
const showBookmarkList = bookmarkSupport && documentCacheKey && filteredBookmarks.length > 0;
|
||||
const showEmptyState =
|
||||
bookmarkSupport &&
|
||||
documentCacheKey &&
|
||||
!isLocalLoading &&
|
||||
!currentError &&
|
||||
currentStatus === 'success' &&
|
||||
!hasBookmarks;
|
||||
const showSearchEmpty =
|
||||
bookmarkSupport &&
|
||||
documentCacheKey &&
|
||||
isSearchActive &&
|
||||
hasBookmarks &&
|
||||
filteredBookmarks.length === 0;
|
||||
const showNoDocument = bookmarkSupport && !documentCacheKey;
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="bookmark-sidebar"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
right: thumbnailVisible ? SIDEBAR_WIDTH : 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: SIDEBAR_WIDTH,
|
||||
zIndex: 998,
|
||||
}}
|
||||
>
|
||||
<div className="bookmark-sidebar__header">
|
||||
<div className="bookmark-sidebar__header-title">
|
||||
<span className="bookmark-sidebar__header-icon">
|
||||
<BookmarksIcon />
|
||||
</span>
|
||||
<Text fw={600} size="sm" tt="uppercase" lts={0.5}>
|
||||
Bookmarks
|
||||
</Text>
|
||||
</div>
|
||||
{bookmarkSupport && bookmarksWithIds.length > 0 && (
|
||||
<>
|
||||
{Object.values(expanded).some(val => val === false) ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={expandAll}
|
||||
aria-label="Expand all bookmarks"
|
||||
title="Expand all"
|
||||
>
|
||||
<LocalIcon icon="unfold-more" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
onClick={collapseAll}
|
||||
aria-label="Collapse all bookmarks"
|
||||
title="Collapse all"
|
||||
>
|
||||
<LocalIcon icon="unfold-less" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Box px="sm" pb="sm" className="bookmark-sidebar__search">
|
||||
<TextInput
|
||||
value={searchTerm}
|
||||
placeholder="Search bookmarks"
|
||||
onChange={(event) => setSearchTerm(event.currentTarget.value)}
|
||||
leftSection={<LocalIcon icon="search" width="1.1rem" height="1.1rem" />}
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="sm" className="bookmark-sidebar__content">
|
||||
{!bookmarkSupport && (
|
||||
<div className="bookmark-sidebar__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Bookmark support is unavailable for this viewer.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookmarkSupport && showNoDocument && (
|
||||
<div className="bookmark-sidebar__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
Open a PDF to view its bookmarks.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bookmarkSupport && documentCacheKey && currentError && (
|
||||
<Stack gap="xs" align="center" className="bookmark-sidebar__error">
|
||||
<Text size="sm" c="red" ta="center">
|
||||
{currentError}
|
||||
</Text>
|
||||
<Button size="xs" variant="light" onClick={requestReload}>
|
||||
Retry
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{bookmarkSupport && documentCacheKey && isLocalLoading && (
|
||||
<Stack gap="md" align="center" c="dimmed" py="xl" className="bookmark-sidebar__loading">
|
||||
<Loader size="md" type="dots" />
|
||||
<Text size="sm" ta="center">
|
||||
Loading bookmarks...
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<div className="bookmark-sidebar__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No bookmarks in this document
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showBookmarkList && (
|
||||
<div className="bookmark-list">
|
||||
{renderBookmarks(filteredBookmarks)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSearchEmpty && (
|
||||
<div className="bookmark-sidebar__empty-state">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No bookmarks match your search
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { useViewer } from "@app/contexts/ViewerContext";
|
||||
import { LocalEmbedPDF } from '@app/components/viewer/LocalEmbedPDF';
|
||||
import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar';
|
||||
import { ThumbnailSidebar } from '@app/components/viewer/ThumbnailSidebar';
|
||||
import { BookmarkSidebar } from '@app/components/viewer/BookmarkSidebar';
|
||||
import { useNavigationGuard, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
|
||||
@@ -38,7 +39,19 @@ const EmbedPdfViewerContent = ({
|
||||
const pdfContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
|
||||
const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer();
|
||||
const {
|
||||
isThumbnailSidebarVisible,
|
||||
toggleThumbnailSidebar,
|
||||
isBookmarkSidebarVisible,
|
||||
zoomActions,
|
||||
panActions: _panActions,
|
||||
rotationActions: _rotationActions,
|
||||
getScrollState,
|
||||
getRotationState,
|
||||
isAnnotationMode,
|
||||
isAnnotationsVisible,
|
||||
exportActions,
|
||||
} = useViewer();
|
||||
|
||||
// Register viewer right-rail buttons
|
||||
useViewerRightRailButtons();
|
||||
@@ -129,6 +142,42 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
}, [previewFile, fileWithUrl]);
|
||||
|
||||
const bookmarkCacheKey = React.useMemo(() => {
|
||||
if (currentFile && isStirlingFile(currentFile)) {
|
||||
return currentFile.fileId;
|
||||
}
|
||||
|
||||
if (previewFile) {
|
||||
const uniquePreviewId = `${previewFile.name}-${previewFile.size}-${previewFile.lastModified ?? 'na'}`;
|
||||
return `preview-${uniquePreviewId}`;
|
||||
}
|
||||
|
||||
if (effectiveFile?.url) {
|
||||
return effectiveFile.url;
|
||||
}
|
||||
|
||||
if (effectiveFile?.file instanceof File) {
|
||||
const fileObj = effectiveFile.file;
|
||||
return `file-${fileObj.name}-${fileObj.size}-${fileObj.lastModified ?? 'na'}`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [currentFile, effectiveFile, previewFile]);
|
||||
|
||||
// Generate cache keys for all active files to enable preloading
|
||||
const allBookmarkCacheKeys = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
return [bookmarkCacheKey].filter(Boolean) as string[];
|
||||
}
|
||||
|
||||
return activeFiles.map(file => {
|
||||
if (isStirlingFile(file)) {
|
||||
return file.fileId;
|
||||
}
|
||||
return undefined;
|
||||
}).filter(Boolean) as string[];
|
||||
}, [activeFiles, previewFile, bookmarkCacheKey]);
|
||||
|
||||
useWheelZoom({
|
||||
ref: viewerRef,
|
||||
onZoomIn: zoomActions.zoomIn,
|
||||
@@ -220,6 +269,10 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
const sidebarWidthRem = 15;
|
||||
const totalRightMargin =
|
||||
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={viewerRef}
|
||||
@@ -261,7 +314,7 @@ const EmbedPdfViewerContent = ({
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
marginRight: `${totalRightMargin}rem`,
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<LocalEmbedPDF
|
||||
@@ -316,6 +369,12 @@ const EmbedPdfViewerContent = ({
|
||||
onToggle={toggleThumbnailSidebar}
|
||||
activeFileIndex={activeFileIndex}
|
||||
/>
|
||||
<BookmarkSidebar
|
||||
visible={isBookmarkSidebarVisible}
|
||||
thumbnailVisible={isThumbnailSidebarVisible}
|
||||
documentCacheKey={bookmarkCacheKey}
|
||||
preloadCacheKeys={allBookmarkCacheKeys}
|
||||
/>
|
||||
|
||||
{/* Navigation Warning Modal */}
|
||||
{!previewFile && (
|
||||
|
||||
@@ -19,6 +19,7 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
||||
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
||||
import { BookmarkPluginPackage } from '@embedpdf/plugin-bookmark';
|
||||
|
||||
// Import annotation plugins
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
@@ -39,6 +40,7 @@ import { SignatureAPIBridge } from '@app/components/viewer/SignatureAPIBridge';
|
||||
import { HistoryAPIBridge } from '@app/components/viewer/HistoryAPIBridge';
|
||||
import type { SignatureAPI, HistoryAPI } from '@app/components/viewer/viewerTypes';
|
||||
import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
|
||||
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
|
||||
|
||||
interface LocalEmbedPDFProps {
|
||||
file?: File | Blob;
|
||||
@@ -138,6 +140,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
// Register thumbnail plugin for page thumbnails
|
||||
createPluginRegistration(ThumbnailPluginPackage),
|
||||
|
||||
// Register bookmark plugin for PDF outline support
|
||||
createPluginRegistration(BookmarkPluginPackage),
|
||||
|
||||
// Register rotate plugin
|
||||
createPluginRegistration(RotatePluginPackage),
|
||||
|
||||
@@ -272,6 +277,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
{enableAnnotations && <SignatureAPIBridge ref={signatureApiRef} />}
|
||||
{enableAnnotations && <HistoryAPIBridge ref={historyApiRef} />}
|
||||
<ExportAPIBridge />
|
||||
<BookmarkAPIBridge />
|
||||
<GlobalPointerProvider>
|
||||
<Viewport
|
||||
style={{
|
||||
|
||||
@@ -19,6 +19,7 @@ export function useViewerRightRailButtons() {
|
||||
const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left');
|
||||
const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right');
|
||||
const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar');
|
||||
const bookmarkLabel = t('rightRail.toggleBookmarks', 'Toggle Bookmarks');
|
||||
|
||||
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
@@ -111,6 +112,17 @@ export function useViewerRightRailButtons() {
|
||||
viewer.toggleThumbnailSidebar();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-toggle-bookmarks',
|
||||
icon: <LocalIcon icon="bookmark-add-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: bookmarkLabel,
|
||||
ariaLabel: bookmarkLabel,
|
||||
section: 'top' as const,
|
||||
order: 55,
|
||||
onClick: () => {
|
||||
viewer.toggleBookmarkSidebar();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-annotation-controls',
|
||||
section: 'top' as const,
|
||||
@@ -120,7 +132,7 @@ export function useViewerRightRailButtons() {
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]);
|
||||
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useToolManagement } from '@app/hooks/useToolManagement';
|
||||
import { useToolManagement, type ToolAvailabilityMap } from '@app/hooks/useToolManagement';
|
||||
import { PageEditorFunctions } from '@app/types/pageEditor';
|
||||
import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy';
|
||||
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
@@ -44,6 +44,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
|
||||
toolAvailability: ToolAvailabilityMap;
|
||||
|
||||
// UI Actions
|
||||
setSidebarsVisible: (visible: boolean) => void;
|
||||
@@ -112,7 +113,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
const navigationState = useNavigationState();
|
||||
|
||||
// Tool management hook
|
||||
const { toolRegistry, getSelectedTool } = useToolManagement();
|
||||
const { toolRegistry, getSelectedTool, toolAvailability } = useToolManagement();
|
||||
const { allTools } = useToolRegistry();
|
||||
|
||||
// Tool history hook
|
||||
@@ -258,6 +259,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
|
||||
// Workflow actions (compound actions that coordinate multiple state changes)
|
||||
const handleToolSelect = useCallback((toolId: ToolId) => {
|
||||
const availabilityInfo = toolAvailability[toolId];
|
||||
const isExplicitlyDisabled = availabilityInfo ? availabilityInfo.available === false : false;
|
||||
if (toolId !== 'read' && toolId !== 'multiTool' && isExplicitlyDisabled) {
|
||||
return;
|
||||
}
|
||||
// If we're currently on a custom workbench (e.g., Validate Signature report),
|
||||
// selecting any tool should take the user back to the default file manager view.
|
||||
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
|
||||
@@ -299,7 +305,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setSearchQuery('');
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false); // Disable read mode when selecting tools
|
||||
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]);
|
||||
|
||||
const handleBackToTools = useCallback(() => {
|
||||
setLeftPanelView('toolPicker');
|
||||
@@ -354,6 +360,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
toolResetFunctions,
|
||||
registerToolReset,
|
||||
resetTool,
|
||||
toolAvailability,
|
||||
|
||||
// Workflow Actions
|
||||
handleToolSelect,
|
||||
@@ -381,6 +388,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
getSelectedTool,
|
||||
toolAvailability,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
RotationActions,
|
||||
SearchActions,
|
||||
ExportActions,
|
||||
BookmarkActions,
|
||||
} from '@app/contexts/viewer/viewerActions';
|
||||
import {
|
||||
BridgeRef,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
SearchState,
|
||||
ExportState,
|
||||
ThumbnailAPIWrapper,
|
||||
BookmarkState,
|
||||
} from '@app/contexts/viewer/viewerBridges';
|
||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
|
||||
@@ -74,6 +76,8 @@ interface ViewerContextType {
|
||||
// UI state managed by this context
|
||||
isThumbnailSidebarVisible: boolean;
|
||||
toggleThumbnailSidebar: () => void;
|
||||
isBookmarkSidebarVisible: boolean;
|
||||
toggleBookmarkSidebar: () => void;
|
||||
|
||||
// Annotation visibility toggle
|
||||
isAnnotationsVisible: boolean;
|
||||
@@ -98,6 +102,8 @@ interface ViewerContextType {
|
||||
getSearchState: () => SearchState;
|
||||
getThumbnailAPI: () => ThumbnailAPIWrapper | null;
|
||||
getExportState: () => ExportState;
|
||||
getBookmarkState: () => BookmarkState;
|
||||
hasBookmarkSupport: () => boolean;
|
||||
|
||||
// Immediate update callbacks
|
||||
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
|
||||
@@ -118,6 +124,7 @@ interface ViewerContextType {
|
||||
rotationActions: RotationActions;
|
||||
searchActions: SearchActions;
|
||||
exportActions: ExportActions;
|
||||
bookmarkActions: BookmarkActions;
|
||||
|
||||
// Bridge registration - internal use by bridges
|
||||
registerBridge: <K extends BridgeKey>(
|
||||
@@ -135,6 +142,7 @@ interface ViewerProviderProps {
|
||||
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
// UI state - only state directly managed by this context
|
||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
||||
const [isBookmarkSidebarVisible, setIsBookmarkSidebarVisible] = useState(false);
|
||||
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
||||
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
||||
const [activeFileIndex, setActiveFileIndex] = useState(0);
|
||||
@@ -193,6 +201,10 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
setIsThumbnailSidebarVisible(prev => !prev);
|
||||
};
|
||||
|
||||
const toggleBookmarkSidebar = () => {
|
||||
setIsBookmarkSidebarVisible(prev => !prev);
|
||||
};
|
||||
|
||||
const toggleAnnotationsVisibility = () => {
|
||||
setIsAnnotationsVisible(prev => !prev);
|
||||
};
|
||||
@@ -242,6 +254,18 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
return bridgeRefs.current.export?.state || { canExport: false };
|
||||
};
|
||||
|
||||
const getBookmarkState = (): BookmarkState => {
|
||||
return (
|
||||
bridgeRefs.current.bookmark?.state || {
|
||||
bookmarks: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const hasBookmarkSupport = () => Boolean(bridgeRefs.current.bookmark);
|
||||
|
||||
// Action handlers - call APIs directly
|
||||
const {
|
||||
scrollActions,
|
||||
@@ -252,6 +276,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
rotationActions,
|
||||
searchActions,
|
||||
exportActions,
|
||||
bookmarkActions,
|
||||
} = createViewerActions({
|
||||
registry: bridgeRefs,
|
||||
getScrollState,
|
||||
@@ -263,6 +288,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
// UI state
|
||||
isThumbnailSidebarVisible,
|
||||
toggleThumbnailSidebar,
|
||||
isBookmarkSidebarVisible,
|
||||
toggleBookmarkSidebar,
|
||||
|
||||
// Annotation controls
|
||||
isAnnotationsVisible,
|
||||
@@ -285,6 +312,8 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
getSearchState,
|
||||
getThumbnailAPI,
|
||||
getExportState,
|
||||
getBookmarkState,
|
||||
hasBookmarkSupport,
|
||||
|
||||
// Immediate updates
|
||||
registerImmediateZoomUpdate,
|
||||
@@ -303,6 +332,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
rotationActions,
|
||||
searchActions,
|
||||
exportActions,
|
||||
bookmarkActions,
|
||||
|
||||
// Bridge registration
|
||||
registerBridge,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ScrollState,
|
||||
ZoomState,
|
||||
} from '@app/contexts/viewer/viewerBridges';
|
||||
import { PdfBookmarkObject } from '@embedpdf/models';
|
||||
|
||||
export interface ScrollActions {
|
||||
scrollToPage: (page: number) => void;
|
||||
@@ -58,6 +59,12 @@ export interface ExportActions {
|
||||
saveAsCopy: () => Promise<ArrayBuffer | null>;
|
||||
}
|
||||
|
||||
export interface BookmarkActions {
|
||||
fetchBookmarks: () => Promise<PdfBookmarkObject[] | null>;
|
||||
clearBookmarks: () => void;
|
||||
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
|
||||
}
|
||||
|
||||
export interface ViewerActionsBundle {
|
||||
scrollActions: ScrollActions;
|
||||
zoomActions: ZoomActions;
|
||||
@@ -67,6 +74,7 @@ export interface ViewerActionsBundle {
|
||||
rotationActions: RotationActions;
|
||||
searchActions: SearchActions;
|
||||
exportActions: ExportActions;
|
||||
bookmarkActions: BookmarkActions;
|
||||
}
|
||||
|
||||
interface ViewerActionDependencies {
|
||||
@@ -307,5 +315,22 @@ export function createViewerActions({
|
||||
rotationActions,
|
||||
searchActions,
|
||||
exportActions,
|
||||
bookmarkActions: {
|
||||
fetchBookmarks: async () => {
|
||||
const api = registry.current.bookmark?.api;
|
||||
if (!api?.fetchBookmarks) {
|
||||
return null;
|
||||
}
|
||||
return api.fetchBookmarks();
|
||||
},
|
||||
clearBookmarks: () => {
|
||||
const api = registry.current.bookmark?.api;
|
||||
api?.clearBookmarks?.();
|
||||
},
|
||||
setLocalBookmarks: (bookmarks, error = null) => {
|
||||
const api = registry.current.bookmark?.api;
|
||||
api?.setLocalBookmarks?.(bookmarks ?? null, error);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SpreadMode } from '@embedpdf/plugin-spread/react';
|
||||
import { PdfBookmarkObject } from '@embedpdf/models';
|
||||
|
||||
export interface ScrollAPIWrapper {
|
||||
scrollToPage: (params: { pageNumber: number }) => void;
|
||||
@@ -59,6 +60,12 @@ export interface ExportAPIWrapper {
|
||||
saveAsCopy: () => { toPromise: () => Promise<ArrayBuffer> };
|
||||
}
|
||||
|
||||
export interface BookmarkAPIWrapper {
|
||||
fetchBookmarks: () => Promise<PdfBookmarkObject[]>;
|
||||
clearBookmarks: () => void;
|
||||
setLocalBookmarks: (bookmarks: PdfBookmarkObject[] | null, error?: string | null) => void;
|
||||
}
|
||||
|
||||
export interface ScrollState {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
@@ -103,6 +110,12 @@ export interface ExportState {
|
||||
canExport: boolean;
|
||||
}
|
||||
|
||||
export interface BookmarkState {
|
||||
bookmarks: PdfBookmarkObject[] | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface BridgeRef<TState = unknown, TApi = unknown> {
|
||||
state: TState;
|
||||
api: TApi;
|
||||
@@ -118,6 +131,7 @@ export interface BridgeStateMap {
|
||||
search: SearchState;
|
||||
thumbnail: unknown;
|
||||
export: ExportState;
|
||||
bookmark: BookmarkState;
|
||||
}
|
||||
|
||||
export interface BridgeApiMap {
|
||||
@@ -130,6 +144,7 @@ export interface BridgeApiMap {
|
||||
search: SearchAPIWrapper;
|
||||
thumbnail: ThumbnailAPIWrapper;
|
||||
export: ExportAPIWrapper;
|
||||
bookmark: BookmarkAPIWrapper;
|
||||
}
|
||||
|
||||
export type BridgeKey = keyof BridgeStateMap;
|
||||
@@ -148,6 +163,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({
|
||||
search: null,
|
||||
thumbnail: null,
|
||||
export: null,
|
||||
bookmark: null,
|
||||
});
|
||||
|
||||
export function registerBridge<K extends BridgeKey>(
|
||||
|
||||
@@ -83,6 +83,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
|
||||
import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
|
||||
import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
|
||||
import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation";
|
||||
import { ENDPOINTS as SPLIT_ENDPOINT_NAMES } from '@app/constants/splitConstants';
|
||||
import CompressSettings from "@app/components/tools/compress/CompressSettings";
|
||||
import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings";
|
||||
import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings";
|
||||
@@ -313,6 +314,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION,
|
||||
endpoints: ["get-info-on-pdf"],
|
||||
synonyms: getSynonyms(t, "getPdfInfo"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
@@ -411,6 +413,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.split.desc", "Split PDFs into multiple documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))),
|
||||
operationConfig: splitOperationConfig,
|
||||
automationSettings: SplitAutomationSettings,
|
||||
synonyms: getSynonyms(t, "split")
|
||||
@@ -478,6 +481,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: ["booklet-imposition"],
|
||||
},
|
||||
pdfToSinglePage: {
|
||||
|
||||
@@ -572,6 +576,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-annotations"],
|
||||
operationConfig: removeAnnotationsOperationConfig,
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "removeAnnotations")
|
||||
@@ -610,7 +615,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
endpoints: ["remove-cert-sign"],
|
||||
operationConfig: removeCertificateSignOperationConfig,
|
||||
synonyms: getSynonyms(t, "removeCertSign"),
|
||||
automationSettings: null,
|
||||
@@ -639,7 +644,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
name: t("home.autoRename.title", "Auto Rename PDF File"),
|
||||
component: AutoRename,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
endpoints: ["auto-rename"],
|
||||
operationConfig: autoRenameOperationConfig,
|
||||
description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
@@ -694,6 +699,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["overlay-pdf"],
|
||||
operationConfig: overlayPdfsOperationConfig,
|
||||
synonyms: getSynonyms(t, "overlay-pdfs"),
|
||||
automationSettings: OverlayPdfsSettings
|
||||
@@ -718,6 +724,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.addImage.desc", "Add images to PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["add-image"],
|
||||
synonyms: getSynonyms(t, "addImage"),
|
||||
automationSettings: null
|
||||
},
|
||||
@@ -728,6 +735,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["scanner-effect"],
|
||||
synonyms: getSynonyms(t, "scannerEffect"),
|
||||
automationSettings: null
|
||||
},
|
||||
@@ -818,6 +826,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["compress-pdf"],
|
||||
operationConfig: compressOperationConfig,
|
||||
automationSettings: CompressSettings,
|
||||
synonyms: getSynonyms(t, "compress")
|
||||
@@ -861,6 +870,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["ocr-pdf"],
|
||||
operationConfig: ocrOperationConfig,
|
||||
automationSettings: OCRSettings,
|
||||
synonyms: getSynonyms(t, "ocr")
|
||||
|
||||
+1
-1
@@ -14,6 +14,6 @@ export type RemoveCertificateSignParametersHook = BaseParametersHook<RemoveCerti
|
||||
export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-certificate-sign',
|
||||
endpointName: 'remove-cert-sign',
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
|
||||
// Track globally fetched endpoint sets to prevent duplicate fetches across components
|
||||
const globalFetchedSets = new Set<string>();
|
||||
const globalEndpointCache: Record<string, boolean> = {};
|
||||
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
||||
|
||||
/**
|
||||
* Hook to check if a specific endpoint is enabled
|
||||
@@ -59,11 +60,13 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
*/
|
||||
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
endpointStatus: Record<string, boolean>;
|
||||
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
||||
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -73,31 +76,25 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// Skip if we already fetched these exact endpoints globally
|
||||
if (!force && globalFetchedSets.has(endpointsKey)) {
|
||||
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
|
||||
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
||||
if (endpoint in globalEndpointCache) {
|
||||
acc[endpoint] = globalEndpointCache[endpoint];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(cachedStatus);
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if JWT exists - if not, optimistically enable all endpoints
|
||||
const hasJwt = !!localStorage.getItem('stirling_jwt');
|
||||
if (!hasJwt) {
|
||||
console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints');
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -110,11 +107,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
|
||||
if (newEndpoints.length === 0) {
|
||||
console.debug('[useEndpointConfig] All endpoints already in global cache');
|
||||
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = globalEndpointCache[endpoint];
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(cachedStatus);
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -123,30 +128,51 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// Use batch API for efficiency - only fetch new endpoints
|
||||
const endpointsParam = newEndpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, boolean>>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const statusMap = response.data;
|
||||
|
||||
// Update global cache with new results
|
||||
Object.assign(globalEndpointCache, statusMap);
|
||||
Object.entries(statusMap).forEach(([endpoint, details]) => {
|
||||
globalEndpointCache[endpoint] = {
|
||||
enabled: details?.enabled ?? true,
|
||||
reason: details?.reason ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
// Get all requested endpoints from cache (including previously cached ones)
|
||||
const fullStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
const fullStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
|
||||
setEndpointStatus(fullStatus);
|
||||
setEndpointStatus(fullStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
} catch (err: any) {
|
||||
// On 401 (auth error), use optimistic fallback instead of disabling
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
globalEndpointCache[endpoint] = true; // Cache the optimistic value
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
globalEndpointCache[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -156,11 +182,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
|
||||
|
||||
// Fallback: assume all endpoints are enabled on error (optimistic)
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -186,6 +218,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: () => fetchAllEndpointStatuses(true),
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { FileId } from '@app/types/file';
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
|
||||
|
||||
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
|
||||
|
||||
export interface ToolAvailabilityInfo {
|
||||
available: boolean;
|
||||
reason?: ToolDisableCause;
|
||||
}
|
||||
|
||||
export type ToolAvailabilityMap = Partial<Record<ToolId, ToolAvailabilityInfo>>;
|
||||
|
||||
interface ToolManagementResult {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
@@ -11,6 +22,7 @@ interface ToolManagementResult {
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
setToolSelectedFileIds: (fileIds: FileId[]) => void;
|
||||
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
|
||||
toolAvailability: ToolAvailabilityMap;
|
||||
}
|
||||
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
@@ -19,9 +31,10 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
const { allTools } = useToolRegistry();
|
||||
const baseRegistry = allTools;
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
|
||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
// Keep tools enabled during loading (optimistic UX)
|
||||
@@ -38,22 +51,64 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
|
||||
}, [endpointsLoading, endpointStatus, baseRegistry]);
|
||||
|
||||
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
|
||||
const tool = baseRegistry[toolKey];
|
||||
if (!tool) {
|
||||
return 'unknown';
|
||||
}
|
||||
const endpoints = tool.endpoints || [];
|
||||
const disabledReasons: EndpointDisableReason[] = endpoints
|
||||
.filter(endpoint => endpointStatus[endpoint] === false)
|
||||
.map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG');
|
||||
|
||||
if (disabledReasons.some(reason => reason === 'DEPENDENCY')) {
|
||||
return 'missingDependency';
|
||||
}
|
||||
if (disabledReasons.some(reason => reason === 'CONFIG')) {
|
||||
return 'disabledByAdmin';
|
||||
}
|
||||
if (disabledReasons.length > 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
}, [baseRegistry, endpointDetails, endpointStatus]);
|
||||
|
||||
const toolAvailability = useMemo(() => {
|
||||
if (endpointsLoading) {
|
||||
return {};
|
||||
}
|
||||
const availability: ToolAvailabilityMap = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
const available = isToolAvailable(toolKey);
|
||||
availability[toolKey] = available
|
||||
? { available: true }
|
||||
: { available: false, reason: deriveToolDisableReason(toolKey) };
|
||||
});
|
||||
return availability;
|
||||
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
|
||||
|
||||
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
|
||||
const availableToolRegistry: Partial<ToolRegistry> = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
const baseTool = baseRegistry[toolKey];
|
||||
if (baseTool) {
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
};
|
||||
}
|
||||
const baseTool = baseRegistry[toolKey];
|
||||
if (!baseTool) return;
|
||||
const availabilityInfo = toolAvailability[toolKey];
|
||||
const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true;
|
||||
|
||||
// Check if tool is "coming soon" (has no component and no link)
|
||||
const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool';
|
||||
|
||||
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
|
||||
return;
|
||||
}
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
};
|
||||
});
|
||||
return availableToolRegistry;
|
||||
}, [isToolAvailable, baseRegistry]);
|
||||
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
|
||||
|
||||
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
|
||||
return toolKey ? toolRegistry[toolKey] || null : null;
|
||||
@@ -65,5 +120,6 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
toolRegistry,
|
||||
setToolSelectedFileIds,
|
||||
getSelectedTool,
|
||||
toolAvailability,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface UserPreferences {
|
||||
toolPanelModePromptSeen: boolean;
|
||||
showLegacyToolDescriptions: boolean;
|
||||
hasCompletedOnboarding: boolean;
|
||||
hideUnavailableTools: boolean;
|
||||
hideUnavailableConversions: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -19,6 +21,8 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
toolPanelModePromptSeen: false,
|
||||
showLegacyToolDescriptions: false,
|
||||
hasCompletedOnboarding: false,
|
||||
hideUnavailableTools: false,
|
||||
hideUnavailableConversions: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf_preferences';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface UpdateSummary {
|
||||
latest_version: string;
|
||||
latest_stable_version?: string;
|
||||
latest_version: string | null;
|
||||
latest_stable_version?: string | null;
|
||||
max_priority: 'urgent' | 'normal' | 'minor' | 'low';
|
||||
recommended_action?: string;
|
||||
any_breaking: boolean;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null;
|
||||
|
||||
export interface EndpointAvailabilityDetails {
|
||||
enabled: boolean;
|
||||
reason?: EndpointDisableReason;
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
import { connectionModeService } from '@desktop/services/connectionModeService';
|
||||
|
||||
|
||||
interface EndpointConfig {
|
||||
backendUrl: string;
|
||||
}
|
||||
@@ -128,6 +130,7 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
|
||||
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
endpointStatus: Record<string, boolean>;
|
||||
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
@@ -140,6 +143,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
});
|
||||
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
@@ -174,13 +178,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
const endpointsParam = endpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', {
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>('/api/v1/config/endpoints-availability', {
|
||||
params: { endpoints: endpointsParam },
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
setEndpointStatus(response.data);
|
||||
const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => {
|
||||
acc[endpointName] = {
|
||||
enabled: detail?.enabled ?? true,
|
||||
reason: detail?.reason ?? null,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, EndpointAvailabilityDetails>);
|
||||
|
||||
const statusMap = Object.keys(details).reduce((acc, key) => {
|
||||
acc[key] = details[key].enabled;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
|
||||
setEndpointDetails(prev => ({ ...prev, ...details }));
|
||||
setEndpointStatus(statusMap);
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
@@ -188,10 +206,13 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
|
||||
|
||||
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
|
||||
acc[endpointName] = true;
|
||||
const fallbackDetail: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpointName] = true;
|
||||
acc.details[endpointName] = fallbackDetail;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(fallbackStatus);
|
||||
}, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
|
||||
setEndpointStatus(fallbackStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
|
||||
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
@@ -209,6 +230,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
useEffect(() => {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -230,6 +252,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchAllEndpointStatuses,
|
||||
|
||||
Reference in New Issue
Block a user