mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Desktop connection SaaS: config, billing, team support (#5768)
Co-authored-by: James Brunton <[email protected]> Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
James Brunton
parent
86072ec91a
commit
5c39acecd8
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Core stub for QuickAccessBar footer extensions
|
||||
* Desktop build overrides this with actual credit counter implementation
|
||||
*/
|
||||
|
||||
interface QuickAccessBarFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function QuickAccessBarFooterExtensions(_props: QuickAccessBarFooterExtensionsProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
interface CloudBadgeProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub component for cloud badge (desktop override provides real implementation)
|
||||
* In web builds, this returns null since cloud routing is desktop-only
|
||||
*/
|
||||
export function CloudBadge(_props: CloudBadgeProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getActiveNavButton,
|
||||
} from '@app/components/shared/quickAccessBar/QuickAccessBar';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
import { QuickAccessBarFooterExtensions } from '@app/components/quickAccessBar/QuickAccessBarFooterExtensions';
|
||||
|
||||
const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -265,6 +266,8 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
{/* Spacer to push bottom buttons to bottom */}
|
||||
<div className="spacer" />
|
||||
|
||||
<QuickAccessBarFooterExtensions className="quick-access-footer" />
|
||||
|
||||
{/* Bottom section */}
|
||||
<Stack gap="lg" align="stretch">
|
||||
{bottomButtons.map((buttonConfig, index) => {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Core stub for SaaS Teams Section
|
||||
* Desktop layer provides the real implementation
|
||||
* This component only appears in SaaS mode
|
||||
*/
|
||||
export function SaaSTeamsSection() {
|
||||
// Core stub - return null (no team management in web builds)
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core stub for SaasPlanSection
|
||||
* This component returns null in non-desktop builds
|
||||
* The desktop layer shadows this with the real implementation
|
||||
*/
|
||||
export function SaasPlanSection() {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Core stub for Credit Exhausted Modal
|
||||
* Desktop build overrides this with actual modal implementation
|
||||
*/
|
||||
|
||||
interface CreditExhaustedModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function CreditExhaustedModal(_props: CreditExhaustedModalProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Core stub for Insufficient Credits Modal
|
||||
* Desktop build overrides this with actual modal implementation
|
||||
*/
|
||||
|
||||
interface InsufficientCreditsModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
toolId?: string;
|
||||
requiredCredits?: number;
|
||||
}
|
||||
|
||||
export function InsufficientCreditsModal(_props: InsufficientCreditsModalProps) {
|
||||
return null;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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 { useConversionCloudStatus } from "@app/hooks/useConversionCloudStatus";
|
||||
import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown";
|
||||
import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings";
|
||||
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
|
||||
@@ -63,7 +64,18 @@ const ConvertSettings = ({
|
||||
|
||||
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
// Get comprehensive conversion status (availability + cloud routing)
|
||||
const conversionStatus = useConversionCloudStatus();
|
||||
|
||||
const isConversionAvailable = (fromExt: string, toExt: string): boolean => {
|
||||
const conversionKey = `${fromExt}-${toExt}`;
|
||||
|
||||
// In desktop SaaS mode, check combined availability (local OR SaaS)
|
||||
if (conversionStatus.availability[conversionKey] !== undefined) {
|
||||
return conversionStatus.availability[conversionKey];
|
||||
}
|
||||
|
||||
// Fallback to local-only check (web mode or desktop non-SaaS mode)
|
||||
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
|
||||
if (!endpointKey) return false;
|
||||
|
||||
@@ -71,6 +83,10 @@ const ConvertSettings = ({
|
||||
return isAvailable;
|
||||
};
|
||||
|
||||
const doesConversionUseCloud = (fromExt: string, toExt: string): boolean => {
|
||||
return conversionStatus.cloudStatus[`${fromExt}-${toExt}`] || false;
|
||||
};
|
||||
|
||||
// Enhanced FROM options with endpoint availability
|
||||
const enhancedFromOptions = useMemo(() => {
|
||||
const baseOptions = FROM_FORMAT_OPTIONS.map(option => {
|
||||
@@ -107,18 +123,20 @@ const ConvertSettings = ({
|
||||
}
|
||||
|
||||
return filteredOptions;
|
||||
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
|
||||
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions, conversionStatus]);
|
||||
|
||||
// Enhanced TO options with endpoint availability
|
||||
// Enhanced TO options with endpoint availability and cloud status
|
||||
const enhancedToOptions = useMemo(() => {
|
||||
if (!parameters.fromExtension) return [];
|
||||
|
||||
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
|
||||
const enhanced = availableOptions.map(option => {
|
||||
const enabled = isConversionAvailable(parameters.fromExtension, option.value);
|
||||
const usesCloud = doesConversionUseCloud(parameters.fromExtension, option.value);
|
||||
return {
|
||||
...option,
|
||||
enabled
|
||||
enabled,
|
||||
usesCloud
|
||||
};
|
||||
});
|
||||
|
||||
@@ -128,7 +146,7 @@ const ConvertSettings = ({
|
||||
}
|
||||
|
||||
return enhanced;
|
||||
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
|
||||
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions, conversionStatus]);
|
||||
|
||||
const resetParametersToDefaults = () => {
|
||||
onParameterChange('imageOptions', {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { Stack, Text, Group, Button, Box, Popover, UnstyledButton, useMantineTheme, useMantineColorScheme } from "@mantine/core";
|
||||
import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown";
|
||||
import CloudOutlinedIcon from "@mui/icons-material/CloudOutlined";
|
||||
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
|
||||
|
||||
interface FormatOption {
|
||||
@@ -8,6 +9,7 @@ interface FormatOption {
|
||||
label: string;
|
||||
group: string;
|
||||
enabled?: boolean;
|
||||
usesCloud?: boolean;
|
||||
}
|
||||
|
||||
interface GroupedFormatDropdownProps {
|
||||
@@ -145,10 +147,20 @@ const GroupedFormatDropdown = ({
|
||||
fontSize: '0.75rem',
|
||||
height: '2rem',
|
||||
padding: '0 0.75rem',
|
||||
flexShrink: 0
|
||||
flexShrink: 0,
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
{option.usesCloud && (
|
||||
<CloudOutlinedIcon
|
||||
style={{
|
||||
fontSize: '0.625rem',
|
||||
marginLeft: '0.25rem',
|
||||
opacity: 0.7
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import { Button, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { useBackendHealth } from '@app/hooks/useBackendHealth';
|
||||
import { CloudBadge } from '@app/components/shared/CloudBadge';
|
||||
|
||||
export interface OperationButtonProps {
|
||||
onClick?: () => void;
|
||||
@@ -14,6 +15,7 @@ export interface OperationButtonProps {
|
||||
fullWidth?: boolean;
|
||||
mt?: string;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
showCloudBadge?: boolean;
|
||||
'data-testid'?: string;
|
||||
'data-tour'?: string;
|
||||
}
|
||||
@@ -29,6 +31,7 @@ const OperationButton = ({
|
||||
fullWidth = false,
|
||||
mt = 'md',
|
||||
type = 'button',
|
||||
showCloudBadge = false,
|
||||
'data-testid': dataTestId,
|
||||
'data-tour': dataTour
|
||||
}: OperationButtonProps) => {
|
||||
@@ -54,12 +57,17 @@ const OperationButton = ({
|
||||
color={color}
|
||||
data-testid={dataTestId}
|
||||
data-tour={dataTour}
|
||||
style={{ minHeight: '2.5rem' }}
|
||||
style={{ minHeight: '2.5rem', position: 'relative' }}
|
||||
>
|
||||
{isLoading
|
||||
? (loadingText || t("loading", "Loading..."))
|
||||
: (submitText || t("submit", "Submit"))
|
||||
}
|
||||
{showCloudBadge && (
|
||||
<Box style={{ position: 'absolute', top: 4, right: 4 }}>
|
||||
<CloudBadge />
|
||||
</Box>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ExecuteButtonConfig {
|
||||
isVisible?: boolean;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
showCloudBadge?: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewStepConfig<TParams = unknown> {
|
||||
@@ -105,6 +106,7 @@ export function createToolFlow<TParams = unknown>(config: ToolFlowConfig<TParams
|
||||
disabled={config.executeButton.disabled}
|
||||
loadingText={config.executeButton.loadingText}
|
||||
submitText={config.executeButton.text}
|
||||
showCloudBadge={config.executeButton.showCloudBadge ?? config.review.operation.willUseCloud ?? false}
|
||||
data-testid={config.executeButton.testId}
|
||||
data-tour="run-button"
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { CloudBadge } from "@app/components/shared/CloudBadge";
|
||||
import { useToolCloudStatus } from "@app/hooks/useToolCloudStatus";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: ToolId;
|
||||
@@ -38,6 +40,10 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
const fav = isFavorite(id as ToolId);
|
||||
|
||||
// Check if this tool will route to SaaS backend (desktop only)
|
||||
const endpointName = tool.endpoints?.[0];
|
||||
const usesCloud = useToolCloudStatus(endpointName);
|
||||
|
||||
const handleClick = (id: ToolId) => {
|
||||
if (isUnavailable) return;
|
||||
if (tool.link) {
|
||||
@@ -98,6 +104,9 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
{t('toolPanel.alpha', 'Alpha')}
|
||||
</Badge>
|
||||
)}
|
||||
{usesCloud && !isUnavailable && (
|
||||
<CloudBadge />
|
||||
)}
|
||||
</div>
|
||||
{matchedSynonym && (
|
||||
<span style={{
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createContext, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Core stub for SaaS Team Context
|
||||
* Desktop layer provides the real implementation
|
||||
*/
|
||||
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
joinedAt: number;
|
||||
}
|
||||
|
||||
export interface TeamInvitation {
|
||||
id: string;
|
||||
email: string;
|
||||
invitedAt: number;
|
||||
invitedBy: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
isPersonal: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface SaaSTeamContextValue {
|
||||
currentTeam: Team | null;
|
||||
teams: Team[];
|
||||
teamMembers: TeamMember[];
|
||||
teamInvitations: TeamInvitation[];
|
||||
receivedInvitations: TeamInvitation[];
|
||||
isTeamLeader: boolean;
|
||||
isPersonalTeam: boolean;
|
||||
loading: boolean;
|
||||
inviteUser: (teamId: string, email: string) => Promise<void>;
|
||||
acceptInvitation: (invitationId: string) => Promise<void>;
|
||||
rejectInvitation: (invitationId: string) => Promise<void>;
|
||||
cancelInvitation: (invitationId: string) => Promise<void>;
|
||||
removeMember: (teamId: string, memberId: string) => Promise<void>;
|
||||
leaveTeam: (teamId: string) => Promise<void>;
|
||||
refreshTeams: () => Promise<void>;
|
||||
}
|
||||
|
||||
const SaaSTeamContext = createContext<SaaSTeamContextValue | undefined>(undefined);
|
||||
|
||||
export function SaaSTeamProvider({ children }: { children: ReactNode }) {
|
||||
// Core stub - no-op implementation
|
||||
return children;
|
||||
}
|
||||
|
||||
export function useSaaSTeam(): SaaSTeamContextValue {
|
||||
// Core stub - return default values
|
||||
return {
|
||||
currentTeam: null,
|
||||
teams: [],
|
||||
teamMembers: [],
|
||||
teamInvitations: [],
|
||||
receivedInvitations: [],
|
||||
isTeamLeader: false,
|
||||
isPersonalTeam: true,
|
||||
loading: false,
|
||||
inviteUser: async () => {},
|
||||
acceptInvitation: async () => {},
|
||||
rejectInvitation: async () => {},
|
||||
cancelInvitation: async () => {},
|
||||
removeMember: async () => {},
|
||||
leaveTeam: async () => {},
|
||||
refreshTeams: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
export { SaaSTeamContext };
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Core stub for SaasBillingContext.
|
||||
* Returns null in web/core builds — desktop layer shadows this with the real implementation.
|
||||
* See: frontend/src/desktop/contexts/SaasBillingContext.tsx
|
||||
*/
|
||||
export const useSaaSBilling = (): null => null;
|
||||
|
||||
export function SaasBillingProvider({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ConvertParameters, defaultParameters } from '@app/hooks/tools/convert/useConvertParameters';
|
||||
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
|
||||
import { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import { getEndpointUrl, isImageFormat, isWebFormat, isOfficeFormat } from '@app/utils/convertUtils';
|
||||
import { getEndpointUrl, getEndpointName, isImageFormat, isWebFormat, isOfficeFormat } from '@app/utils/convertUtils';
|
||||
import { useToolCloudStatus } from '@app/hooks/useToolCloudStatus';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const shouldProcessFilesSeparately = (
|
||||
@@ -195,9 +196,21 @@ export const convertOperationConfig = {
|
||||
defaultParameters,
|
||||
} as const;
|
||||
|
||||
export const useConvertOperation = () => {
|
||||
export const useConvertOperation = (parameters?: ConvertParameters) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Calculate current endpoint name for cloud detection
|
||||
const currentEndpointName = useMemo(() => {
|
||||
if (!parameters?.fromExtension || !parameters?.toExtension) return undefined;
|
||||
|
||||
// Map PDF/X to use PDF/A endpoint (same as in convertProcessor)
|
||||
const actualToExtension = parameters.toExtension === 'pdfx' ? 'pdfa' : parameters.toExtension;
|
||||
return getEndpointName(parameters.fromExtension, actualToExtension);
|
||||
}, [parameters?.fromExtension, parameters?.toExtension]);
|
||||
|
||||
// Check if current conversion will use cloud
|
||||
const willUseCloud = useToolCloudStatus(currentEndpointName);
|
||||
|
||||
const customConvertProcessor = useCallback(async (
|
||||
parameters: ConvertParameters,
|
||||
selectedFiles: File[]
|
||||
@@ -205,7 +218,7 @@ export const useConvertOperation = () => {
|
||||
return convertProcessor(parameters, selectedFiles);
|
||||
}, []);
|
||||
|
||||
return useToolOperation<ConvertParameters>({
|
||||
const operation = useToolOperation<ConvertParameters>({
|
||||
...convertOperationConfig,
|
||||
customProcessor: customConvertProcessor, // Use instance-specific processor for translation support
|
||||
getErrorMessage: (error) => {
|
||||
@@ -218,4 +231,10 @@ export const useConvertOperation = () => {
|
||||
return t("convert.errorConversion", "An error occurred while converting the file.");
|
||||
},
|
||||
});
|
||||
|
||||
// Override willUseCloud with our calculated value
|
||||
return {
|
||||
...operation,
|
||||
willUseCloud,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ import { createNewStirlingFileStub } from '@app/types/fileContext';
|
||||
import { ToolOperation } from '@app/types/file';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import { ensureBackendReady } from '@app/services/backendReadinessGuard';
|
||||
import { useWillUseCloud } from '@app/hooks/useWillUseCloud';
|
||||
import { useCreditCheck } from '@app/hooks/useCreditCheck';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export type { ProcessingProgress, ResponseHandler };
|
||||
@@ -147,6 +149,7 @@ export interface ToolOperationHook<TParams = void> {
|
||||
status: string;
|
||||
errorMessage: string | null;
|
||||
progress: ProcessingProgress | null;
|
||||
willUseCloud?: boolean;
|
||||
|
||||
// Actions
|
||||
executeOperation: (params: TParams, selectedFiles: StirlingFile[]) => Promise<void>;
|
||||
@@ -183,6 +186,16 @@ export const useToolOperation = <TParams>(
|
||||
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles } = useToolResources();
|
||||
|
||||
const { checkCredits } = useCreditCheck(config.operationType);
|
||||
|
||||
// Determine endpoint for cloud usage check
|
||||
const endpointString = config.toolType !== ToolType.custom && config.endpoint
|
||||
? (typeof config.endpoint === 'function'
|
||||
? (config.defaultParameters ? config.endpoint(config.defaultParameters) : undefined)
|
||||
: config.endpoint)
|
||||
: undefined;
|
||||
const willUseCloud = useWillUseCloud(endpointString);
|
||||
|
||||
// Track last operation for undo functionality
|
||||
const lastOperationRef = useRef<{
|
||||
inputFiles: File[];
|
||||
@@ -217,7 +230,24 @@ export const useToolOperation = <TParams>(
|
||||
return;
|
||||
}
|
||||
|
||||
const backendReady = await ensureBackendReady();
|
||||
// Get endpoint (static or dynamic) for backend readiness check
|
||||
const endpoint = config.customProcessor
|
||||
? undefined // Custom processors may not have endpoints
|
||||
: typeof config.endpoint === 'function'
|
||||
? config.endpoint(params)
|
||||
: config.endpoint;
|
||||
|
||||
// Credit check for cloud operations (desktop SaaS mode only, no-op in web builds)
|
||||
if (willUseCloud && endpoint) {
|
||||
const creditError = await checkCredits();
|
||||
if (creditError !== null) {
|
||||
actions.setError(creditError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Backend readiness check (will skip for SaaS-routed endpoints)
|
||||
const backendReady = await ensureBackendReady(endpoint);
|
||||
if (!backendReady) {
|
||||
actions.setError(t('backendHealth.offline', 'Embedded backend is offline. Please try again shortly.'));
|
||||
return;
|
||||
@@ -507,7 +537,7 @@ export const useToolOperation = <TParams>(
|
||||
actions.setLoading(false);
|
||||
actions.setProgress(null);
|
||||
}
|
||||
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles]);
|
||||
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, willUseCloud, checkCredits]);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
cancelApiCalls();
|
||||
@@ -591,6 +621,7 @@ export const useToolOperation = <TParams>(
|
||||
status: state.status,
|
||||
errorMessage: state.errorMessage,
|
||||
progress: state.progress,
|
||||
willUseCloud,
|
||||
|
||||
// Actions
|
||||
executeOperation,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Comprehensive conversion status data
|
||||
*/
|
||||
export interface ConversionStatus {
|
||||
availability: Record<string, boolean>; // Available on local OR SaaS?
|
||||
cloudStatus: Record<string, boolean>; // Will use cloud?
|
||||
localOnly: Record<string, boolean>; // Available ONLY locally (not on SaaS)?
|
||||
}
|
||||
|
||||
/**
|
||||
* Core stub for conversion cloud status checking
|
||||
* Desktop layer provides the real implementation
|
||||
* In web builds, always returns empty objects (no cloud routing)
|
||||
*/
|
||||
export function useConversionCloudStatus(): ConversionStatus {
|
||||
return {
|
||||
availability: {},
|
||||
cloudStatus: {},
|
||||
localOnly: {},
|
||||
}; // Core stub - web builds don't use cloud
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Core stub for credit checking before cloud operations
|
||||
* Desktop layer shadows this with the real implementation
|
||||
* In web builds, always allows the operation (no credit system)
|
||||
*/
|
||||
export function useCreditCheck(_operationType?: string) {
|
||||
return {
|
||||
checkCredits: async (): Promise<string | null> => null, // null = allowed
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core stub for tool cloud status checking
|
||||
* Desktop layer provides the real implementation
|
||||
* In web builds, always returns false (no cloud routing)
|
||||
*/
|
||||
export function useToolCloudStatus(_endpointName?: string): boolean {
|
||||
return false; // Core stub - web builds don't use cloud
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core stub for cloud usage detection
|
||||
* Desktop layer provides the real implementation
|
||||
* In web builds, always returns false since there's no cloud routing
|
||||
*/
|
||||
export function useWillUseCloud(_endpoint?: string): boolean {
|
||||
return false; // Core stub - web builds don't use cloud
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Default backend readiness guard (web builds do not need to wait for
|
||||
* anything outside the browser, so we always report ready).
|
||||
* @param _endpoint - Optional endpoint path (not used in web builds)
|
||||
*/
|
||||
export async function ensureBackendReady(): Promise<boolean> {
|
||||
export async function ensureBackendReady(_endpoint?: string): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,83 +1,9 @@
|
||||
// frontend/src/services/httpErrorHandler.ts
|
||||
import axios from 'axios';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { broadcastErroredFiles, extractErrorFileIds, normalizeAxiosErrorData } from '@app/services/errorUtils';
|
||||
import { showSpecialErrorToast } from '@app/services/specialErrorToasts';
|
||||
|
||||
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
|
||||
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
|
||||
|
||||
function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
|
||||
return s && s.length > max ? `${s.slice(0, max)}…` : s;
|
||||
}
|
||||
|
||||
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
|
||||
const s = (msg || '').trim();
|
||||
if (!s) return true;
|
||||
// Common unhelpful payloads we see
|
||||
if (s === '{}' || s === '[]') return true;
|
||||
if (/^request failed/i.test(s)) return true;
|
||||
if (/^network error/i.test(s)) return true;
|
||||
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
|
||||
return false;
|
||||
}
|
||||
|
||||
function titleForStatus(status?: number): string {
|
||||
if (!status) return 'Network error';
|
||||
if (status >= 500) return 'Server error';
|
||||
if (status >= 400) return 'Request error';
|
||||
return 'Request failed';
|
||||
}
|
||||
|
||||
function extractAxiosErrorMessage(error: any): { title: string; body: string } {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const _statusText = error.response?.statusText || '';
|
||||
let parsed: any = undefined;
|
||||
const raw = error.response?.data;
|
||||
if (typeof raw === 'string') {
|
||||
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
|
||||
} else {
|
||||
parsed = raw;
|
||||
}
|
||||
const extractIds = (): string[] | undefined => {
|
||||
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
|
||||
const rawText = typeof raw === 'string' ? raw : '';
|
||||
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
||||
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
|
||||
};
|
||||
|
||||
const body = ((): string => {
|
||||
const data = parsed;
|
||||
if (!data) return typeof raw === 'string' ? raw : '';
|
||||
const ids = extractIds();
|
||||
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
|
||||
if (data?.message) return data.message as string;
|
||||
if (typeof raw === 'string') return raw;
|
||||
try { return JSON.stringify(data); } catch { return ''; }
|
||||
})();
|
||||
const ids = extractIds();
|
||||
const title = titleForStatus(status);
|
||||
if (ids && ids.length > 0) {
|
||||
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
|
||||
}
|
||||
if (status === 422) {
|
||||
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
try {
|
||||
const msg = (error?.message || String(error)) as string;
|
||||
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
|
||||
} catch (e) {
|
||||
// ignore extraction errors
|
||||
console.debug('extractAxiosErrorMessage', e);
|
||||
return { title: 'Network error', body: FRIENDLY_FALLBACK };
|
||||
}
|
||||
}
|
||||
import { handleSaaSError } from '@app/services/saasErrorInterceptor';
|
||||
import { clampText, extractAxiosErrorMessage } from '@app/services/httpErrorUtils';
|
||||
|
||||
// Module-scoped state to reduce global variable usage
|
||||
const recentSpecialByEndpoint: Record<string, number> = {};
|
||||
@@ -126,6 +52,9 @@ export async function handleHttpError(error: any): Promise<boolean> {
|
||||
console.debug('[httpErrorHandler] Suppressing 401 on auth page:', pathname);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (handleSaaSError(error)) return true;
|
||||
|
||||
// Compute title/body (friendly) from the error object
|
||||
const { title, body } = extractAxiosErrorMessage(error);
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const FRIENDLY_FALLBACK = 'There was an error processing your request.';
|
||||
const MAX_TOAST_BODY_CHARS = 400; // avoid massive, unreadable toasts
|
||||
|
||||
export function clampText(s: string, max = MAX_TOAST_BODY_CHARS): string {
|
||||
return s && s.length > max ? `${s.slice(0, max)}…` : s;
|
||||
}
|
||||
|
||||
function isUnhelpfulMessage(msg: string | null | undefined): boolean {
|
||||
const s = (msg || '').trim();
|
||||
if (!s) return true;
|
||||
// Common unhelpful payloads we see
|
||||
if (s === '{}' || s === '[]') return true;
|
||||
if (/^request failed/i.test(s)) return true;
|
||||
if (/^network error/i.test(s)) return true;
|
||||
if (/^[45]\d\d\b/.test(s)) return true; // "500 Server Error" etc.
|
||||
return false;
|
||||
}
|
||||
|
||||
function titleForStatus(status?: number): string {
|
||||
if (!status) return 'Network error';
|
||||
if (status >= 500) return 'Server error';
|
||||
if (status >= 400) return 'Request error';
|
||||
return 'Request failed';
|
||||
}
|
||||
|
||||
export function extractAxiosErrorMessage(error: any): { title: string; body: string } {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const status = error.response?.status;
|
||||
const _statusText = error.response?.statusText || '';
|
||||
let parsed: any = undefined;
|
||||
const raw = error.response?.data;
|
||||
if (typeof raw === 'string') {
|
||||
try { parsed = JSON.parse(raw); } catch { /* keep as string */ }
|
||||
} else {
|
||||
parsed = raw;
|
||||
}
|
||||
const extractIds = (): string[] | undefined => {
|
||||
if (Array.isArray(parsed?.errorFileIds)) return parsed.errorFileIds as string[];
|
||||
const rawText = typeof raw === 'string' ? raw : '';
|
||||
const uuidMatches = rawText.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g);
|
||||
return uuidMatches && uuidMatches.length > 0 ? Array.from(new Set(uuidMatches)) : undefined;
|
||||
};
|
||||
|
||||
const body = ((): string => {
|
||||
const data = parsed;
|
||||
if (!data) return typeof raw === 'string' ? raw : '';
|
||||
const ids = extractIds();
|
||||
if (ids && ids.length > 0) return `Failed files: ${ids.join(', ')}`;
|
||||
if (data?.message) return data.message as string;
|
||||
if (typeof raw === 'string') return raw;
|
||||
try { return JSON.stringify(data); } catch { return ''; }
|
||||
})();
|
||||
const ids = extractIds();
|
||||
const title = titleForStatus(status);
|
||||
if (ids && ids.length > 0) {
|
||||
return { title, body: 'Process failed due to invalid/corrupted file(s)' };
|
||||
}
|
||||
if (status === 422) {
|
||||
const fallbackMsg = 'Process failed due to invalid/corrupted file(s)';
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? fallbackMsg : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
const bodyMsg = isUnhelpfulMessage(body) ? FRIENDLY_FALLBACK : body;
|
||||
return { title, body: bodyMsg };
|
||||
}
|
||||
try {
|
||||
const msg = (error?.message || String(error)) as string;
|
||||
return { title: 'Network error', body: isUnhelpfulMessage(msg) ? FRIENDLY_FALLBACK : msg };
|
||||
} catch (e) {
|
||||
// ignore extraction errors
|
||||
console.debug('extractAxiosErrorMessage', e);
|
||||
return { title: 'Network error', body: FRIENDLY_FALLBACK };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Core stub for SaaS backend error interception.
|
||||
* Desktop layer shadows this with the real implementation.
|
||||
* In web builds there are no SaaS requests, so this always returns false.
|
||||
*/
|
||||
export function handleSaaSError(_error: unknown): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const Convert = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const convertParams = useConvertParameters();
|
||||
const convertOperation = useConvertOperation();
|
||||
const convertOperation = useConvertOperation(convertParams.parameters);
|
||||
|
||||
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled(convertParams.getEndpointName());
|
||||
|
||||
|
||||
Reference in New Issue
Block a user