Add UI for backend selection

This commit is contained in:
James Brunton 2025-11-14 17:12:09 +00:00
parent 93c3f9e84b
commit fb704cc901
9 changed files with 732 additions and 13 deletions

View File

@ -0,0 +1,275 @@
import React, { useState, useEffect } from 'react';
import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import {
connectionModeService,
ConnectionConfig,
ServerConfig,
} from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService';
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
export const ConnectionSettings: React.FC = () => {
const { t } = useTranslation();
const [config, setConfig] = useState<ConnectionConfig | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false);
const [showServerModal, setShowServerModal] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [newServerConfig, setNewServerConfig] = useState<ServerConfig | null>(null);
// Load current config on mount
useEffect(() => {
const loadConfig = async () => {
const currentConfig = await connectionModeService.getCurrentConfig();
setConfig(currentConfig);
if (currentConfig.mode === 'server') {
const user = await authService.getUserInfo();
setUserInfo(user);
}
};
loadConfig();
}, []);
const handleSwitchToOffline = async () => {
try {
setLoading(true);
await connectionModeService.switchToOffline();
// Reload config
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
setUserInfo(null);
} catch (error) {
console.error('Failed to switch to offline:', error);
} finally {
setLoading(false);
}
};
const handleSwitchToServer = () => {
setShowServerModal(true);
};
const handleServerConfigSubmit = (serverConfig: ServerConfig) => {
setNewServerConfig(serverConfig);
setShowServerModal(false);
setShowLoginModal(true);
};
const handleLogin = async (username: string, password: string) => {
if (!newServerConfig) return;
try {
setLoading(true);
// Login
await authService.login(newServerConfig.url, username, password);
// Switch to server mode
await connectionModeService.switchToServer(newServerConfig);
// Reload config and user info
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
const user = await authService.getUserInfo();
setUserInfo(user);
setShowLoginModal(false);
setNewServerConfig(null);
} catch (error) {
console.error('Login failed:', error);
throw error; // Let LoginForm handle the error
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
try {
setLoading(true);
await authService.logout();
// Switch to offline mode
await connectionModeService.switchToOffline();
// Reload config
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
setUserInfo(null);
} catch (error) {
console.error('Logout failed:', error);
} finally {
setLoading(false);
}
};
if (!config) {
return <Text>{t('common.loading', 'Loading...')}</Text>;
}
return (
<>
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
<Badge color={config.mode === 'offline' ? 'blue' : 'green'} variant="light">
{config.mode === 'offline'
? t('settings.connection.mode.offline', 'Offline')
: t('settings.connection.mode.server', 'Server')}
</Badge>
</Group>
{config.mode === 'server' && config.server_config && (
<>
<div>
<Text size="sm" fw={500}>
{t('settings.connection.server', 'Server')}
</Text>
<Text size="sm" c="dimmed">
{config.server_config.url}
</Text>
</div>
{userInfo && (
<div>
<Text size="sm" fw={500}>
{t('settings.connection.user', 'Logged in as')}
</Text>
<Text size="sm" c="dimmed">
{userInfo.username}
{userInfo.email && ` (${userInfo.email})`}
</Text>
</div>
)}
</>
)}
<Group mt="md">
{config.mode === 'offline' ? (
<Button onClick={handleSwitchToServer} disabled={loading}>
{t('settings.connection.switchToServer', 'Connect to Server')}
</Button>
) : (
<>
<Button onClick={handleSwitchToOffline} variant="default" disabled={loading}>
{t('settings.connection.switchToOffline', 'Switch to Offline')}
</Button>
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
{t('settings.connection.logout', 'Logout')}
</Button>
</>
)}
</Group>
</Stack>
</Card>
{/* Server selection modal */}
<Modal
opened={showServerModal}
onClose={() => setShowServerModal(false)}
title={t('settings.connection.selectServer', 'Select Server')}
>
<ServerSelectionInSettings onSubmit={handleServerConfigSubmit} />
</Modal>
{/* Login modal */}
<Modal
opened={showLoginModal}
onClose={() => {
setShowLoginModal(false);
setNewServerConfig(null);
}}
title={t('settings.connection.login', 'Login')}
>
{newServerConfig && (
<LoginForm
serverUrl={newServerConfig.url}
onLogin={handleLogin}
loading={loading}
/>
)}
</Modal>
</>
);
};
// Mini server selection component for settings
const ServerSelectionInSettings: React.FC<{ onSubmit: (config: ServerConfig) => void }> = ({
onSubmit,
}) => {
const { t } = useTranslation();
const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas');
const [customUrl, setCustomUrl] = useState('');
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim();
if (!url) {
setError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
return;
}
setTesting(true);
setError(null);
try {
const isReachable = await connectionModeService.testConnection(url);
if (!isReachable) {
setError(t('setup.server.error.unreachable', 'Could not connect to server'));
setTesting(false);
return;
}
onSubmit({
url,
server_type: serverType,
});
} catch (err) {
setError(err instanceof Error ? err.message : t('setup.server.error.testFailed', 'Connection test failed'));
setTesting(false);
}
};
return (
<Stack gap="md">
<Radio.Group value={serverType} onChange={(value) => setServerType(value as 'saas' | 'selfhosted')}>
<Stack gap="xs">
<Radio value="saas" label={t('setup.server.type.saas', 'Stirling PDF SaaS')} />
<Radio value="selfhosted" label={t('setup.server.type.selfhosted', 'Self-hosted server')} />
</Stack>
</Radio.Group>
{serverType === 'selfhosted' && (
<TextInput
label={t('setup.server.url.label', 'Server URL')}
placeholder="https://your-server.com"
value={customUrl}
onChange={(e) => {
setCustomUrl(e.target.value);
setError(null);
}}
disabled={testing}
error={error}
/>
)}
{error && !customUrl && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Button onClick={handleSubmit} loading={testing} fullWidth>
{testing ? t('setup.server.testing', 'Testing...') : t('common.continue', 'Continue')}
</Button>
</Stack>
);
};

