mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Add UI for backend selection
This commit is contained in:
parent
93c3f9e84b
commit
fb704cc901
275
frontend/src/desktop/components/ConnectionSettings.tsx
Normal file
275
frontend/src/desktop/components/ConnectionSettings.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
78
frontend/src/desktop/components/SetupWizard/LoginForm.tsx
Normal file
78
frontend/src/desktop/components/SetupWizard/LoginForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
163
frontend/src/desktop/components/SetupWizard/index.tsx
Normal file
163
frontend/src/desktop/components/SetupWizard/index.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
5
frontend/src/desktop/constants/connection.ts
Normal file
5
frontend/src/desktop/constants/connection.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Connection-related constants for desktop app
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const STIRLING_SAAS_URL = 'https://stirling.com/app';
|
||||||
@ -1,18 +1,55 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||||
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
import { useOpenedFile } from '@app/hooks/useOpenedFile';
|
||||||
import { fileOpenService } from '@app/services/fileOpenService';
|
import { fileOpenService } from '@app/services/fileOpenService';
|
||||||
import { useFileManagement } from '@app/contexts/file/fileHooks';
|
import { useFileManagement } from '@app/contexts/file/fileHooks';
|
||||||
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
import { authService } from '@app/services/authService';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App initialization hook
|
* App initialization hook
|
||||||
* Desktop version: Handles Tauri-specific initialization
|
* 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)
|
* - Handles files opened with the app (adds directly to FileContext)
|
||||||
*/
|
*/
|
||||||
export function useAppInitialization(): void {
|
export function useAppInitialization(): { isFirstLaunch: boolean; setupComplete: boolean } {
|
||||||
// Initialize backend on app startup
|
const [isFirstLaunch, setIsFirstLaunch] = useState(false);
|
||||||
useBackendInitializer();
|
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
|
// Get file management actions
|
||||||
const { addFiles } = useFileManagement();
|
const { addFiles } = useFileManagement();
|
||||||
@ -91,4 +128,14 @@ export function useAppInitialization(): void {
|
|||||||
// Clean up listener on unmount
|
// Clean up listener on unmount
|
||||||
return unlisten;
|
return unlisten;
|
||||||
}, [addFiles]);
|
}, [addFiles]);
|
||||||
|
|
||||||
|
return { isFirstLaunch, setupComplete };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSetupCompletion(): (completed: boolean) => void {
|
||||||
|
const [, setSetupComplete] = useState(false);
|
||||||
|
|
||||||
|
return (completed: boolean) => {
|
||||||
|
setSetupComplete(completed);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,12 +5,18 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to initialize backend and monitor health
|
* 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 { status, checkHealth } = useBackendHealth();
|
||||||
const { backendUrl } = useEndpointConfig();
|
const { backendUrl } = useEndpointConfig();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip if disabled
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip if backend already running
|
// Skip if backend already running
|
||||||
if (tauriBackendService.isBackendRunning()) {
|
if (tauriBackendService.isBackendRunning()) {
|
||||||
void checkHealth();
|
void checkHealth();
|
||||||
@ -36,5 +42,5 @@ export function useBackendInitializer() {
|
|||||||
if (status !== 'healthy' && status !== 'starting') {
|
if (status !== 'healthy' && status !== 'starting') {
|
||||||
void initializeBackend();
|
void initializeBackend();
|
||||||
}
|
}
|
||||||
}, [status, backendUrl, checkHealth]);
|
}, [enabled, status, backendUrl, checkHealth]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { connectionModeService, ConnectionMode } from './connectionModeService';
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
|
||||||
export type ExecutionTarget = 'local' | 'remote';
|
export type ExecutionTarget = 'local' | 'remote';
|
||||||
|
|
||||||
@ -14,10 +14,10 @@ export class OperationRouter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines where an operation should execute
|
* 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'
|
* @returns 'local' or 'remote'
|
||||||
*/
|
*/
|
||||||
async getExecutionTarget(operation?: string): Promise<ExecutionTarget> {
|
async getExecutionTarget(_operation?: string): Promise<ExecutionTarget> {
|
||||||
const mode = await connectionModeService.getCurrentMode();
|
const mode = await connectionModeService.getCurrentMode();
|
||||||
|
|
||||||
// Current implementation: simple mode-based routing
|
// Current implementation: simple mode-based routing
|
||||||
@ -37,11 +37,11 @@ export class OperationRouter {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the base URL for an operation based on execution target
|
* 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
|
* @returns Base URL for API calls
|
||||||
*/
|
*/
|
||||||
async getBaseUrl(operation?: string): Promise<string> {
|
async getBaseUrl(_operation?: string): Promise<string> {
|
||||||
const target = await this.getExecutionTarget(operation);
|
const target = await this.getExecutionTarget(_operation);
|
||||||
|
|
||||||
if (target === 'local') {
|
if (target === 'local') {
|
||||||
return 'http://localhost:8080';
|
return 'http://localhost:8080';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user