Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b61b8ac6 | ||
|
|
e8e98128d2 | ||
|
|
e1a879a5f6 | ||
|
|
4fd336c26c |
@@ -21,7 +21,7 @@ on:
|
||||
- 'frontend/tsconfig.desktop.json'
|
||||
- '.github/workflows/tauri-build.yml'
|
||||
push:
|
||||
branches: [main, V2]
|
||||
branches: [main, V2, V2-what-broke-desktop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -312,6 +312,8 @@ jobs:
|
||||
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: ${{ secrets.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY }}
|
||||
VITE_SAAS_SERVER_URL: ${{ secrets.VITE_SAAS_SERVER_URL }}
|
||||
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
|
||||
CI: true
|
||||
with:
|
||||
|
||||
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",
|
||||
|
||||
@@ -3827,6 +3827,7 @@
|
||||
"downloadSelected": "Download Selected Files",
|
||||
"downloadAll": "Download All",
|
||||
"toggleTheme": "Toggle Theme",
|
||||
"toggleBookmarks": "Toggle Bookmarks",
|
||||
"language": "Language",
|
||||
"search": "Search PDF",
|
||||
"panMode": "Pan Mode",
|
||||
@@ -5622,15 +5623,23 @@
|
||||
"description": "Enter credentials"
|
||||
},
|
||||
"mode": {
|
||||
"offline": {
|
||||
"title": "Use Offline",
|
||||
"description": "Run locally without an internet connection"
|
||||
"saas": {
|
||||
"title": "Stirling Cloud",
|
||||
"description": "Sign in with your Stirling account"
|
||||
},
|
||||
"server": {
|
||||
"title": "Connect to Server",
|
||||
"description": "Connect to a remote Stirling PDF server"
|
||||
"selfhosted": {
|
||||
"title": "Self-Hosted Server",
|
||||
"description": "Connect to your own Stirling PDF server"
|
||||
}
|
||||
},
|
||||
"saas": {
|
||||
"title": "Sign in to Stirling",
|
||||
"subtitle": "Sign in with your Stirling account"
|
||||
},
|
||||
"selfhosted": {
|
||||
"title": "Sign in to Server",
|
||||
"subtitle": "Enter your server credentials"
|
||||
},
|
||||
"server": {
|
||||
"title": "Connect to Server",
|
||||
"subtitle": "Enter your self-hosted server URL",
|
||||
@@ -5672,16 +5681,12 @@
|
||||
"connection": {
|
||||
"title": "Connection Mode",
|
||||
"mode": {
|
||||
"offline": "Offline",
|
||||
"server": "Server"
|
||||
"saas": "Stirling Cloud",
|
||||
"selfhosted": "Self-Hosted"
|
||||
},
|
||||
"server": "Server",
|
||||
"user": "Logged in as",
|
||||
"switchToServer": "Connect to Server",
|
||||
"switchToOffline": "Switch to Offline",
|
||||
"logout": "Logout",
|
||||
"selectServer": "Select Server",
|
||||
"login": "Login"
|
||||
"logout": "Log Out"
|
||||
},
|
||||
"general": {
|
||||
"title": "General",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
bisect test
|
||||
@@ -127,7 +127,7 @@ pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Response types for Spring Boot login
|
||||
// Response types for Spring Boot login (self-hosted)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SpringBootSession {
|
||||
access_token: String,
|
||||
@@ -145,6 +145,24 @@ struct SpringBootLoginResponse {
|
||||
user: SpringBootUser,
|
||||
}
|
||||
|
||||
// Response types for Supabase login (SaaS)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SupabaseUserMetadata {
|
||||
full_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SupabaseUser {
|
||||
email: Option<String>,
|
||||
user_metadata: Option<SupabaseUserMetadata>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SupabaseLoginResponse {
|
||||
access_token: String,
|
||||
user: SupabaseUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
@@ -153,7 +171,7 @@ pub struct LoginResponse {
|
||||
}
|
||||
|
||||
/// Login command - makes HTTP request from Rust to bypass CORS
|
||||
/// Supports Spring Boot authentication (self-hosted)
|
||||
/// Supports both Supabase authentication (SaaS) and Spring Boot authentication (self-hosted)
|
||||
#[tauri::command]
|
||||
pub async fn login(
|
||||
server_url: String,
|
||||
@@ -162,54 +180,124 @@ pub async fn login(
|
||||
) -> Result<LoginResponse, String> {
|
||||
log::info!("Login attempt for user: {} to server: {}", username, server_url);
|
||||
|
||||
// Build login URL
|
||||
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
|
||||
log::debug!("Login URL: {}", login_url);
|
||||
// Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
|
||||
// Compare against the configured SaaS server URL from environment
|
||||
let saas_server_url = env!("VITE_SAAS_SERVER_URL");
|
||||
let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/');
|
||||
log::info!("Authentication type: {}", if is_supabase { "Supabase (SaaS)" } else { "Spring Boot (Self-hosted)" });
|
||||
|
||||
// Create HTTP client
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Make login request
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
if is_supabase {
|
||||
// Supabase authentication flow
|
||||
let login_url = format!("{}/auth/v1/token?grant_type=password", server_url.trim_end_matches('/'));
|
||||
|
||||
// Supabase public API key from environment variable (required at compile time)
|
||||
// Set VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY before building
|
||||
let supabase_key = env!("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY");
|
||||
|
||||
let request_body = serde_json::json!({
|
||||
"email": username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("Login response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Login failed with status {}: {}", status, error_text);
|
||||
|
||||
return Err(if status.as_u16() == 401 {
|
||||
"Invalid username or password".to_string()
|
||||
} else if status.as_u16() == 403 {
|
||||
"Access denied".to_string()
|
||||
} else {
|
||||
format!("Login failed: {}", status)
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.header("Content-Type", "application/json;charset=UTF-8")
|
||||
.header("apikey", supabase_key)
|
||||
.header("Authorization", format!("Bearer {}", supabase_key))
|
||||
.header("X-Client-Info", "supabase-js-web/2.58.0")
|
||||
.header("X-Supabase-Api-Version", "2024-01-01")
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Supabase login failed with status {}: {}", status, error_text);
|
||||
|
||||
return Err(if status.as_u16() == 400 || status.as_u16() == 401 {
|
||||
"Invalid username or password".to_string()
|
||||
} else if status.as_u16() == 403 {
|
||||
"Access denied".to_string()
|
||||
} else {
|
||||
format!("Login failed: {}", status)
|
||||
});
|
||||
}
|
||||
|
||||
// Parse Supabase response format
|
||||
let login_response: SupabaseLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Supabase response: {}", e))?;
|
||||
|
||||
let email = login_response.user.email.clone();
|
||||
let username = login_response.user.user_metadata
|
||||
.as_ref()
|
||||
.and_then(|m| m.full_name.clone())
|
||||
.or_else(|| email.clone())
|
||||
.unwrap_or_else(|| username);
|
||||
|
||||
log::info!("Supabase login successful for user: {}", username);
|
||||
|
||||
Ok(LoginResponse {
|
||||
token: login_response.access_token,
|
||||
username,
|
||||
email,
|
||||
})
|
||||
} else {
|
||||
// Spring Boot authentication flow
|
||||
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
|
||||
log::debug!("Spring Boot login URL: {}", login_url);
|
||||
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.json(&serde_json::json!({
|
||||
"username": username,
|
||||
"password": password,
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
log::debug!("Spring Boot login response status: {}", status);
|
||||
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Spring Boot login failed with status {}: {}", status, error_text);
|
||||
|
||||
return Err(if status.as_u16() == 401 {
|
||||
"Invalid username or password".to_string()
|
||||
} else if status.as_u16() == 403 {
|
||||
"Access denied".to_string()
|
||||
} else {
|
||||
format!("Login failed: {}", status)
|
||||
});
|
||||
}
|
||||
|
||||
// Parse Spring Boot response format
|
||||
let login_response: SpringBootLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse Spring Boot response: {}", e))?;
|
||||
|
||||
log::info!("Spring Boot login successful for user: {}", login_response.user.username);
|
||||
|
||||
Ok(LoginResponse {
|
||||
token: login_response.session.access_token,
|
||||
username: login_response.user.username,
|
||||
email: login_response.user.email,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse Spring Boot response format
|
||||
let login_response: SpringBootLoginResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
log::info!("Login successful for user: {}", login_response.user.username);
|
||||
|
||||
Ok(LoginResponse {
|
||||
token: login_response.session.access_token,
|
||||
username: login_response.user.username,
|
||||
email: login_response.user.email,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -349,11 +349,11 @@ pub async fn start_backend(
|
||||
};
|
||||
|
||||
match mode {
|
||||
ConnectionMode::Offline => {
|
||||
add_log("🔌 Running in Offline mode - starting local backend".to_string());
|
||||
ConnectionMode::SaaS => {
|
||||
add_log("☁️ Running in SaaS mode - starting local backend".to_string());
|
||||
}
|
||||
ConnectionMode::Server => {
|
||||
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string());
|
||||
ConnectionMode::SelfHosted => {
|
||||
add_log("🌐 Running in Self-Hosted mode - starting local backend (for hybrid execution support)".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ pub async fn get_connection_config(
|
||||
let mode = store
|
||||
.get(CONNECTION_MODE_KEY)
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or(ConnectionMode::Offline);
|
||||
.unwrap_or(ConnectionMode::SaaS);
|
||||
|
||||
let server_config: Option<ServerConfig> = store
|
||||
.get(SERVER_CONFIG_KEY)
|
||||
@@ -109,3 +109,22 @@ pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
|
||||
|
||||
Ok(!setup_completed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reset_setup_completion(app_handle: AppHandle) -> Result<(), String> {
|
||||
log::info!("Resetting setup completion flag");
|
||||
|
||||
let store = app_handle
|
||||
.store(STORE_FILE)
|
||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||
|
||||
// Reset setup completion flag to force SetupWizard on next launch
|
||||
store.set(FIRST_LAUNCH_KEY, serde_json::json!(false));
|
||||
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||
|
||||
log::info!("Setup completion flag reset successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ pub use files::{add_opened_file, clear_opened_files, get_opened_files};
|
||||
pub use connection::{
|
||||
get_connection_config,
|
||||
is_first_launch,
|
||||
reset_setup_completion,
|
||||
set_connection_mode,
|
||||
};
|
||||
pub use auth::{
|
||||
|
||||
@@ -19,6 +19,7 @@ use commands::{
|
||||
get_user_info,
|
||||
is_first_launch,
|
||||
login,
|
||||
reset_setup_completion,
|
||||
save_auth_token,
|
||||
save_user_info,
|
||||
set_connection_mode,
|
||||
@@ -85,6 +86,7 @@ pub fn run() {
|
||||
is_default_pdf_handler,
|
||||
set_as_default_pdf_handler,
|
||||
is_first_launch,
|
||||
reset_setup_completion,
|
||||
check_backend_health,
|
||||
login,
|
||||
save_auth_token,
|
||||
|
||||
@@ -4,13 +4,6 @@ use std::sync::Mutex;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConnectionMode {
|
||||
Offline,
|
||||
Server,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ServerType {
|
||||
SaaS,
|
||||
SelfHosted,
|
||||
}
|
||||
@@ -18,7 +11,6 @@ pub enum ServerType {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
pub url: String,
|
||||
pub server_type: ServerType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -30,7 +22,7 @@ pub struct ConnectionState {
|
||||
impl Default for ConnectionState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: ConnectionMode::Offline,
|
||||
mode: ConnectionMode::SaaS,
|
||||
server_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -128,6 +141,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,
|
||||
@@ -219,6 +268,10 @@ const EmbedPdfViewerContent = ({
|
||||
}
|
||||
}, [currentFile, activeFileIds, exportActions, actions, selectors, setHasUnsavedChanges]);
|
||||
|
||||
const sidebarWidthRem = 15;
|
||||
const totalRightMargin =
|
||||
(isThumbnailSidebarVisible ? sidebarWidthRem : 0) + (isBookmarkSidebarVisible ? sidebarWidthRem : 0);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={viewerRef}
|
||||
@@ -260,7 +313,7 @@ const EmbedPdfViewerContent = ({
|
||||
overflow: 'hidden',
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
marginRight: isThumbnailSidebarVisible ? '15rem' : '0',
|
||||
marginRight: `${totalRightMargin}rem`,
|
||||
transition: 'margin-right 0.3s ease'
|
||||
}}>
|
||||
<LocalEmbedPDF
|
||||
@@ -315,6 +368,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);
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -17,23 +17,23 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
*/
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
|
||||
const [connectionMode, setConnectionMode] = useState<'offline' | 'server' | null>(null);
|
||||
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null);
|
||||
|
||||
// Load connection mode on mount
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
}, []);
|
||||
|
||||
// Initialize backend health monitoring for server mode
|
||||
// Initialize backend health monitoring for self-hosted mode
|
||||
useEffect(() => {
|
||||
if (setupComplete && !isFirstLaunch && connectionMode === 'server') {
|
||||
console.log('[AppProviders] Initializing external backend monitoring for server mode');
|
||||
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
|
||||
void tauriBackendService.initializeExternalBackend();
|
||||
}
|
||||
}, [setupComplete, isFirstLaunch, connectionMode]);
|
||||
|
||||
// Only start bundled backend if in offline mode and setup is complete
|
||||
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'offline';
|
||||
// Only start bundled backend if in SaaS mode (local backend) and setup is complete
|
||||
// Self-hosted mode connects to remote server so doesn't need local backend
|
||||
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
|
||||
useBackendInitializer(shouldStartBackend);
|
||||
|
||||
// Show setup wizard on first launch
|
||||
@@ -51,8 +51,23 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<SetupWizard
|
||||
onComplete={() => {
|
||||
// Reload the page to reinitialize with new connection config
|
||||
onComplete={async () => {
|
||||
// Wait for backend to become healthy before reloading
|
||||
// This prevents reloading mid-startup which would interrupt the backend
|
||||
const maxWaitTime = 60000; // 60 seconds max
|
||||
const checkInterval = 1000; // Check every second
|
||||
const startTime = Date.now();
|
||||
|
||||
while (Date.now() - startTime < maxWaitTime) {
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, checkInterval));
|
||||
}
|
||||
|
||||
// If we timeout, reload anyway
|
||||
console.warn('[AppProviders] Backend health check timeout, reloading anyway...');
|
||||
window.location.reload();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core';
|
||||
import { Stack, Card, Badge, Button, Text, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
connectionModeService,
|
||||
ConnectionConfig,
|
||||
ServerConfig,
|
||||
} from '@app/services/connectionModeService';
|
||||
import { connectionModeService, ConnectionConfig } 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';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
|
||||
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(() => {
|
||||
@@ -26,7 +17,7 @@ export const ConnectionSettings: React.FC = () => {
|
||||
const currentConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(currentConfig);
|
||||
|
||||
if (currentConfig.mode === 'server') {
|
||||
if (currentConfig.mode === 'saas' || currentConfig.mode === 'selfhosted') {
|
||||
const user = await authService.getUserInfo();
|
||||
setUserInfo(user);
|
||||
}
|
||||
@@ -35,80 +26,26 @@ export const ConnectionSettings: React.FC = () => {
|
||||
loadConfig();
|
||||
}, []);
|
||||
|
||||
const handleSwitchToOffline = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await connectionModeService.switchToOffline();
|
||||
|
||||
// Reload config
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
setUserInfo(null);
|
||||
|
||||
// Reload the page to start the local backend
|
||||
window.location.reload();
|
||||
} 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);
|
||||
|
||||
// Reload the page to stop local backend and initialize external backend monitoring
|
||||
window.location.reload();
|
||||
} 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();
|
||||
// Switch to SaaS mode
|
||||
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
|
||||
|
||||
// Reset setup completion to force login screen on reload
|
||||
await connectionModeService.resetSetupCompletion();
|
||||
|
||||
// Reload config
|
||||
const newConfig = await connectionModeService.getCurrentConfig();
|
||||
setConfig(newConfig);
|
||||
setUserInfo(null);
|
||||
|
||||
// Reload the page to clear all state and reconnect to local backend
|
||||
// Clear URL to home page before reload so we don't return to settings after re-login
|
||||
window.history.replaceState({}, '', '/');
|
||||
|
||||
// Reload the page to clear all state and show login screen
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
@@ -127,21 +64,21 @@ export const ConnectionSettings: React.FC = () => {
|
||||
<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 color={config.mode === 'saas' ? 'blue' : 'green'} variant="light">
|
||||
{config.mode === 'saas'
|
||||
? t('settings.connection.mode.saas', 'Stirling Cloud')
|
||||
: t('settings.connection.mode.selfhosted', 'Self-Hosted')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{config.mode === 'server' && config.server_config && (
|
||||
{(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && (
|
||||
<>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('settings.connection.server', 'Server')}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{config.server_config.url}
|
||||
{config.mode === 'saas' ? 'stirling.com' : config.server_config.url}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -160,128 +97,12 @@ export const ConnectionSettings: React.FC = () => {
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
|
||||
{t('settings.connection.logout', 'Log Out')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Server selection modal */}
|
||||
<Modal
|
||||
opened={showServerModal}
|
||||
onClose={() => setShowServerModal(false)}
|
||||
title={t('settings.connection.selectServer', 'Select Server')}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
<ServerSelectionInSettings onSubmit={handleServerConfigSubmit} />
|
||||
</Modal>
|
||||
|
||||
{/* Login modal */}
|
||||
<Modal
|
||||
opened={showLoginModal}
|
||||
onClose={() => {
|
||||
setShowLoginModal(false);
|
||||
setNewServerConfig(null);
|
||||
}}
|
||||
title={t('settings.connection.login', 'Login')}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LoginFormProps {
|
||||
serverUrl: string;
|
||||
isSaaS?: boolean;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loading }) => {
|
||||
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false, onLogin, loading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -36,7 +37,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loadin
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{serverUrl}</strong>
|
||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
|
||||
@@ -5,7 +5,7 @@ import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import ComputerIcon from '@mui/icons-material/Computer';
|
||||
|
||||
interface ModeSelectionProps {
|
||||
onSelect: (mode: 'offline' | 'server') => void;
|
||||
onSelect: (mode: 'saas' | 'selfhosted') => void;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
@@ -17,31 +17,7 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('offline')}
|
||||
disabled={loading}
|
||||
leftSection={<ComputerIcon />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 'auto',
|
||||
padding: '1.25rem',
|
||||
},
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{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="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('server')}
|
||||
onClick={() => onSelect('saas')}
|
||||
disabled={loading}
|
||||
leftSection={<CloudIcon />}
|
||||
styles={{
|
||||
@@ -52,12 +28,42 @@ export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
section: {
|
||||
marginRight: '1rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.server.title', 'Connect to Server')}</Text>
|
||||
<Text fw={600} size="md">{t('setup.mode.saas.title', 'Use SaaS')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.server.description', 'Connect to a remote Stirling PDF server')}
|
||||
{t('setup.mode.saas.description', 'Sign in to Stirling PDF cloud service')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="xl"
|
||||
variant="default"
|
||||
onClick={() => onSelect('selfhosted')}
|
||||
disabled={loading}
|
||||
leftSection={<ComputerIcon />}
|
||||
styles={{
|
||||
root: {
|
||||
height: 'auto',
|
||||
padding: '1.25rem',
|
||||
},
|
||||
inner: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
section: {
|
||||
marginRight: '1rem',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
||||
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={400}>
|
||||
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server')}
|
||||
</Text>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -41,7 +41,6 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
// Connection successful
|
||||
onSelect({
|
||||
url,
|
||||
server_type: 'selfhosted',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error);
|
||||
|
||||
@@ -7,13 +7,15 @@ 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';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
||||
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
|
||||
import '@app/components/SetupWizard/SetupWizard.css';
|
||||
|
||||
enum SetupStep {
|
||||
ModeSelection,
|
||||
SaaSLogin,
|
||||
ServerSelection,
|
||||
Login,
|
||||
SelfHostedLogin,
|
||||
}
|
||||
|
||||
interface SetupWizardProps {
|
||||
@@ -22,34 +24,42 @@ interface SetupWizardProps {
|
||||
|
||||
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const logoPath = useLogoPath();
|
||||
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);
|
||||
const handleModeSelection = (mode: 'saas' | 'selfhosted') => {
|
||||
setError(null);
|
||||
|
||||
if (mode === 'offline') {
|
||||
handleOfflineSetup();
|
||||
if (mode === 'saas') {
|
||||
// For SaaS, go directly to login screen with SaaS URL
|
||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
} else {
|
||||
// For self-hosted, show server selection first
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOfflineSetup = async () => {
|
||||
const handleSaaSLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError('No SaaS server configured');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await connectionModeService.switchToOffline();
|
||||
await authService.login(serverConfig.url, username, password);
|
||||
await connectionModeService.switchToSaaS(serverConfig.url);
|
||||
await tauriBackendService.startBackend();
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Failed to set up offline mode:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to set up offline mode');
|
||||
console.error('SaaS login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'SaaS login failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -57,10 +67,10 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
const handleServerSelection = (config: ServerConfig) => {
|
||||
setServerConfig(config);
|
||||
setError(null);
|
||||
setActiveStep(SetupStep.Login);
|
||||
setActiveStep(SetupStep.SelfHostedLogin);
|
||||
};
|
||||
|
||||
const handleLogin = async (username: string, password: string) => {
|
||||
const handleSelfHostedLogin = async (username: string, password: string) => {
|
||||
if (!serverConfig) {
|
||||
setError('No server configured');
|
||||
return;
|
||||
@@ -71,23 +81,25 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
setError(null);
|
||||
|
||||
await authService.login(serverConfig.url, username, password);
|
||||
await connectionModeService.switchToServer(serverConfig);
|
||||
await connectionModeService.switchToSelfHosted(serverConfig);
|
||||
await tauriBackendService.initializeExternalBackend();
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
console.error('Self-hosted login failed:', err);
|
||||
setError(err instanceof Error ? err.message : 'Self-hosted login failed');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
if (activeStep === SetupStep.Login) {
|
||||
if (activeStep === SetupStep.SaaSLogin) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setServerConfig(null);
|
||||
} else if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
} else if (activeStep === SetupStep.ServerSelection) {
|
||||
setActiveStep(SetupStep.ModeSelection);
|
||||
setSelectedMode(null);
|
||||
setServerConfig(null);
|
||||
}
|
||||
};
|
||||
@@ -96,10 +108,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.welcome', 'Welcome to Stirling PDF');
|
||||
case SetupStep.SaaSLogin:
|
||||
return t('setup.saas.title', 'Sign in to Stirling Cloud');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.title', 'Connect to Server');
|
||||
case SetupStep.Login:
|
||||
return t('setup.login.title', 'Sign In');
|
||||
case SetupStep.SelfHostedLogin:
|
||||
return t('setup.selfhosted.title', 'Sign in to Server');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -109,10 +123,12 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
switch (activeStep) {
|
||||
case SetupStep.ModeSelection:
|
||||
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
|
||||
case SetupStep.SaaSLogin:
|
||||
return t('setup.saas.subtitle', 'Sign in with your Stirling account');
|
||||
case SetupStep.ServerSelection:
|
||||
return t('setup.server.subtitle', 'Enter your self-hosted server URL');
|
||||
case SetupStep.Login:
|
||||
return t('setup.login.subtitle', 'Enter your credentials to continue');
|
||||
case SetupStep.SelfHostedLogin:
|
||||
return t('setup.selfhosted.subtitle', 'Enter your server credentials');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -126,9 +142,9 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
{/* Logo Header */}
|
||||
<Stack gap="xs" align="center">
|
||||
<Image
|
||||
src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`}
|
||||
src={logoPath}
|
||||
alt="Stirling PDF"
|
||||
h={32}
|
||||
h={64}
|
||||
fit="contain"
|
||||
/>
|
||||
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
|
||||
@@ -153,14 +169,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
<ModeSelection onSelect={handleModeSelection} loading={loading} />
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SaaSLogin && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
isSaaS={true}
|
||||
onLogin={handleSaaSLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelection onSelect={handleServerSelection} loading={loading} />
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.Login && (
|
||||
{activeStep === SetupStep.SelfHostedLogin && (
|
||||
<LoginForm
|
||||
serverUrl={serverConfig?.url || ''}
|
||||
onLogin={handleLogin}
|
||||
isSaaS={false}
|
||||
onLogin={handleSelfHostedLogin}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,4 +2,11 @@
|
||||
* Connection-related constants for desktop app
|
||||
*/
|
||||
|
||||
export const STIRLING_SAAS_URL = 'https://stirling.com/app';
|
||||
// SaaS server URL from environment variable (required)
|
||||
// The SaaS authentication server
|
||||
// Will throw error if VITE_SAAS_SERVER_URL is not set
|
||||
if (!import.meta.env.VITE_SAAS_SERVER_URL) {
|
||||
throw new Error('VITE_SAAS_SERVER_URL environment variable is required');
|
||||
}
|
||||
|
||||
export const STIRLING_SAAS_URL = import.meta.env.VITE_SAAS_SERVER_URL;
|
||||
|
||||
@@ -25,9 +25,7 @@ export function useBackendInitializer(enabled = true) {
|
||||
|
||||
const initializeBackend = async () => {
|
||||
try {
|
||||
console.log('[BackendInitializer] Starting backend...');
|
||||
await tauriBackendService.startBackend(backendUrl);
|
||||
console.log('[BackendInitializer] Backend started successfully');
|
||||
|
||||
// Begin health checks after a short delay
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -267,8 +267,8 @@ const DEFAULT_BACKEND_URL =
|
||||
|
||||
/**
|
||||
* Desktop override exposing the backend URL based on connection mode.
|
||||
* - Offline mode: Uses local bundled backend (from env vars)
|
||||
* - Server mode: Uses configured server URL from connection config
|
||||
* - SaaS mode: Uses local bundled backend (from env vars)
|
||||
* - Self-hosted mode: Uses configured server URL from connection config
|
||||
*/
|
||||
export function useEndpointConfig(): EndpointConfig {
|
||||
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
|
||||
@@ -276,10 +276,10 @@ export function useEndpointConfig(): EndpointConfig {
|
||||
useEffect(() => {
|
||||
connectionModeService.getCurrentConfig()
|
||||
.then((config) => {
|
||||
if (config.mode === 'server' && config.server_config?.url) {
|
||||
if (config.mode === 'selfhosted' && config.server_config?.url) {
|
||||
setBackendUrl(config.server_config.url);
|
||||
} else {
|
||||
// Offline mode - use default from env vars
|
||||
// SaaS mode - use default from env vars (local backend)
|
||||
setBackendUrl(DEFAULT_BACKEND_URL);
|
||||
}
|
||||
})
|
||||
|
||||
@@ -49,7 +49,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
||||
|
||||
// Add auth token for remote requests
|
||||
const isRemote = await operationRouter.isRemoteMode();
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
if (isRemote) {
|
||||
const token = await authService.getAuthToken();
|
||||
if (token) {
|
||||
@@ -59,9 +59,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
|
||||
// Backend readiness check (for local backend)
|
||||
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
|
||||
const isOffline = await operationRouter.isOfflineMode();
|
||||
const isSaaS = await operationRouter.isSaaSMode();
|
||||
|
||||
if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) {
|
||||
if (isSaaS && !skipCheck && !tauriBackendService.isBackendHealthy()) {
|
||||
const method = (extendedConfig.method || 'get').toLowerCase();
|
||||
if (method !== 'get') {
|
||||
const now = Date.now();
|
||||
@@ -93,7 +93,7 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const isRemote = await operationRouter.isRemoteMode();
|
||||
const isRemote = await operationRouter.isSelfHostedMode();
|
||||
if (isRemote) {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (serverConfig) {
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
export type ConnectionMode = 'offline' | 'server';
|
||||
export type ServerType = 'saas' | 'selfhosted';
|
||||
export type ConnectionMode = 'saas' | 'selfhosted';
|
||||
|
||||
export interface ServerConfig {
|
||||
url: string;
|
||||
server_type: ServerType;
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
@@ -31,7 +29,7 @@ export class ConnectionModeService {
|
||||
if (!this.configLoadedOnce) {
|
||||
await this.loadConfig();
|
||||
}
|
||||
return this.currentConfig || { mode: 'offline', server_config: null };
|
||||
return this.currentConfig || { mode: 'saas', server_config: null };
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<ConnectionMode> {
|
||||
@@ -64,38 +62,40 @@ export class ConnectionModeService {
|
||||
this.configLoadedOnce = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load connection config:', error);
|
||||
// Default to offline mode on error
|
||||
this.currentConfig = { mode: 'offline', server_config: null };
|
||||
// Default to SaaS mode on error
|
||||
this.currentConfig = { mode: 'saas', server_config: null };
|
||||
this.configLoadedOnce = true;
|
||||
}
|
||||
}
|
||||
|
||||
async switchToOffline(): Promise<void> {
|
||||
console.log('Switching to offline mode');
|
||||
async switchToSaaS(saasServerUrl: string): Promise<void> {
|
||||
console.log('Switching to SaaS mode');
|
||||
|
||||
const serverConfig: ServerConfig = { url: saasServerUrl };
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'offline',
|
||||
serverConfig: null,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'offline', server_config: null };
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('Switched to offline mode successfully');
|
||||
}
|
||||
|
||||
async switchToServer(serverConfig: ServerConfig): Promise<void> {
|
||||
console.log('Switching to server mode:', serverConfig);
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'server',
|
||||
mode: 'saas',
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'server', server_config: serverConfig };
|
||||
this.currentConfig = { mode: 'saas', server_config: serverConfig };
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('Switched to server mode successfully');
|
||||
console.log('Switched to SaaS mode successfully');
|
||||
}
|
||||
|
||||
async switchToSelfHosted(serverConfig: ServerConfig): Promise<void> {
|
||||
console.log('Switching to self-hosted mode:', serverConfig);
|
||||
|
||||
await invoke('set_connection_mode', {
|
||||
mode: 'selfhosted',
|
||||
serverConfig,
|
||||
});
|
||||
|
||||
this.currentConfig = { mode: 'selfhosted', server_config: serverConfig };
|
||||
this.notifyListeners();
|
||||
|
||||
console.log('Switched to self-hosted mode successfully');
|
||||
}
|
||||
|
||||
async testConnection(url: string): Promise<boolean> {
|
||||
@@ -126,6 +126,16 @@ export class ConnectionModeService {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async resetSetupCompletion(): Promise<void> {
|
||||
try {
|
||||
await invoke('reset_setup_completion');
|
||||
console.log('Setup completion flag reset successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to reset setup completion:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionModeService = ConnectionModeService.getInstance();
|
||||
|
||||
@@ -22,14 +22,16 @@ export class OperationRouter {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// Current implementation: simple mode-based routing
|
||||
if (mode === 'offline') {
|
||||
if (mode === 'saas') {
|
||||
// SaaS mode: For now, all operations run locally
|
||||
// Future enhancement: complex operations will be sent to SaaS server
|
||||
return 'local';
|
||||
}
|
||||
|
||||
// In server mode, currently all operations go to remote
|
||||
// In self-hosted mode, currently all operations go to remote
|
||||
// Future enhancement: check if operation is "simple" and route to local if so
|
||||
// Example future logic:
|
||||
// if (mode === 'server' && operation && this.isSimpleOperation(operation)) {
|
||||
// if (mode === 'selfhosted' && operation && this.isSimpleOperation(operation)) {
|
||||
// return 'local';
|
||||
// }
|
||||
|
||||
@@ -66,19 +68,19 @@ export class OperationRouter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we're currently in remote mode
|
||||
* Checks if we're currently in self-hosted mode
|
||||
*/
|
||||
async isRemoteMode(): Promise<boolean> {
|
||||
async isSelfHostedMode(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
return mode === 'server';
|
||||
return mode === 'selfhosted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if we're currently in offline mode
|
||||
* Checks if we're currently in SaaS mode
|
||||
*/
|
||||
async isOfflineMode(): Promise<boolean> {
|
||||
async isSaaSMode(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
return mode === 'offline';
|
||||
return mode === 'saas';
|
||||
}
|
||||
|
||||
// Future enhancement: operation classification
|
||||
|
||||
@@ -64,7 +64,6 @@ export class TauriBackendService {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[TauriBackendService] Initializing external backend monitoring');
|
||||
this.backendStarted = true; // Mark as active for health checks
|
||||
this.setStatus('starting');
|
||||
this.beginHealthMonitoring();
|
||||
@@ -82,19 +81,17 @@ export class TauriBackendService {
|
||||
this.setStatus('starting');
|
||||
|
||||
this.startPromise = invoke('start_backend', { backendUrl })
|
||||
.then(async (result) => {
|
||||
console.log('Backend started:', result);
|
||||
.then(async () => {
|
||||
this.backendStarted = true;
|
||||
this.setStatus('starting');
|
||||
|
||||
// Poll for the dynamically assigned port
|
||||
await this.waitForPort();
|
||||
|
||||
this.beginHealthMonitoring();
|
||||
})
|
||||
.catch((error) => {
|
||||
this.setStatus('unhealthy');
|
||||
console.error('Failed to start backend:', error);
|
||||
console.error('[TauriBackendService] Failed to start backend:', error);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -105,13 +102,11 @@ export class TauriBackendService {
|
||||
}
|
||||
|
||||
private async waitForPort(maxAttempts = 30): Promise<void> {
|
||||
console.log('[TauriBackendService] Waiting for backend port assignment...');
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const port = await invoke<number | null>('get_backend_port');
|
||||
if (port) {
|
||||
this.backendPort = port;
|
||||
console.log(`[TauriBackendService] Backend port detected: ${port}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -138,11 +133,11 @@ export class TauriBackendService {
|
||||
async checkBackendHealth(): Promise<boolean> {
|
||||
const mode = await connectionModeService.getCurrentMode();
|
||||
|
||||
// For remote server mode, check the configured server
|
||||
if (mode !== 'offline') {
|
||||
// For self-hosted mode, check the configured remote server
|
||||
if (mode === 'selfhosted') {
|
||||
const serverConfig = await connectionModeService.getServerConfig();
|
||||
if (!serverConfig) {
|
||||
console.error('[TauriBackendService] Server mode but no server URL configured');
|
||||
console.error('[TauriBackendService] Self-hosted mode but no server URL configured');
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
@@ -161,21 +156,20 @@ export class TauriBackendService {
|
||||
} catch (error) {
|
||||
const errorStr = String(error);
|
||||
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
|
||||
console.error('[TauriBackendService] Server health check failed:', error);
|
||||
console.error('[TauriBackendService] Self-hosted server health check failed:', error);
|
||||
}
|
||||
this.setStatus('unhealthy');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// For offline mode, check the bundled backend via Rust
|
||||
// For SaaS mode, check the bundled local backend via Rust
|
||||
if (!this.backendStarted) {
|
||||
this.setStatus('stopped');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.backendPort) {
|
||||
console.debug('[TauriBackendService] Backend port not available yet');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -197,7 +191,6 @@ export class TauriBackendService {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const isHealthy = await this.checkBackendHealth();
|
||||
if (isHealthy) {
|
||||
console.log('Backend is healthy');
|
||||
return;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
@@ -210,7 +203,6 @@ export class TauriBackendService {
|
||||
* Reset backend state (used when switching from external to local backend)
|
||||
*/
|
||||
reset(): void {
|
||||
console.log('[TauriBackendService] Resetting backend state');
|
||||
this.backendStarted = false;
|
||||
this.backendPort = null;
|
||||
this.setStatus('stopped');
|
||||
|
||||
Reference in New Issue
Block a user