View File

@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { Stack, TextInput, PasswordInput, Button, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface LoginFormProps {
serverUrl: string;
onLogin: (username: string, password: string) => Promise<void>;
loading: boolean;
}
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loading }) => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!username.trim()) {
setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username'));
return;
}
if (!password) {
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
return;
}
setValidationError(null);
await onLogin(username.trim(), password);
};
return (
<form onSubmit={handleSubmit}>
<Stack gap="md" mt="lg">
<Text size="sm" c="dimmed">
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{serverUrl}</strong>
</Text>
<TextInput
label={t('setup.login.username.label', 'Username')}
placeholder={t('setup.login.username.placeholder', 'Enter your username')}
value={username}
onChange={(e) => {
setUsername(e.target.value);
setValidationError(null);
}}
disabled={loading}
required
/>
<PasswordInput
label={t('setup.login.password.label', 'Password')}
placeholder={t('setup.login.password.placeholder', 'Enter your password')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setValidationError(null);
}}
disabled={loading}
required
/>
{validationError && (
<Text c="red" size="sm">
{validationError}
</Text>
)}
<Button type="submit" loading={loading} disabled={loading} mt="md">
{t('setup.login.submit', 'Login')}
</Button>
</Stack>
</form>
);
};

View File

@ -0,0 +1,47 @@
import React from 'react';
import { Stack, Button, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { IconCloud, IconDeviceDesktop } from '@tabler/icons-react';
interface ModeSelectionProps {
onSelect: (mode: 'offline' | 'server') => void;
loading: boolean;
}
export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading }) => {
const { t } = useTranslation();
return (
<Stack gap="md" mt="lg">
<Button
size="lg"
variant="light"
onClick={() => onSelect('offline')}
disabled={loading}
leftSection={<IconDeviceDesktop size={24} />}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600}>{t('setup.mode.offline.title', 'Use Offline')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.offline.description', 'Run locally without an internet connection')}
</Text>
</div>
</Button>
<Button
size="lg"
variant="light"
onClick={() => onSelect('server')}
disabled={loading}
leftSection={<IconCloud size={24} />}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600}>{t('setup.mode.server.title', 'Connect to Server')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.server.description', 'Connect to a remote Stirling PDF server')}
</Text>
</div>
</Button>
</Stack>
);
};

View File

@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { Stack, Button, TextInput, Radio, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ServerConfig } from '@app/services/connectionModeService';
import { connectionModeService } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
interface ServerSelectionProps {
onSelect: (config: ServerConfig) => void;
loading: boolean;
}
export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, loading }) => {
const { t } = useTranslation();
const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas');
const [customUrl, setCustomUrl] = useState('');
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const handleContinue = async () => {
const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim();
if (!url) {
setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
return;
}
// Test connection before proceeding
setTesting(true);
setTestError(null);
try {
const isReachable = await connectionModeService.testConnection(url);
if (!isReachable) {
setTestError(t('setup.server.error.unreachable', 'Could not connect to server'));
setTesting(false);
return;
}
// Connection successful
onSelect({
url,
server_type: serverType,
});
} catch (error) {
console.error('Connection test failed:', error);
setTestError(
error instanceof Error
? error.message
: t('setup.server.error.testFailed', 'Connection test failed')
);
} finally {
setTesting(false);
}
};
return (
<Stack gap="md" mt="lg">
<Radio.Group value={serverType} onChange={(value) => setServerType(value as 'saas' | 'selfhosted')}>
<Stack gap="xs">
<Radio value="saas" label={t('setup.server.type.saas', 'Stirling PDF SaaS (stirling.com/app)')} />
<Radio value="selfhosted" label={t('setup.server.type.selfhosted', 'Self-hosted server')} />
</Stack>
</Radio.Group>
{serverType === 'selfhosted' && (
<TextInput
label={t('setup.server.url.label', 'Server URL')}
placeholder="https://your-server.com"
value={customUrl}
onChange={(e) => {
setCustomUrl(e.target.value);
setTestError(null);
}}
disabled={loading || testing}
error={testError}
description={t(
'setup.server.url.description',
'Enter the full URL of your self-hosted Stirling PDF server'
)}
/>
)}
{testError && (
<Text c="red" size="sm">
{testError}
</Text>
)}
<Button onClick={handleContinue} loading={testing || loading} disabled={loading} mt="md">
{testing
? t('setup.server.testing', 'Testing connection...')
: t('common.continue', 'Continue')}
</Button>
</Stack>
);
};

View File

@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { Container, Paper, Stepper, Button, Group, Title, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ModeSelection } from '@app/components/SetupWizard/ModeSelection';
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService';
enum SetupStep {
ModeSelection,
ServerSelection,
Login,
}
interface SetupWizardProps {
onComplete: () => void;
}
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleModeSelection = (mode: 'offline' | 'server') => {
setSelectedMode(mode);
setError(null);
if (mode === 'offline') {
// Skip directly to completion for offline mode
handleOfflineSetup();
} else {
// Go to server selection
setActiveStep(SetupStep.ServerSelection);
}
};
const handleOfflineSetup = async () => {
try {
setLoading(true);
setError(null);
// Set connection mode to offline
await connectionModeService.switchToOffline();
// Start the local backend
await tauriBackendService.startBackend();
// Complete setup
onComplete();
} catch (err) {
console.error('Failed to set up offline mode:', err);
setError(err instanceof Error ? err.message : 'Failed to set up offline mode');
setLoading(false);
}
};
const handleServerSelection = (config: ServerConfig) => {
setServerConfig(config);
setError(null);
setActiveStep(SetupStep.Login);
};
const handleLogin = async (username: string, password: string) => {
if (!serverConfig) {
setError('No server configured');
return;
}
try {
setLoading(true);
setError(null);
// Attempt login
await authService.login(serverConfig.url, username, password);
// Set connection mode to server
await connectionModeService.switchToServer(serverConfig);
// Start local backend (for hybrid execution support)
await tauriBackendService.startBackend();
// Complete setup
onComplete();
} catch (err) {
console.error('Login failed:', err);
setError(err instanceof Error ? err.message : 'Login failed');
setLoading(false);
}
};
const handleBack = () => {
setError(null);
if (activeStep === SetupStep.Login) {
// From login back to server selection
setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) {
// From server selection back to mode selection
setActiveStep(SetupStep.ModeSelection);
setSelectedMode(null);
setServerConfig(null);
}
};
return (
<Container size="sm" style={{ marginTop: '4rem' }}>
<Paper shadow="md" p="xl" radius="md">
<Title order={1} mb="md">
{t('setup.welcome', 'Welcome to Stirling PDF')}
</Title>
<Text size="sm" c="dimmed" mb="xl">
{t('setup.description', 'Get started by choosing how you want to use Stirling PDF')}
</Text>
<Stepper active={activeStep} breakpoint="sm">
<Stepper.Step
label={t('setup.step1.label', 'Choose Mode')}
description={t('setup.step1.description', 'Offline or Server')}
>
<ModeSelection onSelect={handleModeSelection} loading={loading} />
</Stepper.Step>
<Stepper.Step
label={t('setup.step2.label', 'Select Server')}
description={t('setup.step2.description', 'SaaS or Self-hosted')}
>
<ServerSelection onSelect={handleServerSelection} loading={loading} />
</Stepper.Step>
<Stepper.Step
label={t('setup.step3.label', 'Login')}
description={t('setup.step3.description', 'Enter credentials')}
>
<LoginForm
serverUrl={serverConfig?.url || ''}
onLogin={handleLogin}
loading={loading}
/>
</Stepper.Step>
</Stepper>
{error && (
<Text c="red" size="sm" mt="md">
{error}
</Text>
)}
{activeStep > SetupStep.ModeSelection && (
<Group justify="center" mt="xl">
<Button variant="default" onClick={handleBack} disabled={loading}>
{t('common.back', 'Back')}
</Button>
</Group>
)}
</Paper>
</Container>
);
};

View File

@ -0,0 +1,5 @@
/**
* Connection-related constants for desktop app
*/
export const STIRLING_SAAS_URL = 'https://stirling.com/app';

View File

@ -1,18 +1,55 @@
import { useEffect, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { useOpenedFile } from '@app/hooks/useOpenedFile';
import { fileOpenService } from '@app/services/fileOpenService';
import { useFileManagement } from '@app/contexts/file/fileHooks';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
/**
* App initialization hook
* Desktop version: Handles Tauri-specific initialization
* - Starts the backend on app startup
* - Checks for first launch and shows setup wizard if needed
* - Starts the backend on app startup (after setup)
* - Initializes auth state for server mode
* - Handles files opened with the app (adds directly to FileContext)
*/
export function useAppInitialization(): void {
// Initialize backend on app startup
useBackendInitializer();
export function useAppInitialization(): { isFirstLaunch: boolean; setupComplete: boolean } {
const [isFirstLaunch, setIsFirstLaunch] = useState(false);
const [setupComplete, setSetupComplete] = useState(false);
const setupCheckCompleteRef = useRef(false);
// Check if this is first launch
useEffect(() => {
const checkFirstLaunch = async () => {
try {
const firstLaunch = await connectionModeService.isFirstLaunch();
setIsFirstLaunch(firstLaunch);
if (!firstLaunch) {
// Not first launch - initialize normally
await authService.initializeAuthState();
setSetupComplete(true);
}
setupCheckCompleteRef.current = true;
} catch (error) {
console.error('Failed to check first launch:', error);
// On error, assume not first launch and proceed
setIsFirstLaunch(false);
setSetupComplete(true);
setupCheckCompleteRef.current = true;
}
};
if (!setupCheckCompleteRef.current) {
checkFirstLaunch();
}
}, []);
// Initialize backend on app startup (only if setup is complete)
const shouldStartBackend = setupComplete && !isFirstLaunch;
useBackendInitializer(shouldStartBackend);
// Get file management actions
const { addFiles } = useFileManagement();
@ -91,4 +128,14 @@ export function useAppInitialization(): void {
// Clean up listener on unmount
return unlisten;
}, [addFiles]);
return { isFirstLaunch, setupComplete };
}
export function useSetupCompletion(): (completed: boolean) => void {
const [, setSetupComplete] = useState(false);
return (completed: boolean) => {
setSetupComplete(completed);
};
}

View File

@ -5,12 +5,18 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
/**
* Hook to initialize backend and monitor health
* @param enabled - Whether to initialize the backend (default: true)
*/
export function useBackendInitializer() {
export function useBackendInitializer(enabled = true) {
const { status, checkHealth } = useBackendHealth();
const { backendUrl } = useEndpointConfig();
useEffect(() => {
// Skip if disabled
if (!enabled) {
return;
}
// Skip if backend already running
if (tauriBackendService.isBackendRunning()) {
void checkHealth();
@ -36,5 +42,5 @@ export function useBackendInitializer() {
if (status !== 'healthy' && status !== 'starting') {
void initializeBackend();
}
}, [status, backendUrl, checkHealth]);
}, [enabled, status, backendUrl, checkHealth]);
}

View File

@ -1,4 +1,4 @@
import { connectionModeService, ConnectionMode } from './connectionModeService';
import { connectionModeService } from '@app/services/connectionModeService';
export type ExecutionTarget = 'local' | 'remote';
@ -14,10 +14,10 @@ export class OperationRouter {
/**
* Determines where an operation should execute
* @param operation - The operation name (for future operation classification)
* @param _operation - The operation name (for future operation classification)
* @returns 'local' or 'remote'
*/
async getExecutionTarget(operation?: string): Promise<ExecutionTarget> {
async getExecutionTarget(_operation?: string): Promise<ExecutionTarget> {
const mode = await connectionModeService.getCurrentMode();
// Current implementation: simple mode-based routing
@ -37,11 +37,11 @@ export class OperationRouter {
/**
* Gets the base URL for an operation based on execution target
* @param operation - The operation name (for future operation classification)
* @param _operation - The operation name (for future operation classification)
* @returns Base URL for API calls
*/
async getBaseUrl(operation?: string): Promise<string> {
const target = await this.getExecutionTarget(operation);
async getBaseUrl(_operation?: string): Promise<string> {
const target = await this.getExecutionTarget(_operation);
if (target === 'local') {
return 'http://localhost:8080';