diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a98fe5b..f6d3bb9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -150,6 +150,13 @@ armbian-imager/ │ │ │ ├── BoardModal.tsx │ │ │ ├── ImageModal.tsx │ │ │ └── DeviceModal.tsx +│ │ ├── settings/ # Settings modal components +│ │ │ ├── SettingsModal.tsx # Main settings modal +│ │ │ ├── GeneralSection.tsx# General settings (MOTD, updates) +│ │ │ ├── ThemeSection.tsx # Theme selection (light/dark/auto) +│ │ │ ├── LanguageSection.tsx# Language selection (17 languages) +│ │ │ ├── AdvancedSection.tsx# Developer mode & logs +│ │ │ └── AboutSection.tsx # App info & links │ │ └── shared/ # Reusable components │ │ ├── AppVersion.tsx # Version display │ │ ├── ErrorDisplay.tsx # Error presentation @@ -158,12 +165,16 @@ armbian-imager/ │ ├── hooks/ # Custom React Hooks │ │ ├── useTauri.ts # Tauri IPC wrappers │ │ ├── useVendorLogos.ts # Logo validation -│ │ └── useAsyncData.ts # Async data fetching pattern +│ │ ├── useAsyncData.ts # Async data fetching pattern +│ │ └── useSettings.ts # Settings persistence hook +│ ├── contexts/ # React Context providers +│ │ └── ThemeContext.tsx # Theme state management (light/dark/auto) │ ├── config/ # Static configuration │ │ ├── constants.ts # App constants │ │ ├── deviceColors.ts # Device color mapping -│ │ └── os-info.ts # OS information -│ ├── locales/ # i18n translations (15 languages) +│ │ ├── os-info.ts # OS information +│ │ └── i18n.ts # i18n config & language metadata +│ ├── locales/ # i18n translations (17 languages) │ ├── styles/ # Modular CSS │ │ ├── theme.css # Theme variables (light/dark) │ │ ├── components.css # Component styles @@ -181,6 +192,7 @@ armbian-imager/ │ │ │ ├── operations.rs # Download & flash operations │ │ │ ├── custom_image.rs # Custom image handling │ │ │ ├── progress.rs # Progress event emission +│ │ │ ├── settings.rs # Settings commands (get/set dev mode, logs) │ │ │ ├── system.rs # System utilities │ │ │ └── state.rs # Shared application state │ │ ├── devices/ # Platform-specific device detection @@ -245,7 +257,8 @@ armbian-imager/ | React 19 | UI Framework | | TypeScript | Type Safety | | Vite | Build Tool & Dev Server | -| i18next | i18n (15 languages) | +| React Context API | State Management (Theme) | +| i18next | i18n (17 languages) | | Lucide | Icons | ### Backend @@ -254,6 +267,7 @@ armbian-imager/ |------------|---------| | Rust | Systems Programming | | Tauri 2 | Desktop Framework | +| Tauri Store Plugin | Persistent Settings | | Tokio | Async Runtime | | Serde | Serialization | | Reqwest | HTTP Client | diff --git a/README.md b/README.md index 1bc0406..43647b0 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,12 @@ On first launch, macOS may block the application because it is not signed. If th 3. **Select Image** — Choose desktop or server, kernel variant, and stable or nightly builds 4. **Flash** — Download, decompress, write, and verify automatically +## Customization + +- **Theme Selection**: Light, dark, or automatic based on system preferences +- **Developer Mode**: Enable detailed logging and view application logs +- **Language Selection**: 17 languages with automatic system detection + ## Platform Support | Platform | Architecture | Notes | diff --git a/package-lock.json b/package-lock.json index 555b50a..a33578f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,14 +12,17 @@ "@tauri-apps/plugin-fs": "^2.4.4", "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-shell": "^2.3.3", + "@tauri-apps/plugin-store": "^2.4.1", "@tauri-apps/plugin-updater": "^2", "@types/qrcode": "^1.5.6", + "ansi-to-html": "^0.7.0", "i18next": "^25.7.2", "lucide-react": "^0.560.0", "qrcode": "^1.5.4", "react": "^19.2.2", "react-dom": "^19.2.2", - "react-i18next": "^16.5.0" + "react-i18next": "^16.5.0", + "twemoji": "^14.0.2" }, "devDependencies": { "@eslint/js": "^9.39.1", @@ -1602,6 +1605,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-store": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.1.tgz", + "integrity": "sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-updater": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.9.0.tgz", @@ -2065,6 +2077,21 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansi-to-html": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz", + "integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==", + "license": "MIT", + "dependencies": { + "entities": "^2.2.0" + }, + "bin": { + "ansi-to-html": "bin/ansi-to-html" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2310,6 +2337,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -2650,6 +2686,29 @@ "dev": true, "license": "ISC" }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs-extra/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2710,6 +2769,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2921,6 +2986,18 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-5.0.0.tgz", + "integrity": "sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w==", + "license": "MIT", + "dependencies": { + "universalify": "^0.1.2" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -3242,6 +3319,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.2.tgz", "integrity": "sha512-fhyD2BLrew6qYf4NNtHff1rLXvzR25rq49p+FeqByOazc6TcSi2n8EYulo5C1PbH+1uBW++5S1SG7FcUU6mlDg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3490,6 +3568,24 @@ "typescript": ">=4.8.4" } }, + "node_modules/twemoji": { + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/twemoji/-/twemoji-14.0.2.tgz", + "integrity": "sha512-BzOoXIe1QVdmsUmZ54xbEH+8AgtOKUiG53zO5vVP2iUu6h5u9lN15NcuS6te4OY96qx0H7JK9vjjl9WQbkTRuA==", + "license": "MIT", + "dependencies": { + "fs-extra": "^8.0.1", + "jsonfile": "^5.0.0", + "twemoji-parser": "14.0.0", + "universalify": "^0.1.2" + } + }, + "node_modules/twemoji-parser": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/twemoji-parser/-/twemoji-parser-14.0.0.tgz", + "integrity": "sha512-9DUOTGLOWs0pFWnh1p6NF+C3CkQ96PWmEFwhOVmT3WbecRC+68AIqpsnJXygfkFcp4aXbOp8Dwbhh/HQgvoRxA==", + "license": "MIT" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3548,6 +3644,15 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", diff --git a/package.json b/package.json index 0666765..261ee47 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,19 @@ "dependencies": { "@tauri-apps/api": "^2.9.1", "@tauri-apps/plugin-fs": "^2.4.4", - "@tauri-apps/plugin-shell": "^2.3.3", - "@tauri-apps/plugin-updater": "^2", "@tauri-apps/plugin-process": "^2", + "@tauri-apps/plugin-shell": "^2.3.3", + "@tauri-apps/plugin-store": "^2.4.1", + "@tauri-apps/plugin-updater": "^2", "@types/qrcode": "^1.5.6", + "ansi-to-html": "^0.7.0", "i18next": "^25.7.2", "lucide-react": "^0.560.0", "qrcode": "^1.5.4", "react": "^19.2.2", "react-dom": "^19.2.2", - "react-i18next": "^16.5.0" + "react-i18next": "^16.5.0", + "twemoji": "^14.0.2" }, "devDependencies": { "@eslint/js": "^9.39.1", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 33a4eeb..ae37428 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ tauri-plugin-shell = "2" tauri-plugin-dialog = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" +tauri-plugin-store = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["full"] } diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 38dfa8a..57cb40f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,7 @@ fn main() { + // Extract Tauri version from Cargo.toml and expose it as a compile-time env var + println!("cargo:rustc-env=TAURI_VERSION={}", tauri_version()); + // On Windows, embed the manifest to request admin privileges at startup #[cfg(windows)] { @@ -11,3 +14,26 @@ fn main() { #[cfg(not(windows))] tauri_build::build(); } + +/// Extract Tauri version from Cargo.toml dependencies +fn tauri_version() -> String { + let cargo_toml = std::path::PathBuf::from("Cargo.toml"); + + if let Ok(content) = std::fs::read_to_string(&cargo_toml) { + // Look for tauri dependency version + for line in content.lines() { + if line.contains("tauri =") || line.contains("tauri =") { + // Extract version from line like: tauri = { version = "2.x", ... } + if let Some(start) = line.find("version = \"") { + let after_version = &line[start + 11..]; + if let Some(end) = after_version.find('"') { + return after_version[..end].to_string(); + } + } + } + } + } + + // Fallback to unknown if parsing fails + "unknown".to_string() +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cc2d87c..0750527 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -8,6 +8,7 @@ "shell:allow-open", "dialog:default", "updater:default", - "process:allow-restart" + "process:allow-restart", + "store:default" ] } diff --git a/src-tauri/src/commands/board_queries.rs b/src-tauri/src/commands/board_queries.rs index 7df1a4f..9589d64 100644 --- a/src-tauri/src/commands/board_queries.rs +++ b/src-tauri/src/commands/board_queries.rs @@ -13,7 +13,7 @@ use crate::images::{ extract_images, fetch_all_images, filter_images_for_board, get_unique_boards, BoardInfo, ImageInfo, }; -use crate::{log_error, log_info}; +use crate::{log_debug, log_error, log_info}; use super::state::AppState; @@ -59,6 +59,13 @@ pub async fn get_images_for_board( board_slug, stable_only ); + log_debug!( + "board_queries", + "Filters - preapp: {:?}, kernel: {:?}, variant: {:?}", + preapp_filter, + kernel_filter, + variant_filter + ); let json_guard = state.images_json.lock().await; let json = json_guard.as_ref().ok_or_else(|| { @@ -71,6 +78,7 @@ pub async fn get_images_for_board( })?; let images = extract_images(json); + log_debug!("board_queries", "Total images available: {}", images.len()); let filtered = filter_images_for_board( &images, &board_slug, @@ -79,6 +87,12 @@ pub async fn get_images_for_board( variant_filter.as_deref(), stable_only, ); + log_debug!( + "board_queries", + "Filtered down to {} images for board {}", + filtered.len(), + board_slug + ); log_info!( "board_queries", "Found {} images for board {}", diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7160477..b7e5859 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod custom_image; pub mod operations; pub mod progress; pub mod scraping; +pub mod settings; mod state; pub mod system; pub mod update; diff --git a/src-tauri/src/commands/operations.rs b/src-tauri/src/commands/operations.rs index d3c0a61..8c25878 100644 --- a/src-tauri/src/commands/operations.rs +++ b/src-tauri/src/commands/operations.rs @@ -3,13 +3,13 @@ //! Handles download and flash operations. use std::path::PathBuf; -use tauri::State; +use tauri::{AppHandle, State}; use crate::config; use crate::download::download_image as do_download; use crate::flash::{flash_image as do_flash, request_authorization}; use crate::utils::get_cache_dir; -use crate::{log_error, log_info}; +use crate::{log_debug, log_error, log_info}; use super::state::AppState; @@ -57,10 +57,16 @@ pub async fn download_image( state: State<'_, AppState>, ) -> Result { log_info!("operations", "Starting download: {}", file_url); + log_debug!( + "operations", + "Download directory: {:?}", + get_cache_dir(config::app::NAME).join("images") + ); if let Some(ref sha) = file_url_sha { log_info!("operations", "SHA URL: {}", sha); } else { log_info!("operations", "No SHA URL provided"); + log_debug!("operations", "SHA verification will be skipped"); } let download_dir = get_cache_dir(config::app::NAME).join("images"); @@ -92,6 +98,7 @@ pub async fn flash_image( device_path: String, verify: bool, state: State<'_, AppState>, + _app: AppHandle, ) -> Result<(), String> { log_info!( "operations", @@ -100,15 +107,32 @@ pub async fn flash_image( device_path, verify ); + log_debug!( + "operations", + "Image path exists: {}", + std::path::Path::new(&image_path).exists() + ); + log_debug!( + "operations", + "Device path exists: {}", + std::path::Path::new(&device_path).exists() + ); + log_debug!("operations", "Verification enabled: {}", verify); + let path = PathBuf::from(&image_path); let flash_state = state.flash_state.clone(); let result = do_flash(&path, &device_path, flash_state, verify).await; - if let Err(ref e) = result { - log_error!("operations", "Flash failed: {}", e); - } else { - log_info!("operations", "Flash completed successfully"); + + match &result { + Ok(_) => { + log_info!("operations", "Flash completed successfully"); + } + Err(e) => { + log_error!("operations", "Flash failed: {}", e); + } } + result } diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs new file mode 100644 index 0000000..70f187b --- /dev/null +++ b/src-tauri/src/commands/settings.rs @@ -0,0 +1,302 @@ +//! Settings persistence commands using Tauri Store plugin +//! +//! Manages user preferences like theme and language using the Tauri Store plugin. + +use crate::log_info; +use tauri_plugin_store::StoreExt; + +const MODULE: &str = "commands::settings"; +const SETTINGS_STORE: &str = "settings.json"; +const MAX_LOG_SIZE: u64 = 5 * 1024 * 1024; // 5MB +const MAX_LOG_LINES: usize = 10_000; + +/// Default values for settings +fn default_theme() -> String { + "auto".to_string() +} + +fn default_language() -> String { + "auto".to_string() +} + +fn default_show_motd() -> bool { + true +} + +fn default_show_updater_modal() -> bool { + true +} + +fn default_developer_mode() -> bool { + false +} + +/// Get the current theme preference +#[tauri::command] +pub fn get_theme(app: tauri::AppHandle) -> String { + match app.store(SETTINGS_STORE) { + Ok(store) => match store.get("theme") { + Some(value) => value.as_str().unwrap_or("auto").to_string(), + None => { + log_info!(MODULE, "Theme not found in store, using default"); + default_theme() + } + }, + Err(e) => { + log_info!(MODULE, "Error loading store, using default theme: {}", e); + default_theme() + } + } +} + +/// Set the theme preference +#[tauri::command] +pub fn set_theme(theme: String, app: tauri::AppHandle) -> Result<(), String> { + log_info!(MODULE, "Setting theme to: {}", theme); + + match app.store(SETTINGS_STORE) { + Ok(store) => { + store.set("theme", theme); + Ok(()) + } + Err(e) => Err(format!("Failed to access store: {}", e)), + } +} + +/// Get the current language preference +#[tauri::command] +pub fn get_language(app: tauri::AppHandle) -> String { + match app.store(SETTINGS_STORE) { + Ok(store) => match store.get("language") { + Some(value) => value.as_str().unwrap_or("auto").to_string(), + None => { + log_info!(MODULE, "Language not found in store, using default"); + default_language() + } + }, + Err(e) => { + log_info!(MODULE, "Error loading store, using default language: {}", e); + default_language() + } + } +} + +/// Set the language preference +#[tauri::command] +pub fn set_language(language: String, app: tauri::AppHandle) -> Result<(), String> { + log_info!(MODULE, "Setting language to: {}", language); + + match app.store(SETTINGS_STORE) { + Ok(store) => { + store.set("language", language); + Ok(()) + } + Err(e) => Err(format!("Failed to access store: {}", e)), + } +} + +/// Get the MOTD visibility preference +#[tauri::command] +pub fn get_show_motd(app: tauri::AppHandle) -> bool { + match app.store(SETTINGS_STORE) { + Ok(store) => match store.get("show_motd") { + Some(value) => value.as_bool().unwrap_or(true), + None => { + log_info!(MODULE, "show_motd not found in store, using default"); + default_show_motd() + } + }, + Err(e) => { + log_info!( + MODULE, + "Error loading store, using default show_motd: {}", + e + ); + default_show_motd() + } + } +} + +/// Set the MOTD visibility preference +#[tauri::command] +pub fn set_show_motd(show: bool, app: tauri::AppHandle) -> Result<(), String> { + log_info!(MODULE, "Setting show_motd to: {}", show); + + match app.store(SETTINGS_STORE) { + Ok(store) => { + store.set("show_motd", show); + Ok(()) + } + Err(e) => Err(format!("Failed to access store: {}", e)), + } +} + +/// System information structure +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct SystemInfo { + pub platform: String, + pub arch: String, +} + +/// Get the real system platform and architecture +#[tauri::command] +pub fn get_system_info() -> SystemInfo { + let platform = std::env::consts::OS.to_string(); + let arch = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "ARM64", + "x86" => "x86", + "arm" => "ARM", + _ => std::env::consts::ARCH, + } + .to_string(); + + SystemInfo { platform, arch } +} + +/// Get the Tauri version +/// +/// Returns the Tauri framework version as a compile-time constant. +/// The version is extracted from Cargo.toml during build time via build.rs. +#[tauri::command] +pub fn get_tauri_version() -> String { + env!("TAURI_VERSION").to_string() +} + +/// Get the updater modal visibility preference +#[tauri::command] +pub fn get_show_updater_modal(app: tauri::AppHandle) -> bool { + match app.store(SETTINGS_STORE) { + Ok(store) => match store.get("show_updater_modal") { + Some(value) => value.as_bool().unwrap_or(true), + None => { + log_info!( + MODULE, + "show_updater_modal not found in store, using default" + ); + default_show_updater_modal() + } + }, + Err(e) => { + log_info!( + MODULE, + "Error loading store, using default show_updater_modal: {}", + e + ); + default_show_updater_modal() + } + } +} + +/// Set the updater modal visibility preference +#[tauri::command] +pub fn set_show_updater_modal(show: bool, app: tauri::AppHandle) -> Result<(), String> { + log_info!(MODULE, "Setting show_updater_modal to: {}", show); + + match app.store(SETTINGS_STORE) { + Ok(store) => { + store.set("show_updater_modal", show); + Ok(()) + } + Err(e) => Err(format!("Failed to access store: {}", e)), + } +} + +/// Get the developer mode preference +#[tauri::command] +pub fn get_developer_mode(app: tauri::AppHandle) -> bool { + match app.store(SETTINGS_STORE) { + Ok(store) => match store.get("developer_mode") { + Some(value) => value.as_bool().unwrap_or_else(default_developer_mode), + None => { + log_info!(MODULE, "developer_mode not found in store, using default"); + default_developer_mode() + } + }, + Err(e) => { + log_info!( + MODULE, + "Error loading store, using default developer_mode: {}", + e + ); + default_developer_mode() + } + } +} + +/// Set the developer mode preference +#[tauri::command] +pub fn set_developer_mode(enabled: bool, app: tauri::AppHandle) -> Result<(), String> { + log_info!(MODULE, "Setting developer_mode to: {}", enabled); + + // Update the log level based on developer mode + crate::logging::set_log_level(enabled); + + match app.store(SETTINGS_STORE) { + Ok(store) => { + store.set("developer_mode", enabled); + Ok(()) + } + Err(e) => Err(format!("Failed to access store: {}", e)), + } +} + +/// Read only the last N lines from a file to avoid loading large files into memory +/// +/// This function is optimized for large log files by reading line-by-line +/// and only keeping the last N lines in memory. +fn read_last_lines(path: &std::path::PathBuf, lines: usize) -> Result { + use std::io::{BufRead, BufReader}; + + let file = std::fs::File::open(path).map_err(|e| format!("Failed to open log file: {}", e))?; + + let reader = BufReader::new(file); + let all_lines: Vec = reader.lines().map_while(Result::ok).collect(); + + let start = if all_lines.len() > lines { + all_lines.len() - lines + } else { + 0 + }; + + Ok(all_lines[start..].join("\n")) +} + +/// Get the latest log file contents +/// +/// For large log files (>5MB), only the last 10,000 lines are returned +/// to avoid memory issues. This prevents the application from consuming +/// excessive memory when viewing logs. +#[tauri::command] +pub fn get_logs() -> Result { + use crate::logging; + use std::fs::Metadata; + + match logging::get_current_log_path() { + Some(log_path) => { + if !log_path.exists() { + return Ok("No log file found".to_string()); + } + + // Get file metadata to check size + let metadata: Metadata = std::fs::metadata(&log_path) + .map_err(|e| format!("Failed to read log file metadata: {}", e))?; + + // For large files, use optimized line reader + if metadata.len() > MAX_LOG_SIZE { + log_info!( + MODULE, + "Log file is large ({} bytes), reading last {} lines", + metadata.len(), + MAX_LOG_LINES + ); + return read_last_lines(&log_path, MAX_LOG_LINES); + } + + // For small files, read entire contents + std::fs::read_to_string(&log_path) + .map_err(|e| format!("Failed to read log file: {}", e)) + } + None => Ok("No log file available".to_string()), + } +} diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 288524c..597a3ea 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -2,17 +2,23 @@ //! //! Platform-specific system operations like opening URLs and locale detection. -use crate::log_info; +use crate::{log_debug, log_info}; use sys_locale::get_locale; const MODULE: &str = "commands::system"; -/// Log a message from the frontend +/// Log a message from the frontend (INFO level) #[tauri::command] pub fn log_from_frontend(module: String, message: String) { log_info!(&format!("frontend::{}", module), "{}", message); } +/// Log a debug message from the frontend (DEBUG level - only shown in developer mode) +#[tauri::command] +pub fn log_debug_from_frontend(module: String, message: String) { + log_debug!(&format!("frontend::{}", module), "{}", message); +} + /// Get the system locale (e.g., "en-US", "it-IT", "de-DE") /// Returns the language code for i18n initialization #[tauri::command] diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 3ab80b5..7793e25 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -14,7 +14,7 @@ use tokio::sync::Mutex; use crate::config; use crate::decompress::decompress_with_rust_xz; -use crate::{log_error, log_info, log_warn}; +use crate::{log_debug, log_error, log_info, log_warn}; const MODULE: &str = "download"; @@ -59,12 +59,15 @@ impl Default for DownloadState { /// Extract filename from URL fn extract_filename(url: &str) -> Result<&str, String> { + log_debug!(MODULE, "Extracting filename from URL: {}", url); let url_path = url.split('?').next().unwrap_or(url); - url_path + let filename = url_path .split('/') .next_back() .filter(|s| !s.is_empty()) - .ok_or_else(|| "Invalid URL: no filename".to_string()) + .ok_or_else(|| "Invalid URL: no filename".to_string())?; + log_debug!(MODULE, "Extracted filename: {}", filename); + Ok(filename) } /// Fetch expected SHA256 from URL @@ -108,10 +111,16 @@ async fn fetch_expected_sha(client: &Client, sha_url: &str) -> Result) -> Result { log_info!(MODULE, "Calculating SHA256 of: {}", path.display()); + log_debug!( + MODULE, + "File size: {:?} bytes", + path.metadata().ok().map(|m| m.len()) + ); let mut file = File::open(path).map_err(|e| format!("Failed to open file for SHA: {}", e))?; let mut hasher = Sha256::new(); let mut buffer = [0u8; 8192]; + let mut bytes_processed = 0u64; loop { // Check for cancellation @@ -127,6 +136,16 @@ fn calculate_file_sha256(path: &Path, state: &Arc) -> Result (Option, Option) { let log_dir = get_log_dir(); @@ -217,6 +222,30 @@ pub fn error(module: &str, message: &str) { } } +/// Set the minimum log level at runtime +/// +/// This function allows dynamically changing the log level, for example +/// when developer mode is toggled. When enabled, debug messages are shown. +/// When disabled, only info and above are shown. +pub fn set_log_level(debug_enabled: bool) { + if let Ok(mut logger) = LOGGER.lock() { + let new_level = if debug_enabled { + LogLevel::Debug + } else { + LogLevel::Info + }; + logger.set_min_level(new_level); + + // Log the change after setting it (using the new level) + let level_str = if debug_enabled { "DEBUG" } else { "INFO" }; + logger.log( + LogLevel::Info, + "logging", + &format!("Log level changed to {}", level_str), + ); + } +} + /// Log a message with format arguments (debug level) #[macro_export] macro_rules! log_debug { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index af74275..00dfc29 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -20,6 +20,7 @@ mod utils; use commands::AppState; #[allow(unused_imports)] // Used by get_webview_window in debug builds use tauri::Manager; +use tauri_plugin_store::StoreExt; use crate::utils::get_cache_dir; @@ -97,7 +98,8 @@ fn main() { let mut builder = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_process::init()); + .plugin(tauri_plugin_process::init()) + .plugin(tauri_plugin_store::Builder::new().build()); // Enable updater only for AppImage on Linux (other formats like .deb don't support it) #[cfg(target_os = "linux")] @@ -135,8 +137,22 @@ fn main() { commands::system::open_url, commands::system::get_system_locale, commands::system::log_from_frontend, + commands::system::log_debug_from_frontend, commands::update::get_github_release, paste::upload::upload_logs, + commands::settings::get_theme, + commands::settings::set_theme, + commands::settings::get_language, + commands::settings::set_language, + commands::settings::get_show_motd, + commands::settings::set_show_motd, + commands::settings::get_show_updater_modal, + commands::settings::set_show_updater_modal, + commands::settings::get_developer_mode, + commands::settings::set_developer_mode, + commands::settings::get_logs, + commands::settings::get_system_info, + commands::settings::get_tauri_version, ]) .setup(|app| { #[cfg(debug_assertions)] @@ -145,6 +161,31 @@ fn main() { window.open_devtools(); } } + + // Initialize log level based on developer mode setting + match app.store("settings.json") { + Ok(store) => { + let developer_mode = store + .get("developer_mode") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if developer_mode { + log_info!("main", "Developer mode enabled, setting log level to DEBUG"); + logging::set_log_level(true); + } else { + log_info!("main", "Developer mode disabled, using default log level"); + } + } + Err(e) => { + log_warn!( + "main", + "Failed to access settings store: {}. Using default log level (INFO).", + e + ); + } + } + let _ = app; // Suppress unused warning in release Ok(()) }) diff --git a/src/App.tsx b/src/App.tsx index b9a6cde..8fcc482 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Header, HomePage } from './components/layout'; import { ManufacturerModal, BoardModal, ImageModal, DeviceModal } from './components/modals'; import { FlashProgress } from './components/flash'; -import { AppVersion } from './components/shared'; +import { SettingsButton } from './components/settings'; import { selectCustomImage, detectBoardFromFilename, logInfo } from './hooks/useTauri'; import { useDeviceMonitor } from './hooks/useDeviceMonitor'; import type { BoardInfo, ImageInfo, BlockDevice, ModalType, SelectionStep, Manufacturer } from './types'; @@ -208,7 +208,7 @@ function App() { onSelect={handleDeviceSelect} /> - + {!isFlashing && } ); } diff --git a/src/components/index.ts b/src/components/index.ts index 421e2e5..5013afb 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,9 @@ // Layout export * from './layout'; +// Settings +export * from './settings'; + // Modals export * from './modals'; diff --git a/src/components/modals/Modal.tsx b/src/components/modals/Modal.tsx index 4ccd476..fa12b1f 100644 --- a/src/components/modals/Modal.tsx +++ b/src/components/modals/Modal.tsx @@ -1,5 +1,5 @@ import { type ReactNode, useEffect, useCallback, useState, useRef } from 'react'; -import { X } from 'lucide-react'; +import { X, ChevronLeft } from 'lucide-react'; interface ModalProps { isOpen: boolean; @@ -7,9 +7,11 @@ interface ModalProps { title: string; children: ReactNode; searchBar?: ReactNode; + showBack?: boolean; + onBack?: () => void; } -export function Modal({ isOpen, onClose, title, children, searchBar }: ModalProps) { +export function Modal({ isOpen, onClose, title, children, searchBar, showBack, onBack }: ModalProps) { const [isExiting, setIsExiting] = useState(false); const isExitingRef = useRef(false); @@ -26,9 +28,13 @@ export function Modal({ isOpen, onClose, title, children, searchBar }: ModalProp const handleEscape = useCallback((e: KeyboardEvent) => { if (e.key === 'Escape') { - handleClose(); + if (showBack && onBack) { + onBack(); + } else { + handleClose(); + } } - }, [handleClose]); + }, [handleClose, showBack, onBack]); useEffect(() => { if (isOpen) { @@ -52,7 +58,14 @@ export function Modal({ isOpen, onClose, title, children, searchBar }: ModalProp
e.stopPropagation()}>
-

{title}

+
+ {showBack && onBack && ( + + )} +

{title}

+
diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx new file mode 100644 index 0000000..2fc5a57 --- /dev/null +++ b/src/components/settings/AboutSection.tsx @@ -0,0 +1,164 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getVersion } from '@tauri-apps/api/app'; +import { open } from '@tauri-apps/plugin-shell'; +import { + Cpu, + Monitor, + Tag, + Box, + Github, + BookOpen, + AlertCircle, + MessageSquare, + type LucideIcon, +} from 'lucide-react'; +import { getTauriVersion, getSystemInfo } from '../../hooks/useTauri'; +import { LINKS } from '../../config/constants'; +import armbianLogo from '../../../src-tauri/icons/icon.png'; + +/** + * About section + * + * Displays app information including: + * - Hero with logo and description + * - Technical details (version, platform, arch, Tauri version) + * - External links (GitHub, Docs, Issues, Forum) + */ + +/** + * Format platform name for display + * Maps platform identifiers to user-friendly names + */ +function formatPlatformName(platform: string): string { + const platformNames: Record = { + macos: 'macOS', + windows: 'Windows', + linux: 'Linux' + }; + return platformNames[platform] || platform; +} + +/** + * Info card component + * + * Displays a piece of information with an icon, label, and value. + * Used in the About section to show app details. + */ +interface InfoCardProps { + icon: LucideIcon; + label: string; + value: string; +} + +function InfoCard({ icon: Icon, label, value }: InfoCardProps) { + return ( +
+ +
+
{label}
+
{value}
+
+
+ ); +} + +/** + * Link button component + */ +interface LinkButtonProps { + icon: React.ComponentType<{ className?: string; size?: number }>; + text: string; + onClick: () => void; +} + +function LinkButton({ icon: Icon, text, onClick }: LinkButtonProps) { + return ( + + ); +} + +export function AboutSection() { + const { t } = useTranslation(); + const [appVersion, setAppVersion] = useState(''); + const [platform, setPlatform] = useState(''); + const [arch, setArch] = useState(''); + const [tauriVersion, setTauriVersion] = useState(''); + + useEffect(() => { + const loadAppInfo = async () => { + try { + const [version, tauriVer, systemInfo] = await Promise.all([ + getVersion(), + getTauriVersion(), + getSystemInfo() + ]); + setAppVersion(version); + setTauriVersion(tauriVer); + + // Format platform name for display + setPlatform(formatPlatformName(systemInfo.platform)); + setArch(systemInfo.arch); + } catch (error) { + console.error('Failed to load app info:', error); + } + }; + loadAppInfo(); + }, []); + + const openLink = (url: string) => { + open(url); + }; + + return ( +
+ {/* Hero Section */} +
+ Armbian +

Armbian Imager

+

{t('settings.appDescription')}

+
+ + {/* Technical Info Cards */} +
+ + + + +
+ + {/* Links Section */} +
+

{t('settings.links')}

+
+ openLink(LINKS.GITHUB_REPO)} + /> + openLink(LINKS.DOCS)} + /> + openLink(`${LINKS.GITHUB_REPO}/issues`)} + /> + openLink(LINKS.FORUM)} + /> +
+
+
+ ); +} diff --git a/src/components/settings/AdvancedSection.tsx b/src/components/settings/AdvancedSection.tsx new file mode 100644 index 0000000..60c5dc1 --- /dev/null +++ b/src/components/settings/AdvancedSection.tsx @@ -0,0 +1,106 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Code, FileText } from 'lucide-react'; +import { getDeveloperMode, setDeveloperMode } from '../../hooks/useSettings'; +import { LogsModal } from './LogsModal'; + +/** + * Advanced settings section for power users + * + * Contains developer mode toggle and view logs button. + */ +export function AdvancedSection() { + const { t } = useTranslation(); + const [developerMode, setDeveloperModeState] = useState(false); + const [logsModalOpen, setLogsModalOpen] = useState(false); + const [isToggling, setIsToggling] = useState(false); + + // Load developer mode preference on mount + useEffect(() => { + const loadDeveloperModePreference = async () => { + try { + const value = await getDeveloperMode(); + setDeveloperModeState(value); + } catch (error) { + console.error('Failed to load developer mode preference:', error); + } + }; + loadDeveloperModePreference(); + }, []); + + const handleToggleDeveloperMode = async () => { + // Prevent concurrent toggles + if (isToggling) { + return; + } + + const previousValue = developerMode; + const newValue = !developerMode; + + // Optimistic update + setDeveloperModeState(newValue); + setIsToggling(true); + + try { + await setDeveloperMode(newValue); + + // Notify other components that settings changed + window.dispatchEvent(new Event('armbian-settings-changed')); + } catch (error) { + // Rollback on error + console.error('Failed to set developer mode preference:', error); + setDeveloperModeState(previousValue); + } finally { + setIsToggling(false); + } + }; + + return ( +
+

{t('settings.advancedCategory')}

+ +
+ {/* Developer Mode Toggle */} +
+
+
+ +
+
+
{t('settings.developerMode')}
+
{t('settings.developerModeDescription')}
+
+
+ +
+ + {/* View Logs Button */} +
setLogsModalOpen(true)}> +
+
+ +
+
+
{t('settings.viewLogs')}
+
{t('settings.viewLogsDescription')}
+
+
+ + + +
+
+ + {/* Logs Modal */} + setLogsModalOpen(false)} /> +
+ ); +} diff --git a/src/components/settings/AppearanceSection.tsx b/src/components/settings/AppearanceSection.tsx new file mode 100644 index 0000000..dc58275 --- /dev/null +++ b/src/components/settings/AppearanceSection.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next'; +import { useTheme } from '../../contexts/ThemeContext'; + +/** + * Appearance settings section for sidebar layout + * + * Allows users to select between Light, Dark, and Auto themes. + * Uses clickable box cards with icons. + */ +export function AppearanceSection() { + const { t } = useTranslation(); + const { theme, setTheme } = useTheme(); + + return ( +
+

{t('settings.chooseTheme')}

+ +
+
setTheme('light')} + > + + + + + {t('settings.themeLight')} +
+ +
setTheme('dark')} + > + + + + {t('settings.themeDark')} +
+ +
setTheme('auto')} + > + + + + + + {t('settings.themeAuto')} +
+
+
+ ); +} diff --git a/src/components/settings/GeneralSection.tsx b/src/components/settings/GeneralSection.tsx new file mode 100644 index 0000000..a9733e9 --- /dev/null +++ b/src/components/settings/GeneralSection.tsx @@ -0,0 +1,114 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Lightbulb, Download } from 'lucide-react'; +import { getShowMotd, setShowMotd, getShowUpdaterModal, setShowUpdaterModal } from '../../hooks/useSettings'; + +/** + * General settings section for sidebar layout + * + * Contains general app preferences like MOTD visibility. + */ +export function GeneralSection() { + const { t } = useTranslation(); + const [showMotd, setShowMotdState] = useState(true); + const [showUpdaterModal, setShowUpdaterModalState] = useState(true); + + // Load MOTD preference on mount + useEffect(() => { + const loadMotdPreference = async () => { + try { + const value = await getShowMotd(); + setShowMotdState(value); + } catch (error) { + console.error('Failed to load MOTD preference:', error); + } + }; + loadMotdPreference(); + }, []); + + // Load updater modal preference on mount + useEffect(() => { + const loadUpdaterModalPreference = async () => { + try { + const value = await getShowUpdaterModal(); + setShowUpdaterModalState(value); + } catch (error) { + console.error('Failed to load updater modal preference:', error); + } + }; + loadUpdaterModalPreference(); + }, []); + + const handleToggleMotd = async () => { + try { + const newValue = !showMotd; + await setShowMotd(newValue); + setShowMotdState(newValue); + + // Notify MOTD component specifically + window.dispatchEvent(new Event('armbian-motd-changed')); + } catch (error) { + console.error('Failed to set MOTD preference:', error); + } + }; + + const handleToggleUpdaterModal = async () => { + try { + const newValue = !showUpdaterModal; + await setShowUpdaterModal(newValue); + setShowUpdaterModalState(newValue); + + // Notify other components that settings changed + window.dispatchEvent(new Event('armbian-settings-changed')); + } catch (error) { + console.error('Failed to set updater modal preference:', error); + } + }; + + return ( +
+

{t('settings.generalTitle')}

+ +
+
+
+
+ +
+
+
{t('settings.showMotd')}
+
{t('settings.showMotdDescription')}
+
+
+ +
+
+
+
+ +
+
+
{t('settings.showUpdaterModal')}
+
{t('settings.showUpdaterModalDescription')}
+
+
+ +
+
+
+ ); +} diff --git a/src/components/settings/LanguageSection.tsx b/src/components/settings/LanguageSection.tsx new file mode 100644 index 0000000..f487a06 --- /dev/null +++ b/src/components/settings/LanguageSection.tsx @@ -0,0 +1,86 @@ +import { useState, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import twemoji from 'twemoji'; +import { load } from '@tauri-apps/plugin-store'; +import { changeLanguage as i18nChangeLanguage, getCurrentLanguage } from '../../i18n'; +import { SUPPORTED_LANGUAGES } from '../../config/i18n'; + +/** + * Language settings section for sidebar layout + * + * Displays a list of languages with flag emojis. + * Uses Twemoji for consistent flag rendering across platforms. + */ +export function LanguageSection() { + const { t } = useTranslation(); + const [currentLanguage, setCurrentLanguage] = useState(getCurrentLanguage()); + const languageListRef = useRef(null); + + /** + * Check if language is set to auto on mount + */ + useEffect(() => { + const checkAutoLanguage = async () => { + try { + const store = await load('settings.json', { autoSave: true, defaults: {} }); + const savedLanguage = await store.get('language'); + const autoMode = !savedLanguage; + if (autoMode) { + setCurrentLanguage('auto'); + } + } catch (error) { + console.error('Failed to check language mode:', error); + } + }; + checkAutoLanguage(); + }, []); + + /** + * Parse flags with Twemoji when component mounts or language changes + */ + useEffect(() => { + if (languageListRef.current) { + twemoji.parse(languageListRef.current); + } + }, [currentLanguage]); + + /** + * Handle language change + */ + const handleLanguageChange = async (langCode: string) => { + try { + await i18nChangeLanguage(langCode); + setCurrentLanguage(langCode); + } catch (error) { + console.error('Failed to change language:', error); + } + }; + + return ( +
+

{t('settings.chooseLanguage')}

+ +
+ {SUPPORTED_LANGUAGES.map((lang) => ( +
handleLanguageChange(lang.code)} + > +
+ {lang.flag} + + {lang.name || t('settings.languageAuto')} + +
+ {currentLanguage === lang.code && ( + + + + )} +
+ ))} +
+
+ ); +} diff --git a/src/components/settings/LogsModal.tsx b/src/components/settings/LogsModal.tsx new file mode 100644 index 0000000..d76408b --- /dev/null +++ b/src/components/settings/LogsModal.tsx @@ -0,0 +1,126 @@ +import { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { X, Copy, Check } from 'lucide-react'; +import Ansi from 'ansi-to-html'; +import { getLogs } from '../../hooks/useTauri'; + +/** + * Strip ANSI escape codes from text + * @param text - Text with ANSI codes + * @returns Plain text without ANSI codes + */ +function stripAnsiCodes(text: string): string { + // ANSI escape sequence pattern (eslint-disable-line required for control characters) + // eslint-disable-next-line no-control-regex + const ansiEscapePattern = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + return text.replace(ansiEscapePattern, ''); +} + +interface LogsModalProps { + isOpen: boolean; + onClose: () => void; +} + +/** + * Modal component for viewing application logs + * + * Displays the latest log file contents with ANSI color formatting + * and scroll behavior for easy debugging. + */ +export function LogsModal({ isOpen, onClose }: LogsModalProps) { + const { t } = useTranslation(); + const [logs, setLogs] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + + // Load logs when modal opens + useEffect(() => { + if (isOpen) { + loadLogs(); + } + }, [isOpen]); + + const loadLogs = async () => { + setLoading(true); + setError(null); + try { + const logContent = await getLogs(); + setLogs(logContent); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load logs'); + console.error('Failed to load logs:', err); + } finally { + setLoading(false); + } + }; + + const handleCopyLogs = async () => { + try { + if (!logs) return; + // Strip ANSI codes to get plain text + const plainText = stripAnsiCodes(logs); + await navigator.clipboard.writeText(plainText); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy logs:', err); + } + }; + + // Convert ANSI to HTML + const ansiConverter = new Ansi({ + fg: '#FFF', + bg: '#000', + newline: true, + escapeXML: true, + stream: false + }); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+

{t('settings.viewLogs')}

+ +
+ +
+ {loading ? ( +
{t('modal.loading')}
+ ) : error ? ( +
{error}
+ ) : ( + <> +
+              {logs && (
+                
+              )}
+            
+          )}
+        
+
+
+ ); +} diff --git a/src/components/settings/SettingsButton.tsx b/src/components/settings/SettingsButton.tsx new file mode 100644 index 0000000..9621bdb --- /dev/null +++ b/src/components/settings/SettingsButton.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Settings } from 'lucide-react'; +import { SettingsModal } from './SettingsModal'; + +/** + * Settings button component + * + * Displays a settings icon in the bottom-right corner that opens the settings modal. + * Replaces the old AppVersion component. + */ +export function SettingsButton() { + const { t } = useTranslation(); + const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [isExiting, setIsExiting] = useState(false); + const isExitingRef = useState(false); + + /** + * Open settings modal + */ + const handleOpenSettings = () => { + if (isExitingRef[0]) return; + setIsSettingsOpen(true); + }; + + /** + * Close settings modal with animation + */ + const handleCloseSettings = () => { + if (isExiting) return; + setIsExiting(true); + setTimeout(() => { + setIsSettingsOpen(false); + setIsExiting(false); + }, 200); + }; + + return ( + <> + + + + + ); +} diff --git a/src/components/settings/SettingsModal.tsx b/src/components/settings/SettingsModal.tsx new file mode 100644 index 0000000..61e3b52 --- /dev/null +++ b/src/components/settings/SettingsModal.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { X, Settings, Terminal } from 'lucide-react'; +import { AppearanceSection } from './AppearanceSection'; +import { LanguageSection } from './LanguageSection'; +import { GeneralSection } from './GeneralSection'; +import { AboutSection } from './AboutSection'; +import { AdvancedSection } from './AdvancedSection'; + +type SettingsView = 'general' | 'appearance' | 'language' | 'advanced' | 'about'; + +interface SettingsModalProps { + isOpen: boolean; + onClose: () => void; +} + +/** + * Settings modal with sidebar navigation + * + * Features a left sidebar with category options and a right content area. + * Matches the classic settings UI pattern shown in the reference image. + */ +export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { + const { t } = useTranslation(); + const [activeSection, setActiveSection] = useState('general'); + + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+

{t('settings.title')}

+ +
+ + {/* Sidebar + Content */} +
+ {/* Sidebar */} +
+ + + + + + + + + +
+ + {/* Content Area */} +
+ {activeSection === 'general' && } + {activeSection === 'appearance' && } + {activeSection === 'language' && } + {activeSection === 'advanced' && } + {activeSection === 'about' && } +
+
+
+
+ ); +} diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts new file mode 100644 index 0000000..3e19fe4 --- /dev/null +++ b/src/components/settings/index.ts @@ -0,0 +1,7 @@ +export { SettingsModal } from './SettingsModal'; +export { SettingsButton } from './SettingsButton'; +export { AppearanceSection } from './AppearanceSection'; +export { LanguageSection } from './LanguageSection'; +export { AdvancedSection } from './AdvancedSection'; +export { AboutSection } from './AboutSection'; +export { LogsModal } from './LogsModal'; diff --git a/src/components/shared/AppVersion.tsx b/src/components/shared/AppVersion.tsx deleted file mode 100644 index 5cfab63..0000000 --- a/src/components/shared/AppVersion.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useState, useEffect } from 'react'; -import { getVersion } from '@tauri-apps/api/app'; - -export function AppVersion() { - const [version, setVersion] = useState(''); - - useEffect(() => { - getVersion().then(setVersion).catch(() => {}); - }, []); - - if (!version) return null; - - return v{version}; -} diff --git a/src/components/shared/MotdTip.tsx b/src/components/shared/MotdTip.tsx index 519a957..acbaa79 100644 --- a/src/components/shared/MotdTip.tsx +++ b/src/components/shared/MotdTip.tsx @@ -1,10 +1,8 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { Lightbulb, ExternalLink } from 'lucide-react'; import { openUrl } from '../../hooks/useTauri'; - -// Configuration -const MOTD_URL = 'https://raw.githubusercontent.com/armbian/os/main/motd.json'; -const ROTATE_INTERVAL_MS = 30000; // 30 seconds +import { getShowMotd } from '../../hooks/useSettings'; +import { LINKS, TIMING } from '../../config/constants'; interface MotdMessage { message: string; @@ -14,8 +12,10 @@ interface MotdMessage { export function MotdTip() { const [tip, setTip] = useState(null); + const [showMotd, setShowMotd] = useState(null); const messagesRef = useRef([]); const currentIndexRef = useRef(0); + const intervalRef = useRef(null); const pickNextMessage = useCallback(() => { if (messagesRef.current.length === 0) return; @@ -26,11 +26,33 @@ export function MotdTip() { }, []); useEffect(() => { + let isMounted = true; + const fetchMotd = async () => { try { - const response = await fetch(MOTD_URL); + // Load MOTD preference + const motdEnabled = await getShowMotd(); + + if (!isMounted) return; + + setShowMotd(motdEnabled); + + // Clear any existing interval + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + if (!motdEnabled) { + setTip(null); // Clear the tip + return; // Don't fetch MOTD if disabled + } + + const response = await fetch(LINKS.MOTD); const messages: MotdMessage[] = await response.json(); + if (!isMounted) return; + // Filter out expired messages const now = new Date(); const validMessages = messages.filter((msg) => { @@ -44,6 +66,9 @@ export function MotdTip() { // Pick a random starting message currentIndexRef.current = Math.floor(Math.random() * validMessages.length); setTip(validMessages[currentIndexRef.current]); + + // Start rotation interval + intervalRef.current = setInterval(pickNextMessage, TIMING.MOTD_ROTATION); } } catch (err) { console.error('Failed to fetch MOTD:', err); @@ -52,12 +77,27 @@ export function MotdTip() { fetchMotd(); - // Rotate messages every 30 seconds - const interval = setInterval(pickNextMessage, ROTATE_INTERVAL_MS); - return () => clearInterval(interval); + // Listen for MOTD setting changes only + const handleMotdChange = () => { + fetchMotd(); + }; + + window.addEventListener('armbian-motd-changed', handleMotdChange); + + return () => { + isMounted = false; + // Cleanup interval and event listener + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + window.removeEventListener('armbian-motd-changed', handleMotdChange); + }; }, [pickNextMessage]); - if (!tip) return null; + // Don't render if we haven't loaded the setting yet, if MOTD is disabled, or if no tip + if (showMotd !== true || !tip) { + return null; + } const handleClick = () => { openUrl(tip.url).catch(console.error); diff --git a/src/components/shared/UpdateModal.tsx b/src/components/shared/UpdateModal.tsx index eb9cf1a..7040b37 100644 --- a/src/components/shared/UpdateModal.tsx +++ b/src/components/shared/UpdateModal.tsx @@ -6,6 +6,7 @@ import { relaunch } from '@tauri-apps/plugin-process'; import { logInfo } from '../../hooks/useTauri'; import { formatFileSize, getErrorMessage } from '../../utils'; import { ChangelogModal } from './ChangelogModal'; +import { getShowUpdaterModal } from '../../hooks/useSettings'; type UpdateState = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'error'; @@ -23,11 +24,24 @@ export function UpdateModal() { const [dismissed, setDismissed] = useState(false); const [showChangelog, setShowChangelog] = useState(false); const hasCheckedRef = useRef(false); + const hasLoggedDisabledRef = useRef(false); const checkForUpdate = useCallback(async () => { if (hasCheckedRef.current) return; hasCheckedRef.current = true; + // Check if updater modal is enabled in settings + const showModal = await getShowUpdaterModal(); + if (!showModal) { + // Log this message only once per session + if (!hasLoggedDisabledRef.current) { + logInfo('updater', 'Updater modal disabled in settings, skipping update check'); + hasLoggedDisabledRef.current = true; + } + setState('idle'); + return; + } + setState('checking'); setError(null); diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts index f9ad781..7035df8 100644 --- a/src/components/shared/index.ts +++ b/src/components/shared/index.ts @@ -2,7 +2,6 @@ * Shared components exports */ -export { AppVersion } from './AppVersion'; export { BoardCardSkeleton, ListItemSkeleton } from './SkeletonCard'; export { ChangelogModal } from './ChangelogModal'; export { ConfirmationDialog } from './ConfirmationDialog'; diff --git a/src/config/constants.ts b/src/config/constants.ts index 215b61c..e1ac16d 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -14,3 +14,21 @@ export const POLLING = { /** Device type identifiers */ export type DeviceType = 'system' | 'sd' | 'usb' | 'sata' | 'sas' | 'nvme' | 'hdd'; + +/** External links */ +export const LINKS = { + /** GitHub repository URL */ + GITHUB_REPO: 'https://github.com/armbian/imager', + /** Documentation URL */ + DOCS: 'https://docs.armbian.com', + /** Community forum URL */ + FORUM: 'https://forum.armbian.com', + /** MOTD (Message of the Day) JSON file */ + MOTD: 'https://raw.githubusercontent.com/armbian/os/main/motd.json', +} as const; + +/** Message rotation intervals */ +export const TIMING = { + /** MOTD rotation interval in milliseconds */ + MOTD_ROTATION: 30000, // 30 seconds +} as const; diff --git a/src/config/i18n.ts b/src/config/i18n.ts new file mode 100644 index 0000000..b12a57c --- /dev/null +++ b/src/config/i18n.ts @@ -0,0 +1,88 @@ +/** + * i18n Configuration + * + * Centralized configuration for all supported languages. + * + * To add a new language: + * 1. Create a new JSON file in src/locales/{code}.json + * 2. Add an entry to SUPPORTED_LANGUAGES below with metadata + * 3. That's it! The language will be automatically loaded + */ + +export interface LanguageMetadata { + /** ISO 639-1 language code */ + code: string; + /** Native language name (e.g., "Italiano" for Italian) */ + name: string; + /** Flag emoji for visual identification */ + flag: string; +} + +/** + * Complete list of supported languages with metadata + * + * For new languages, add an entry here: { code: 'xx', name: 'Native Name', flag: '🇽🇽' } + */ +const LANGUAGES: LanguageMetadata[] = [ + { code: 'en', name: 'English', flag: '🇬🇧' }, + { code: 'de', name: 'Deutsch', flag: '🇩🇪' }, + { code: 'es', name: 'Español', flag: '🇪🇸' }, + { code: 'fr', name: 'Français', flag: '🇫🇷' }, + { code: 'hr', name: 'Hrvatski', flag: '🇭🇷' }, + { code: 'it', name: 'Italiano', flag: '🇮🇹' }, + { code: 'ja', name: '日本語', flag: '🇯🇵' }, + { code: 'ko', name: '한국어', flag: '🇰🇷' }, + { code: 'nl', name: 'Nederlands', flag: '🇳🇱' }, + { code: 'pl', name: 'Polski', flag: '🇵🇱' }, + { code: 'pt', name: 'Português', flag: '🇵🇹' }, + { code: 'ru', name: 'Русский', flag: '🇷🇺' }, + { code: 'sl', name: 'Slovenščina', flag: '🇸🇮' }, + { code: 'sv', name: 'Svenska', flag: '🇸🇪' }, + { code: 'tr', name: 'Türkçe', flag: '🇹🇷' }, + { code: 'uk', name: 'Українська', flag: '🇺🇦' }, + { code: 'zh', name: '中文', flag: '🇨🇳' }, +]; + +/** + * Auto language option - name is set dynamically in UI via translation + */ +const AUTO_LANGUAGE: LanguageMetadata = { + code: 'auto', + name: '', // Will be translated in UI + flag: '🌐', +}; + +export const SUPPORTED_LANGUAGES: LanguageMetadata[] = [ + AUTO_LANGUAGE, + ...LANGUAGES.sort((a, b) => a.name.localeCompare(b.name)) +]; + +/** + * Get a list of all supported language codes + */ +export function getSupportedLanguageCodes(): string[] { + return SUPPORTED_LANGUAGES.map((lang) => lang.code); +} + +/** + * Get metadata for a specific language by its code + */ +export function getLanguageByCode(code: string): LanguageMetadata | undefined { + return SUPPORTED_LANGUAGES.find((lang) => lang.code === code); +} + +/** + * Get the default language (English) + */ +export function getDefaultLanguage(): string { + return 'en'; +} + +/** + * Extract language code from locale string + * e.g., "en-US" -> "en", "it-IT" -> "it" + */ +export function getLanguageFromLocale(locale: string): string { + const lang = locale.split('-')[0].toLowerCase(); + return getSupportedLanguageCodes().includes(lang) ? lang : getDefaultLanguage(); +} diff --git a/src/config/index.ts b/src/config/index.ts index 1b06610..b001ca5 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -23,7 +23,7 @@ export { } from './badges'; // Constants and polling intervals -export { POLLING, type DeviceType } from './constants'; +export { POLLING, LINKS, TIMING, type DeviceType } from './constants'; // Device colors export { DEVICE_COLORS, getDeviceColors, type DeviceColorConfig } from './deviceColors'; diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..f9b4ce4 --- /dev/null +++ b/src/contexts/ThemeContext.tsx @@ -0,0 +1,121 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'; +import { getTheme, setTheme as saveTheme } from '../hooks/useSettings'; + +/** + * Theme type + */ +export type Theme = 'light' | 'dark' | 'auto'; + +/** + * Theme context interface + */ +interface ThemeContextType { + theme: Theme; + setTheme: (theme: Theme) => void; +} + +/** + * Theme context + */ +const ThemeContext = createContext(undefined); + +/** + * Theme provider props + */ +interface ThemeProviderProps { + children: ReactNode; +} + +/** + * Theme provider component + * + * Manages theme state and applies theme classes to the document element. + * Persists theme preference via Tauri backend commands. + */ +export function ThemeProvider({ children }: ThemeProviderProps) { + const [theme, setThemeState] = useState('auto'); + const [isInitialized, setIsInitialized] = useState(false); + + /** + * Apply theme to document element + */ + const applyTheme = (selectedTheme: Theme) => { + const root = document.documentElement; + + if (selectedTheme === 'light') { + root.classList.add('theme-light'); + root.classList.remove('theme-dark'); + } else if (selectedTheme === 'dark') { + root.classList.add('theme-dark'); + root.classList.remove('theme-light'); + } else { + // auto - remove both classes, let CSS media query handle it + root.classList.remove('theme-light', 'theme-dark'); + } + }; + + /** + * Load theme from storage on mount + */ + useEffect(() => { + const loadTheme = async () => { + try { + const savedTheme = await getTheme(); + setThemeState(savedTheme as Theme); + applyTheme(savedTheme as Theme); + } catch (error) { + // If no saved theme, default to auto + console.warn('Failed to load theme from storage, using auto:', error); + setThemeState('auto'); + applyTheme('auto'); + } finally { + setIsInitialized(true); + } + }; + + loadTheme(); + }, []); + + /** + * Set theme and persist to storage + */ + const setTheme = async (newTheme: Theme) => { + setThemeState(newTheme); + applyTheme(newTheme); + + // Persist to storage + try { + await saveTheme(newTheme); + } catch (error) { + console.error('Failed to save theme to storage:', error); + } + }; + + const value = { + theme, + setTheme, + }; + + // Don't render children until theme is loaded to prevent flash + if (!isInitialized) { + return null; + } + + return {children}; +} + +/** + * Hook to access theme context + * + * @throws Error if used outside ThemeProvider + */ +// eslint-disable-next-line react-refresh/only-export-components -- This is a hook, not a component +export function useTheme(): ThemeContextType { + const context = useContext(ThemeContext); + + if (!context) { + throw new Error('useTheme must be used within a ThemeProvider'); + } + + return context; +} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts new file mode 100644 index 0000000..528e48c --- /dev/null +++ b/src/hooks/useSettings.ts @@ -0,0 +1,203 @@ +/** + * Settings management hook using Tauri Store plugin + * + * Provides direct access to persistent settings without backend commands. + * All operations are wrapped in proper error handling to prevent silent failures. + */ + +import { load } from '@tauri-apps/plugin-store'; + +const SETTINGS_FILE = 'settings.json'; +let storeInstance: Awaited> | null = null; +let storePromise: Promise>> | null = null; + +/** + * Initialize the settings store (lazy loading with concurrent access protection) + * + * This function prevents race conditions when multiple components + * try to access the store simultaneously by caching the initialization promise. + * + * @returns Promise resolving to the store instance + * @throws Error if store initialization fails + */ +async function getStore() { + if (storeInstance) { + return storeInstance; + } + + if (!storePromise) { + storePromise = load(SETTINGS_FILE, { autoSave: true, defaults: {} }) + .then(store => { + storeInstance = store; + storePromise = null; + return store; + }) + .catch(error => { + storePromise = null; + throw new Error(`Failed to initialize settings store: ${error}`); + }); + } + + return storePromise; +} + +/** + * Get the current theme preference + * + * @returns Promise resolving to theme value ('auto', 'light', or 'dark') + * @throws Error if store access fails + */ +export async function getTheme(): Promise { + try { + const store = await getStore(); + return (await store.get('theme')) || 'auto'; + } catch (error) { + throw new Error(`Failed to get theme: ${error}`); + } +} + +/** + * Set the theme preference + * + * @param theme - Theme value ('auto', 'light', or 'dark') + * @throws Error if store access or save fails + */ +export async function setTheme(theme: string): Promise { + try { + const store = await getStore(); + await store.set('theme', theme); + await store.save(); // Explicitly save to ensure persistence + } catch (error) { + throw new Error(`Failed to set theme: ${error}`); + } +} + +/** + * Get the current language preference + * + * @returns Promise resolving to language code (e.g., 'en', 'de', 'fr') + * @throws Error if store access fails + */ +export async function getLanguage(): Promise { + try { + const store = await getStore(); + return (await store.get('language')) || 'en'; + } catch (error) { + throw new Error(`Failed to get language: ${error}`); + } +} + +/** + * Set the language preference + * + * @param language - Language code (e.g., 'en', 'de', 'fr') + * @throws Error if store access or save fails + */ +export async function setLanguage(language: string): Promise { + try { + const store = await getStore(); + await store.set('language', language); + await store.save(); // Explicitly save to ensure persistence + } catch (error) { + throw new Error(`Failed to set language: ${error}`); + } +} + +/** + * Get the MOTD visibility preference + * + * @returns Promise resolving to true if MOTD should be shown, false otherwise + * @throws Error if store access fails + */ +export async function getShowMotd(): Promise { + try { + const store = await getStore(); + const value = await store.get('show_motd'); + return value ?? true; // Default to true if not set + } catch (error) { + throw new Error(`Failed to get MOTD preference: ${error}`); + } +} + +/** + * Set the MOTD visibility preference + * + * @param show - true to show MOTD, false to hide + * @throws Error if store access or save fails + */ +export async function setShowMotd(show: boolean): Promise { + try { + const store = await getStore(); + await store.set('show_motd', show); + await store.save(); // Explicitly save to ensure persistence + } catch (error) { + throw new Error(`Failed to set MOTD preference: ${error}`); + } +} + +/** + * Get the updater modal visibility preference + * + * @returns Promise resolving to true if updater modal should be shown, false otherwise + * @throws Error if store access fails + */ +export async function getShowUpdaterModal(): Promise { + try { + const store = await getStore(); + const value = await store.get('show_updater_modal'); + return value ?? true; // Default to true if not set + } catch (error) { + throw new Error(`Failed to get updater modal preference: ${error}`); + } +} + +/** + * Set the updater modal visibility preference + * + * @param show - true to show updater modal, false to hide + * @throws Error if store access or save fails + */ +export async function setShowUpdaterModal(show: boolean): Promise { + try { + const store = await getStore(); + await store.set('show_updater_modal', show); + await store.save(); // Explicitly save to ensure persistence + } catch (error) { + throw new Error(`Failed to set updater modal preference: ${error}`); + } +} + +/** + * Get the developer mode preference + * + * @returns Promise resolving to true if developer mode is enabled, false otherwise + * @throws Error if store access fails + */ +export async function getDeveloperMode(): Promise { + try { + const store = await getStore(); + const value = await store.get('developer_mode'); + return value ?? false; // Default to false if not set + } catch (error) { + throw new Error(`Failed to get developer mode preference: ${error}`); + } +} + +/** + * Set the developer mode preference + * + * This setting controls debug logging verbosity across the application. + * When enabled, more detailed debug information is logged. + * + * @param enabled - true to enable developer mode, false to disable + * @throws Error if store access or save fails + */ +export async function setDeveloperMode(enabled: boolean): Promise { + try { + const store = await getStore(); + await store.set('developer_mode', enabled); + await store.save(); // Explicitly save to ensure persistence + } catch (error) { + throw new Error(`Failed to set developer mode preference: ${error}`); + } +} diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index f525d30..31b1a45 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -108,6 +108,10 @@ export async function logInfo(module: string, message: string): Promise { return invoke('log_from_frontend', { module, message }); } +export async function logDebug(module: string, message: string): Promise { + return invoke('log_debug_from_frontend', { module, message }); +} + export interface GitHubRelease { tag_name: string; name: string; @@ -125,3 +129,72 @@ export interface GitHubRelease { export async function getGithubRelease(version: string): Promise { return invoke('get_github_release', { version }); } + +/** + * Get the current theme preference + */ +export async function getTheme(): Promise { + return invoke('get_theme'); +} + +/** + * Set the theme preference + */ +export async function setTheme(theme: string): Promise { + return invoke('set_theme', { theme }); +} + +/** + * Get the current language preference + */ +export async function getLanguage(): Promise { + return invoke('get_language'); +} + +/** + * Set the language preference + */ +export async function setLanguage(language: string): Promise { + return invoke('set_language', { language }); +} + +/** + * Get the real system platform and architecture + */ +export async function getSystemInfo(): Promise<{ platform: string; arch: string }> { + return invoke('get_system_info'); +} + +/** + * Get the Tauri framework version + */ +export async function getTauriVersion(): Promise { + return invoke('get_tauri_version'); +} + +/** + * Get the current log file contents + * + * Retrieves the contents of the current log file. For large log files (>5MB), + * only the last 10,000 lines are returned to prevent memory issues. + * + * @returns Promise resolving to the log file contents with ANSI color codes preserved + * @throws Error if log file cannot be read or does not exist + * + * @example + * // Display full log contents + * const logs = await getLogs(); + * console.log(logs); // Full log contents with colors + * + * @example + * // Handle errors gracefully + * try { + * const logs = await getLogs(); + * // Process logs... + * } catch (error) { + * console.error('Failed to retrieve logs:', error); + * } + */ +export async function getLogs(): Promise { + return invoke('get_logs'); +} diff --git a/src/i18n.ts b/src/i18n.ts index b0653bb..ddb269c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -1,72 +1,60 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; - -import en from './locales/en.json'; -import it from './locales/it.json'; -import de from './locales/de.json'; -import fr from './locales/fr.json'; -import es from './locales/es.json'; -import pt from './locales/pt.json'; -import nl from './locales/nl.json'; -import pl from './locales/pl.json'; -import ru from './locales/ru.json'; -import zh from './locales/zh.json'; -import ja from './locales/ja.json'; -import ko from './locales/ko.json'; -import uk from './locales/uk.json'; -import tr from './locales/tr.json'; -import sl from './locales/sl.json'; -import hr from './locales/hr.json'; -import sv from './locales/sv.json'; - -const resources = { - en: { translation: en }, - it: { translation: it }, - de: { translation: de }, - fr: { translation: fr }, - es: { translation: es }, - pt: { translation: pt }, - nl: { translation: nl }, - pl: { translation: pl }, - ru: { translation: ru }, - zh: { translation: zh }, - ja: { translation: ja }, - ko: { translation: ko }, - uk: { translation: uk }, - tr: { translation: tr }, - sl: { translation: sl }, - hr: { translation: hr }, - sv: { translation: sv }, -}; - -// Supported languages -const supportedLanguages = ['en', 'it', 'de', 'fr', 'es', 'pt', 'nl', 'pl', 'ru', 'zh', 'ja', 'ko', 'uk', 'tr', 'sl', 'sv', 'hr']; +import { load } from '@tauri-apps/plugin-store'; +import { SUPPORTED_LANGUAGES, getLanguageFromLocale } from './config/i18n'; /** - * Extract language code from locale string - * e.g., "en-US" -> "en", "it-IT" -> "it" + * Dynamically load all translation files + * + * Uses Vite's import.meta.glob to load all JSON files in locales directory. + * This eliminates the need for static imports and automatically includes + * any new language files added to the locales folder. */ -function getLanguageFromLocale(locale: string): string { - const lang = locale.split('-')[0].toLowerCase(); - return supportedLanguages.includes(lang) ? lang : 'en'; -} +const localeModules = import.meta.glob('./locales/*.json', { eager: true }); /** - * Initialize i18n with system locale detection + * Build resources object from dynamically loaded locale files + * + * Extracts the language code from the filename (e.g., './locales/en.json' -> 'en') + * and creates the i18next-compatible resources structure. + */ +const resources = Object.entries(localeModules).reduce((acc, [path, module]) => { + // Extract language code from path: './locales/en.json' -> 'en' + const langCode = path.match(/\.\/locales\/(.+)\.json$/)?.[1]; + if (langCode && module) { + acc[langCode] = { translation: module as Record }; + } + return acc; +}, {} as Record }>); + +// Export supported language codes for use in other components +export const supportedLanguages = SUPPORTED_LANGUAGES.map((lang) => lang.code); + +/** + * Initialize i18n with saved language or system locale detection */ export async function initI18n(): Promise { - let systemLocale = 'en-US'; + let language = 'en'; try { - // Get system locale from Tauri backend - systemLocale = await invoke('get_system_locale'); - } catch (error) { - console.warn('Failed to get system locale, using default:', error); + // Try to load saved language first using Store plugin + const store = await load('settings.json', { autoSave: true, defaults: {} }); + const savedLanguage = await store.get('language'); + if (savedLanguage) { + language = savedLanguage; + } + } catch { + // If no saved language, detect from system locale + try { + const systemLocale = await invoke('get_system_locale'); + language = getLanguageFromLocale(systemLocale); + } catch (localeError) { + console.warn('Failed to get system locale, using default:', localeError); + language = 'en'; + } } - const language = getLanguageFromLocale(systemLocale); - await i18n .use(initReactI18next) .init({ @@ -82,4 +70,55 @@ export async function initI18n(): Promise { }); } +/** + * Change the current language and persist to storage + * @param lang - Language code to change to (e.g., 'en', 'it', 'auto') + */ +export async function changeLanguage(lang: string): Promise { + const store = await load('settings.json', { autoSave: true, defaults: {} }); + + if (lang === 'auto') { + // Remove saved language to enable auto-detection + try { + await store.delete('language'); + } catch (error) { + console.error('Failed to delete language from storage:', error); + } + + // Detect system locale and change to it + try { + const systemLocale = await invoke('get_system_locale'); + const detectedLang = getLanguageFromLocale(systemLocale); + await i18n.changeLanguage(detectedLang); + } catch (localeError) { + console.warn('Failed to get system locale, using default:', localeError); + await i18n.changeLanguage('en'); + } + } else { + // Change language in i18next + await i18n.changeLanguage(lang); + + // Persist to storage using Store plugin + try { + await store.set('language', lang); + } catch (error) { + console.error('Failed to save language to storage:', error); + } + } +} + +/** + * Get the current language + */ +export function getCurrentLanguage(): string { + return i18n.language; +} + +/** + * Check if a language is supported + */ +export function isLanguageSupported(lang: string): boolean { + return supportedLanguages.includes(lang); +} + export default i18n; diff --git a/src/locales/de.json b/src/locales/de.json index d88180f..e5c0302 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -102,6 +102,42 @@ "unknown": "Unbekannt", "confirm": "Bestätigen" }, + "settings": { + "title": "Einstellungen", + "general": "Allgemein", + "theme": "Design", + "language": "Sprache", + "appInfo": "Über", + "generalTitle": "Allgemein", + "showMotd": "Tipps anzeigen", + "showMotdDescription": "Hilfreiche Tipps und Ankündigungen zeigen", + "showUpdaterModal": "Update-Benachrichtigungen anzeigen", + "showUpdaterModalDescription": "Verfügbare App-Updates anzeigen", + "chooseTheme": "Wählen Sie Ihr Design", + "chooseLanguage": "Wählen Sie Ihre Sprache", + "languageAuto": "Automatisch", + "appDescription": "Dienstprogramm zum Schreiben von Armbian OS auf SD-Karten und USB-Laufwerke", + "version": "Version", + "platform": "Plattform", + "arch": "Architektur", + "tauriVersion": "Tauri-Version", + "links": "Links", + "githubRepo": "GitHub-Repository", + "documentation": "Dokumentation", + "reportIssue": "Problem melden", + "community": "Community-Forum", + "themeLight": "Hell", + "themeDark": "Dunkel", + "themeAuto": "Auto", + "advancedCategory": "Erweitert", + "developerMode": "Entwicklermodus", + "developerModeDescription": "Ausführliche Protokollierung und Debug-Infos aktivieren", + "viewLogs": "Protokolle anzeigen", + "viewLogsDescription": "Anwendungsprotokolle zur Fehlerbehebung anzeigen", + "noLogsAvailable": "Keine Protokolle verfügbar", + "copyLogs": "Protokolle kopieren", + "copied": "Kopiert!" + }, "update": { "title": "Update verfügbar", "later": "Später", diff --git a/src/locales/en.json b/src/locales/en.json index 8978bb9..a374458 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -102,6 +102,42 @@ "unknown": "Unknown", "confirm": "Confirm" }, + "settings": { + "title": "Settings", + "general": "General", + "theme": "Theme", + "language": "Language", + "appInfo": "About", + "generalTitle": "General", + "showMotd": "Show tips", + "showMotdDescription": "Display helpful tips and announcements", + "showUpdaterModal": "Show update notifications", + "showUpdaterModalDescription": "Display available app updates", + "chooseTheme": "Choose your theme", + "chooseLanguage": "Choose your language", + "languageAuto": "Automatic", + "appDescription": "Utility for writing Armbian OS to SD cards and USB drives", + "version": "Version", + "platform": "Platform", + "arch": "Architecture", + "tauriVersion": "Tauri Version", + "links": "Links", + "githubRepo": "GitHub Repository", + "documentation": "Documentation", + "reportIssue": "Report Issue", + "community": "Community Forum", + "themeLight": "Light", + "themeDark": "Dark", + "themeAuto": "Auto", + "advancedCategory": "Advanced", + "developerMode": "Developer mode", + "developerModeDescription": "Enable verbose logging and debug info", + "viewLogs": "View Logs", + "viewLogsDescription": "Show application logs for debugging", + "noLogsAvailable": "No logs available", + "copyLogs": "Copy logs", + "copied": "Copied!" + }, "update": { "title": "Update Available", "later": "Later", diff --git a/src/locales/es.json b/src/locales/es.json index c9f3e4c..564cf2b 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -102,6 +102,42 @@ "unknown": "Desconocido", "confirm": "Confirmar" }, + "settings": { + "title": "Configuración", + "general": "General", + "theme": "Tema", + "language": "Idioma", + "appInfo": "Acerca de", + "generalTitle": "General", + "showMotd": "Mostrar consejos", + "showMotdDescription": "Consejos útiles y anuncios", + "showUpdaterModal": "Mostrar actualizaciones", + "showUpdaterModalDescription": "Mostrar actualizaciones disponibles de la aplicación", + "chooseTheme": "Elige tu tema", + "chooseLanguage": "Elige tu idioma", + "languageAuto": "Automático", + "appDescription": "Utilidad para escribir Armbian OS en tarjetas SD y unidades USB", + "version": "Versión", + "platform": "Plataforma", + "arch": "Arquitectura", + "tauriVersion": "Versión de Tauri", + "links": "Enlaces", + "githubRepo": "Repositorio GitHub", + "documentation": "Documentación", + "reportIssue": "Informar de un problema", + "community": "Foro de la comunidad", + "themeLight": "Claro", + "themeDark": "Oscuro", + "themeAuto": "Auto", + "advancedCategory": "Avanzado", + "developerMode": "Modo desarrollador", + "developerModeDescription": "Habilitar registro detallado e información de depuración", + "viewLogs": "Ver registros", + "viewLogsDescription": "Mostrar los registros de la aplicación para depuración", + "noLogsAvailable": "No hay registros disponibles", + "copyLogs": "Copiar registros", + "copied": "¡Copiado!" + }, "update": { "title": "Actualización disponible", "later": "Más tarde", diff --git a/src/locales/fr.json b/src/locales/fr.json index 267cbc2..4af72d4 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -102,6 +102,42 @@ "unknown": "Inconnu", "confirm": "Confirmer" }, + "settings": { + "title": "Paramètres", + "general": "Général", + "theme": "Thème", + "language": "Langue", + "appInfo": "À propos", + "generalTitle": "Général", + "showMotd": "Afficher les conseils", + "showMotdDescription": "Conseils utiles et annonces", + "showUpdaterModal": "Afficher les mises à jour", + "showUpdaterModalDescription": "Afficher les mises à jour disponibles de l'application", + "chooseTheme": "Choisissez votre thème", + "chooseLanguage": "Choisissez votre langue", + "languageAuto": "Automatique", + "appDescription": "Utilitaire pour écrire Armbian OS sur cartes SD et clés USB", + "version": "Version", + "platform": "Plateforme", + "arch": "Architecture", + "tauriVersion": "Version Tauri", + "links": "Liens", + "githubRepo": "Dépôt GitHub", + "documentation": "Documentation", + "reportIssue": "Signaler un problème", + "community": "Forum communautaire", + "themeLight": "Clair", + "themeDark": "Sombre", + "themeAuto": "Auto", + "advancedCategory": "Avancé", + "developerMode": "Mode développeur", + "developerModeDescription": "Activer la journalisation détaillée et les infos de débogage", + "viewLogs": "Voir les journaux", + "viewLogsDescription": "Afficher les journaux de l'application pour le débogage", + "noLogsAvailable": "Aucun journal disponible", + "copyLogs": "Copier les journaux", + "copied": "Copié !" + }, "update": { "title": "Mise à jour disponible", "later": "Plus tard", diff --git a/src/locales/hr.json b/src/locales/hr.json index 58122ca..70d46ab 100644 --- a/src/locales/hr.json +++ b/src/locales/hr.json @@ -102,6 +102,42 @@ "unknown": "Nepoznato", "confirm": "Potvrda" }, + "settings": { + "title": "Postavke", + "general": "Općenito", + "theme": "Tema", + "language": "Jezik", + "appInfo": "O aplikaciji", + "generalTitle": "Općenito", + "showMotd": "Prikaži savjete", + "showMotdDescription": "Korisne savjete i oglasi", + "showUpdaterModal": "Prikaži obavijesti o ažuriranju", + "showUpdaterModalDescription": "Prikaži dostupna ažuriranja aplikacije", + "chooseTheme": "Odaberi svoju temu", + "chooseLanguage": "Odaberi svoj jezik", + "languageAuto": "Automatski", + "appDescription": "Pomoćni program za pisanje Armbian OS na SD kartice i USB uređaje", + "version": "Verzija", + "platform": "Platforma", + "arch": "Arhitektura", + "tauriVersion": "Tauri verzija", + "links": "Linkovi", + "githubRepo": "GitHub repozitorij", + "documentation": "Dokumentacija", + "reportIssue": "Prijavite problem", + "community": "Forum zajednice", + "themeLight": "Svijetla", + "themeDark": "Tamna", + "themeAuto": "Auto", + "advancedCategory": "Napredno", + "developerMode": "Način razvojnog programera", + "developerModeDescription": "Omogućite detaljno prijavljivanje i informacije o otklanjanju pogrešaka", + "viewLogs": "Prikaži zapise", + "viewLogsDescription": "Prikaži zapise aplikacije za otklanjanje pogrešaka", + "noLogsAvailable": "Nema dostupnih zapisa", + "copyLogs": "Kopiraj zapise", + "copied": "Kopirano!" + }, "update": { "title": "Dostupno ažuriranje", "later": "Kasnije", diff --git a/src/locales/it.json b/src/locales/it.json index 4a71642..c55af62 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -102,6 +102,42 @@ "unknown": "Sconosciuto", "confirm": "Conferma" }, + "settings": { + "title": "Impostazioni", + "general": "Generale", + "theme": "Tema", + "language": "Lingua", + "appInfo": "Informazioni", + "generalTitle": "Generale", + "showMotd": "Mostra suggerimenti", + "showMotdDescription": "Visualizza suggerimenti utili e annunci", + "showUpdaterModal": "Mostra notifiche aggiornamenti", + "showUpdaterModalDescription": "Visualizza aggiornamenti disponibili dell'app", + "chooseTheme": "Scegli il tema", + "chooseLanguage": "Scegli la lingua", + "languageAuto": "Automatico", + "appDescription": "Utility per la scrittura di Armbian OS su schede SD e supporti USB", + "version": "Versione", + "platform": "Piattaforma", + "arch": "Architettura", + "tauriVersion": "Versione Tauri", + "links": "Link", + "githubRepo": "Repository GitHub", + "documentation": "Documentazione", + "reportIssue": "Segnala Problema", + "community": "Forum della Comunità", + "themeLight": "Chiaro", + "themeDark": "Scuro", + "themeAuto": "Auto", + "advancedCategory": "Avanzate", + "developerMode": "Modalità sviluppatore", + "developerModeDescription": "Abilita logging dettagliato e messaggi debug", + "viewLogs": "Visualizza Log", + "viewLogsDescription": "Mostra i log dell'applicazione per il debug", + "noLogsAvailable": "Nessun log disponibile", + "copyLogs": "Copia log", + "copied": "Copiato!" + }, "update": { "title": "Aggiornamento Disponibile", "later": "Più tardi", diff --git a/src/locales/ja.json b/src/locales/ja.json index 40f893a..ba26afd 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -102,6 +102,42 @@ "unknown": "不明", "confirm": "確認" }, + "settings": { + "title": "設定", + "general": "一般", + "theme": "テーマ", + "language": "言語", + "appInfo": "について", + "generalTitle": "一般", + "showMotd": "ヒントを表示", + "showMotdDescription": "役立つヒントとお知らせを表示", + "showUpdaterModal": "更新通知を表示", + "showUpdaterModalDescription": "利用可能なアプリの更新を表示", + "chooseTheme": "テーマを選択", + "chooseLanguage": "言語を選択", + "languageAuto": "自動", + "appDescription": "SDカードとUSBドライブにArmbian OSを書き込むユーティリティ", + "version": "バージョン", + "platform": "プラットフォーム", + "arch": "アーキテクチャ", + "tauriVersion": "Tauriバージョン", + "links": "リンク", + "githubRepo": "GitHubリポジトリ", + "documentation": "ドキュメント", + "reportIssue": "問題を報告", + "community": "コミュニティフォーラム", + "themeLight": "ライト", + "themeDark": "ダーク", + "themeAuto": "自動", + "advancedCategory": "詳細", + "developerMode": "開発者モード", + "developerModeDescription": "詳細なログ記録とデバッグ情報を有効にする", + "viewLogs": "ログを表示", + "viewLogsDescription": "デバッグのためにアプリケーションログを表示", + "noLogsAvailable": "利用可能なログはありません", + "copyLogs": "ログをコピー", + "copied": "コピーしました!" + }, "update": { "title": "アップデートが利用可能", "later": "後で", diff --git a/src/locales/ko.json b/src/locales/ko.json index ad6f73b..0fc12ab 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -102,6 +102,42 @@ "unknown": "알 수 없음", "confirm": "확인" }, + "settings": { + "title": "설정", + "general": "일반", + "theme": "테마", + "language": "언어", + "appInfo": "정보", + "generalTitle": "일반", + "showMotd": "팁 표시", + "showMotdDescription": "유용한 팁과 공지사항 표시", + "showUpdaterModal": "업데이트 알림 표시", + "showUpdaterModalDescription": "사용 가능한 앱 업데이트 표시", + "chooseTheme": "테마 선택", + "chooseLanguage": "언어 선택", + "languageAuto": "자동", + "appDescription": "SD 카드 및 USB 드라이브에 Armbian OS를 쓰는 유틸리티", + "version": "버전", + "platform": "플랫폼", + "arch": "아키텍처", + "tauriVersion": "Tauri 버전", + "links": "링크", + "githubRepo": "GitHub 저장소", + "documentation": "문서", + "reportIssue": "문제 신고", + "community": "커뮤니티 포럼", + "themeLight": "라이트", + "themeDark": "다크", + "themeAuto": "자동", + "advancedCategory": "고급", + "developerMode": "개발자 모드", + "developerModeDescription": "상세 로깅 및 디버그 정보 사용", + "viewLogs": "로그 보기", + "viewLogsDescription": "디버깅을 위한 애플리케이션 로그 표시", + "noLogsAvailable": "사용 가능한 로그가 없음", + "copyLogs": "로그 복사", + "copied": "복사됨!" + }, "update": { "title": "업데이트 가능", "later": "나중에", diff --git a/src/locales/nl.json b/src/locales/nl.json index 6db6911..593e171 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -102,6 +102,42 @@ "unknown": "Onbekend", "confirm": "Bevestigen" }, + "settings": { + "title": "Instellingen", + "general": "Algemeen", + "theme": "Thema", + "language": "Taal", + "appInfo": "Over", + "generalTitle": "Algemeen", + "showMotd": "Tips weergeven", + "showMotdDescription": "Handige tips en aankondigingen", + "showUpdaterModal": "Update-meldingen weergeven", + "showUpdaterModalDescription": "Beschikbare app-updates weergeven", + "chooseTheme": "Kies je thema", + "chooseLanguage": "Kies je taal", + "languageAuto": "Automatisch", + "appDescription": "Hulpprogramma voor het schrijven van Armbian OS naar SD-kaarten en USB-stations", + "version": "Versie", + "platform": "Platform", + "arch": "Architectuur", + "tauriVersion": "Tauri-versie", + "links": "Links", + "githubRepo": "GitHub-repository", + "documentation": "Documentatie", + "reportIssue": "Probleem melden", + "community": "Communityforum", + "themeLight": "Licht", + "themeDark": "Donker", + "themeAuto": "Auto", + "advancedCategory": "Geavanceerd", + "developerMode": "Ontwikkelaarsmodus", + "developerModeDescription": "Gedetailleerde logging en debug-info inschakelen", + "viewLogs": "Logs bekijken", + "viewLogsDescription": "Toon applicatielogs voor foutopsporing", + "noLogsAvailable": "Geen logs beschikbaar", + "copyLogs": "Logs kopiëren", + "copied": "Gekopieerd!" + }, "update": { "title": "Update beschikbaar", "later": "Later", diff --git a/src/locales/pl.json b/src/locales/pl.json index 4cc63ab..0c3b1b4 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -102,6 +102,42 @@ "unknown": "Nieznany", "confirm": "Potwierdź" }, + "settings": { + "title": "Ustawienia", + "general": "Ogólne", + "theme": "Motyw", + "language": "Język", + "appInfo": "O programie", + "generalTitle": "Ogólne", + "showMotd": "Pokaż wskazówki", + "showMotdDescription": "Przydatne wskazówki i ogłoszenia", + "showUpdaterModal": "Pokazuj powiadomienia o aktualizacjach", + "showUpdaterModalDescription": "Wyświetl dostępne aktualizacje aplikacji", + "chooseTheme": "Wybierz swój motyw", + "chooseLanguage": "Wybierz swój język", + "languageAuto": "Automatyczny", + "appDescription": "Narzędzie do zapisu Armbian OS na karty SD i dyski USB", + "version": "Wersja", + "platform": "Platforma", + "arch": "Architektura", + "tauriVersion": "Wersja Tauri", + "links": "Linki", + "githubRepo": "Repozytorium GitHub", + "documentation": "Dokumentacja", + "reportIssue": "Zgłoś problem", + "community": "Forum społeczności", + "themeLight": "Jasny", + "themeDark": "Ciemny", + "themeAuto": "Auto", + "advancedCategory": "Zaawansowane", + "developerMode": "Tryb deweloperski", + "developerModeDescription": "Włącz szczegółowe rejestrowanie i informacje debugowania", + "viewLogs": "Wyświetl logi", + "viewLogsDescription": "Pokaż logi aplikacji do debugowania", + "noLogsAvailable": "Brak dostępnych logów", + "copyLogs": "Kopiuj logi", + "copied": "Skopiowano!" + }, "update": { "title": "Dostępna aktualizacja", "later": "Później", diff --git a/src/locales/pt.json b/src/locales/pt.json index d5e234b..a45d23f 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -102,6 +102,42 @@ "unknown": "Desconhecido", "confirm": "Confirmar" }, + "settings": { + "title": "Configurações", + "general": "Geral", + "theme": "Tema", + "language": "Idioma", + "appInfo": "Sobre", + "generalTitle": "Geral", + "showMotd": "Mostrar dicas", + "showMotdDescription": "Dicas úteis e anúncios", + "showUpdaterModal": "Mostrar atualizações", + "showUpdaterModalDescription": "Exibir atualizações disponíveis do aplicativo", + "chooseTheme": "Escolha seu tema", + "chooseLanguage": "Escolha seu idioma", + "languageAuto": "Automático", + "appDescription": "Utilitário para gravar Armbian OS em cartões SD e unidades USB", + "version": "Versão", + "platform": "Plataforma", + "arch": "Arquitetura", + "tauriVersion": "Versão Tauri", + "links": "Links", + "githubRepo": "Repositório GitHub", + "documentation": "Documentação", + "reportIssue": "Reportar problema", + "community": "Fórum da comunidade", + "themeLight": "Claro", + "themeDark": "Escuro", + "themeAuto": "Auto", + "advancedCategory": "Avançado", + "developerMode": "Modo de desenvolvedor", + "developerModeDescription": "Ativar registro detalhado e informações de depuração", + "viewLogs": "Ver logs", + "viewLogsDescription": "Mostrar logs da aplicação para depuração", + "noLogsAvailable": "Nenhum log disponível", + "copyLogs": "Copiar logs", + "copied": "Copiado!" + }, "update": { "title": "Atualização disponível", "later": "Mais tarde", diff --git a/src/locales/ru.json b/src/locales/ru.json index d479f52..aa8f15c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -102,6 +102,42 @@ "unknown": "Неизвестно", "confirm": "Подтвердить" }, + "settings": { + "title": "Настройки", + "general": "Общее", + "theme": "Тема", + "language": "Язык", + "appInfo": "О программе", + "generalTitle": "Общее", + "showMotd": "Показывать советы", + "showMotdDescription": "Полезные советы и объявления", + "showUpdaterModal": "Показывать уведомления об обновлениях", + "showUpdaterModalDescription": "Отображать доступные обновления приложения", + "chooseTheme": "Выберите тему", + "chooseLanguage": "Выберите язык", + "languageAuto": "Автоматически", + "appDescription": "Утилита для записи Armbian OS на SD-карты и USB-накопители", + "version": "Версия", + "platform": "Платформа", + "arch": "Архитектура", + "tauriVersion": "Версия Tauri", + "links": "Ссылки", + "githubRepo": "Репозиторий GitHub", + "documentation": "Документация", + "reportIssue": "Сообщить о проблеме", + "community": "Форум сообщества", + "themeLight": "Светлая", + "themeDark": "Темная", + "themeAuto": "Авто", + "advancedCategory": "Расширенные", + "developerMode": "Режим разработчика", + "developerModeDescription": "Включить подробное журналирование и отладочную информацию", + "viewLogs": "Просмотр логов", + "viewLogsDescription": "Показать журналы приложения для отладки", + "noLogsAvailable": "Нет доступных логов", + "copyLogs": "Копировать логи", + "copied": "Скопировано!" + }, "update": { "title": "Доступно обновление", "later": "Позже", diff --git a/src/locales/sl.json b/src/locales/sl.json index 3a11eb3..1c04e19 100644 --- a/src/locales/sl.json +++ b/src/locales/sl.json @@ -102,6 +102,42 @@ "unknown": "Neznano", "confirm": "Potrdi" }, + "settings": { + "title": "Nastavitve", + "general": "Splošno", + "theme": "Tema", + "language": "Jezik", + "appInfo": "O aplikaciji", + "generalTitle": "Splošno", + "showMotd": "Pokaži nasvete", + "showMotdDescription": "Uporabne nasvete in obvestila", + "showUpdaterModal": "Pokaži obvestila o posodobitvah", + "showUpdaterModalDescription": "Prikaz dostopnih posodobitev aplikacije", + "chooseTheme": "Izberite temo", + "chooseLanguage": "Izberite jezik", + "languageAuto": "Samodejno", + "appDescription": "Pripomoček za zapis Armbian OS na SD kartice in USB pogone", + "version": "Različica", + "platform": "Platforma", + "arch": "Arhitektura", + "tauriVersion": "Različica Tauri", + "links": "Povezave", + "githubRepo": "GitHub repozitorij", + "documentation": "Dokumentacija", + "reportIssue": "Prijavite težavo", + "community": "Forum skupnosti", + "themeLight": "Svetla", + "themeDark": "Temna", + "themeAuto": "Samodejno", + "advancedCategory": "Napredno", + "developerMode": "Razvojni način", + "developerModeDescription": "Omogoči podrobno beleženje in informacije za razhroščevanje", + "viewLogs": "Prikaži dnevnike", + "viewLogsDescription": "Prikaži dnevnike aplikacije za razhroščevanje", + "noLogsAvailable": "Ni dostopnih dnevnikov", + "copyLogs": "Kopiraj dnevnike", + "copied": "Kopirano!" + }, "update": { "title": "Na voljo posodobitev", "later": "Kasneje", diff --git a/src/locales/sv.json b/src/locales/sv.json index d27093c..b4d57a1 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -102,6 +102,42 @@ "unknown": "Okänd", "confirm": "Bekräfta" }, + "settings": { + "title": "Inställningar", + "general": "Allmänt", + "theme": "Tema", + "language": "Språk", + "appInfo": "Om", + "generalTitle": "Allmänt", + "showMotd": "Visa tips", + "showMotdDescription": "Användbara tips och meddelanden", + "showUpdaterModal": "Visa uppdateringsmeddelanden", + "showUpdaterModalDescription": "Visa tillgängliga appuppdateringar", + "chooseTheme": "Välj ditt tema", + "chooseLanguage": "Välj ditt språk", + "languageAuto": "Automatisk", + "appDescription": "Verktyg för att skriva Armbian OS till SD-kort och USB-enheter", + "version": "Version", + "platform": "Plattform", + "arch": "Arkitektur", + "tauriVersion": "Tauri-version", + "links": "Länkar", + "githubRepo": "GitHub-förråd", + "documentation": "Dokumentation", + "reportIssue": "Rapportera problem", + "community": "Communityforum", + "themeLight": "Ljust", + "themeDark": "Mörkt", + "themeAuto": "Auto", + "advancedCategory": "Avancerat", + "developerMode": "Utvecklarläge", + "developerModeDescription": "Aktivera detaljerad loggning och felsökningsinfo", + "viewLogs": "Visa loggar", + "viewLogsDescription": "Visa applikationsloggar för felsökning", + "noLogsAvailable": "Inga loggar tillgängliga", + "copyLogs": "Kopiera loggar", + "copied": "Kopierad!" + }, "update": { "title": "Uppdatering tillgänglig", "later": "Senare", diff --git a/src/locales/tr.json b/src/locales/tr.json index 70ddb61..e1002fa 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -102,6 +102,42 @@ "unknown": "Bilinmeyen", "confirm": "Onayla" }, + "settings": { + "title": "Ayarlar", + "general": "Genel", + "theme": "Tema", + "language": "Dil", + "appInfo": "Hakkında", + "generalTitle": "Genel", + "showMotd": "İpuçları göster", + "showMotdDescription": "Yararlı ipuçları ve duyurular", + "showUpdaterModal": "Güncelleme bildirimlerini göster", + "showUpdaterModalDescription": "Mevcut uygulama güncellemelerini görüntüle", + "chooseTheme": "Tema seçin", + "chooseLanguage": "Dil seçin", + "languageAuto": "Otomatik", + "appDescription": "Armbian OS'u SD kartlara ve USB sürücülere yazma yardımcı programı", + "version": "Sürüm", + "platform": "Platform", + "arch": "Mimari", + "tauriVersion": "Tauri Sürümü", + "links": "Bağlantılar", + "githubRepo": "GitHub Deposu", + "documentation": "Belgeler", + "reportIssue": "Sorun bildir", + "community": "Topluluk Forumu", + "themeLight": "Açık", + "themeDark": "Koyu", + "themeAuto": "Otomatik", + "advancedCategory": "Gelişmiş", + "developerMode": "Geliştirici modu", + "developerModeDescription": "Detaylı günlük kaydı ve hata ayıklama bilgilerini etkinleştir", + "viewLogs": "Günlükleri Görüntüle", + "viewLogsDescription": "Hata ayıklama için uygulama günlüklerini göster", + "noLogsAvailable": "Kullanılabilir günlük yok", + "copyLogs": "Günlükleri kopyala", + "copied": "Kopyalandı!" + }, "update": { "title": "Güncelleme mevcut", "later": "Daha sonra", diff --git a/src/locales/uk.json b/src/locales/uk.json index 3c1863c..cc71884 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -102,6 +102,42 @@ "unknown": "Невідомо", "confirm": "Підтвердити" }, + "settings": { + "title": "Налаштування", + "general": "Загальне", + "theme": "Тема", + "language": "Мова", + "appInfo": "Про програму", + "generalTitle": "Загальне", + "showMotd": "Показувати поради", + "showMotdDescription": "Корисні поради та оголошення", + "showUpdaterModal": "Показувати сповіщення про оновлення", + "showUpdaterModalDescription": "Відображати доступні оновлення програми", + "chooseTheme": "Виберіть тему", + "chooseLanguage": "Виберіть мову", + "languageAuto": "Автоматично", + "appDescription": "Утиліта для запису Armbian OS на SD-карти та USB-накопичувачі", + "version": "Версія", + "platform": "Платформа", + "arch": "Архітектура", + "tauriVersion": "Версія Tauri", + "links": "Посилання", + "githubRepo": "Репозиторій GitHub", + "documentation": "Документація", + "reportIssue": "Повідомити про проблему", + "community": "Форум спільноти", + "themeLight": "Світла", + "themeDark": "Темна", + "themeAuto": "Авто", + "advancedCategory": "Розширені", + "developerMode": "Режим розробника", + "developerModeDescription": "Увімкнути детальне журналювання та налагоджувальну інформацію", + "viewLogs": "Перегляд логів", + "viewLogsDescription": "Показати журнали програми для налагодження", + "noLogsAvailable": "Немає доступних логів", + "copyLogs": "Копіювати логи", + "copied": "Скопійовано!" + }, "update": { "title": "Доступне оновлення", "later": "Пізніше", diff --git a/src/locales/zh.json b/src/locales/zh.json index 501e8c4..8c6d5bb 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -102,6 +102,42 @@ "unknown": "未知", "confirm": "确认" }, + "settings": { + "title": "设置", + "general": "通用", + "theme": "主题", + "language": "语言", + "appInfo": "关于", + "generalTitle": "通用", + "showMotd": "显示提示", + "showMotdDescription": "显示有用的提示和公告", + "showUpdaterModal": "显示更新通知", + "showUpdaterModalDescription": "显示可用的应用更新", + "chooseTheme": "选择主题", + "chooseLanguage": "选择语言", + "languageAuto": "自动", + "appDescription": "用于将 Armbian OS 写入 SD 卡和 U 盘的工具", + "version": "版本", + "platform": "平台", + "arch": "架构", + "tauriVersion": "Tauri 版本", + "links": "链接", + "githubRepo": "GitHub 仓库", + "documentation": "文档", + "reportIssue": "报告问题", + "community": "社区论坛", + "themeLight": "浅色", + "themeDark": "深色", + "themeAuto": "自动", + "advancedCategory": "高级", + "developerMode": "开发者模式", + "developerModeDescription": "启用详细日志记录和调试信息", + "viewLogs": "查看日志", + "viewLogsDescription": "显示应用程序日志以进行调试", + "noLogsAvailable": "没有可用的日志", + "copyLogs": "复制日志", + "copied": "已复制!" + }, "update": { "title": "有可用更新", "later": "稍后", diff --git a/src/main.tsx b/src/main.tsx index f9c3f3a..ccf8cc3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; import { initI18n } from './i18n'; +import { ThemeProvider } from './contexts/ThemeContext'; // Disable context menu in production if (import.meta.env.PROD) { @@ -12,7 +13,9 @@ if (import.meta.env.PROD) { initI18n().then(() => { ReactDOM.createRoot(document.getElementById('root')!).render( - + + + ); }); diff --git a/src/styles/components.css b/src/styles/components.css index 17467f7..5e30560 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1151,10 +1151,14 @@ .update-modal-close:hover { background: var(--bg-hover); - color: var(--text-primary); + color: var(--accent); transform: rotate(90deg); } +.update-modal-close:active { + transform: rotate(90deg) scale(0.95); +} + .update-modal { position: relative; } diff --git a/src/styles/layout.css b/src/styles/layout.css index faad355..873cb80 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -486,13 +486,54 @@ } /* ======================================== - APP VERSION (footer) + SETTINGS BUTTON (footer) ======================================== */ -.app-version { +.settings-button { position: fixed; bottom: 8px; right: 12px; - font-size: 10px; - color: var(--text-muted); - opacity: 0.5; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 10px; + width: 42px; + height: 42px; + cursor: pointer; + opacity: 0.8; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.settings-button:hover { + opacity: 1; + background: var(--bg-hover); + border-color: var(--accent); + transform: scale(1.1); + color: var(--accent); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.settings-button:active { + transform: scale(1.05); +} + +.settings-button svg { + display: block; + transition: transform 0.6s ease; +} + +.settings-button:hover svg { + transform: rotate(360deg); +} + +@keyframes settings-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } } diff --git a/src/styles/modal.css b/src/styles/modal.css index b03ba7b..68f5342 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -196,6 +196,30 @@ border-bottom: 1px solid var(--border-light); } +.modal-header-left { + display: flex; + align-items: center; + gap: 8px; +} + +.modal-back { + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: var(--text-secondary); + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.15s ease; +} + +.modal-back:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + .modal-title { font-size: 16px; font-weight: 600; @@ -212,12 +236,17 @@ align-items: center; justify-content: center; border-radius: 4px; - transition: all 0.15s ease; + transition: all 0.2s ease; } .modal-close:hover { background: var(--bg-hover); - color: var(--text-primary); + color: var(--accent); + transform: rotate(90deg); +} + +.modal-close:active { + transform: rotate(90deg) scale(0.95); } .modal-body { @@ -366,3 +395,868 @@ .modal-search-input::placeholder { color: var(--text-muted); } + +/* ======================================== + LIST ITEM STYLES + ======================================== */ +.list-item.selected { + background: var(--bg-hover); +} + +.list-item.selected .list-item-title { + color: var(--accent); + font-weight: 600; +} + +/* ======================================== + SETTINGS MODAL - SIDEBAR LAYOUT + ======================================== */ +.settings-modal { + background: var(--bg-modal); + border-radius: 12px; + width: 100%; + max-width: min(95vw, 900px); + max-height: min(92vh, 800px); + display: flex; + flex-direction: column; + box-shadow: var(--shadow-lg); + overflow: hidden; +} + +.settings-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-light); + background: var(--bg-secondary); +} + +.settings-modal-header h2 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.settings-modal-body { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Sidebar */ +.settings-sidebar { + width: 240px; + min-width: 200px; + max-width: 280px; + background: var(--bg-secondary); + border-right: 1px solid var(--border-light); + padding: 12px 0; + overflow-y: auto; + flex-shrink: 0; +} + +.settings-sidebar-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 20px; + background: none; + border: none; + border-left: 3px solid transparent; + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + text-align: left; +} + +.settings-sidebar-item:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.settings-sidebar-item.active { + background: linear-gradient(135deg, rgba(242, 101, 34, 0.12) 0%, rgba(242, 101, 34, 0.06) 100%); + border-left-color: var(--accent); + color: var(--accent); + font-weight: 600; +} + +.settings-sidebar-item svg { + flex-shrink: 0; + opacity: 0.8; +} + +.settings-sidebar-item.active svg { + opacity: 1; +} + +/* Content Area */ +.settings-content { + flex: 1; + overflow-y: auto; + padding: 24px; + background: var(--bg-modal); +} + +/* Settings content sections */ +.settings-section { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Section title header */ +.settings-section-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 8px 0; + padding-bottom: 8px; + border-bottom: 1px solid var(--border-light); +} + +/* Settings category (group of related settings) */ +.settings-category { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* Category title */ +.settings-category-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0; + padding: 0; +} + +/* Settings item (row with icon + label + toggle) */ +.settings-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: var(--bg-card); + border: 2px solid var(--border-light); + border-radius: 12px; +} + +.settings-item-left { + display: flex; + align-items: center; + gap: 10px; +} + +.settings-item-icon { + display: flex; + align-items: center; + padding-right: 10px; + border-right: 1px solid var(--border-light); +} + +.settings-item-left svg { + color: var(--accent); + flex-shrink: 0; + width: 22px !important; + height: 22px !important; +} + +.settings-item-content { + display: flex; + flex-direction: column; + gap: 2px; +} + +.settings-item-label { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.settings-item-description { + font-size: 11px; + color: var(--text-secondary); + line-height: 1.3; +} + +/* Toggle switch */ +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--border-color); + transition: 0.2s; + border-radius: 24px; +} + +.toggle-slider:before { + position: absolute; + content: ""; + height: 18px; + width: 18px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.2s; + border-radius: 50%; +} + +.toggle-switch input:checked + .toggle-slider { + background-color: var(--accent); +} + +.toggle-switch input:checked + .toggle-slider:before { + transform: translateX(20px); +} + +/* Clickable settings item (with arrow) */ +.settings-item-clickable { + cursor: pointer; + transition: all 0.2s ease; +} + +.settings-item-clickable:hover { + background: var(--bg-hover); + border-color: rgba(242, 101, 34, 0.3); +} + +.settings-item-clickable:active { + transform: scale(0.98); +} + +.settings-item-arrow { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: var(--text-muted); + opacity: 0.6; + transition: all 0.2s ease; +} + +.settings-item-clickable:hover .settings-item-arrow { + color: var(--accent); + opacity: 1; + transform: translateX(2px); +} + +/* Settings search box */ +.settings-search { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: 8px; + margin-bottom: 16px; +} + +.search-icon { + color: var(--text-muted); + flex-shrink: 0; + opacity: 0.6; +} + +.settings-search-input { + flex: 1; + background: none; + border: none; + outline: none; + font-size: 14px; + color: var(--text-primary); +} + +.settings-search-input::placeholder { + color: var(--text-muted); +} + +/* Settings list (for languages, etc) */ +.settings-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.settings-list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + background: var(--bg-card); + border: 2px solid var(--border-light); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s ease; + position: relative; +} + +.settings-list-item:hover { + border-color: rgba(242, 101, 34, 0.3); + background: var(--bg-hover); + transform: translateX(4px); +} + +.settings-list-item.active { + border-color: var(--accent); + background: linear-gradient(135deg, rgba(242, 101, 34, 0.15) 0%, rgba(242, 101, 34, 0.08) 100%); + padding-left: 20px; +} + +.settings-list-item.active::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 4px; + height: 60%; + background: var(--accent); + border-radius: 0 4px 4px 0; +} + +.settings-list-item.active svg { + color: var(--accent); + flex-shrink: 0; +} + +.settings-list-item-left { + display: flex; + align-items: center; + gap: 14px; +} + +.language-flag-emoji { + font-size: 28px; + line-height: 1; + min-width: 32px; + text-align: center; +} + +.language-flag-emoji img.emoji { + width: 28px; + height: 28px; + vertical-align: middle; +} + +.settings-list-item-label { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.settings-list-item.active .settings-list-item-label { + color: var(--accent); + font-weight: 600; +} + +/* Theme boxes - card-style theme selector */ +.theme-boxes { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} + +.theme-box { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 20px 16px; + background: var(--bg-card); + border: 2px solid var(--border-light); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.theme-box:hover { + border-color: var(--border-color); + background: var(--bg-hover); +} + +.theme-box.active { + border-color: var(--accent); + background: rgba(242, 101, 34, 0.08); +} + +.theme-box.active svg { + color: var(--accent); +} + +.theme-box svg { + color: var(--text-secondary); + transition: color 0.2s ease; +} + +.theme-box-label { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.no-results { + text-align: center; + padding: 40px 20px; + color: var(--text-muted); + font-size: 14px; +} + +/* About section */ +.about-section { + display: flex; + flex-direction: column; + gap: 32px; +} + +/* Hero Section - redesigned */ +.about-hero { + text-align: center; + padding: 32px 24px; + background: linear-gradient(135deg, rgba(242, 101, 34, 0.08) 0%, rgba(242, 101, 34, 0.03) 100%); + border: 1px solid rgba(242, 101, 34, 0.15); + border-radius: 16px; + position: relative; + overflow: hidden; +} + +.about-hero::before { + content: ''; + position: absolute; + top: -50%; + right: -20%; + width: 200px; + height: 200px; + background: radial-gradient(circle, rgba(242, 101, 34, 0.1) 0%, transparent 70%); + border-radius: 50%; + pointer-events: none; +} + +.about-logo { + width: 80px; + height: auto; + margin-bottom: 16px; + filter: drop-shadow(0 4px 12px rgba(242, 101, 34, 0.15)); +} + +.about-title { + font-size: 24px; + font-weight: 700; + background: linear-gradient(135deg, var(--text-primary) 0%, var(--text-secondary) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + margin: 0 0 8px 0; + letter-spacing: -0.3px; +} + +.about-description { + font-size: 14px; + color: var(--text-secondary); + margin: 0; + line-height: 1.6; + max-width: 400px; + margin-left: auto; + margin-right: auto; +} + +/* Info Cards - redesigned */ +.about-info-cards { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.info-card { + display: flex; + align-items: center; + gap: 14px; + padding: 16px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: 12px; + transition: all 0.2s ease; + position: relative; + overflow: hidden; +} + +.info-card::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: linear-gradient(180deg, var(--accent) 0%, rgba(242, 101, 34, 0.5) 100%); + opacity: 0; + transition: opacity 0.2s ease; +} + +.info-card:hover { + border-color: rgba(242, 101, 34, 0.3); + background: var(--bg-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.info-card:hover::before { + opacity: 1; +} + +.info-card-icon { + color: var(--accent); + flex-shrink: 0; +} + +.info-card-content { + flex: 1; + min-width: 0; +} + +.info-card-label { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 3px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.info-card-value { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace; +} + +/* Links Section - redesigned */ +.about-links h4 { + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 0 0 16px 0; +} + +.about-links-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.link-button { + display: flex; + align-items: center; + gap: 12px; + padding: 16px; + background: var(--bg-card); + border: 1px solid var(--border-light); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s ease; + text-align: left; + width: 100%; + position: relative; +} + +.link-button:hover { + background: var(--bg-hover); + border-color: rgba(242, 101, 34, 0.3); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(242, 101, 34, 0.1); +} + +.link-button:active { + transform: translateY(0); +} + +.link-button-icon { + color: var(--accent); + flex-shrink: 0; +} + +.link-button-icon svg { + width: 20px; + height: 20px; +} + +.link-button-text { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + flex: 1; +} + +.link-button-arrow { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + color: var(--text-muted); + flex-shrink: 0; + margin-left: auto; + opacity: 0.7; + transform: translateX(0); + transition: all 0.2s ease; +} + +.link-button:hover .link-button-arrow { + color: var(--accent); + opacity: 1; + transform: translateX(2px); +} + +/* Responsive */ +@media (max-width: 640px) { + .settings-modal { + max-width: 100vw; + max-height: min(95vh, 800px); + border-radius: 8px; + } + + .settings-modal-body { + flex-direction: column; + } + + .settings-sidebar { + width: 100%; + max-width: none; + min-width: none; + border-right: none; + border-bottom: 1px solid var(--border-light); + padding: 8px 0; + } + + .settings-sidebar-item { + padding: 12px 20px; + } + + .settings-content { + padding: 16px; + } + + .about-info-cards, + .about-links-grid { + grid-template-columns: 1fr; + } +} + +/* Large screens - use more space */ +@media (min-width: 1400px) { + .settings-modal { + max-width: min(90vw, 1000px); + max-height: min(90vh, 900px); + } + + .settings-sidebar { + width: 260px; + max-width: 300px; + } +} + +/* Logs Modal */ +.modal-content.logs-modal { + max-width: 1000px; + width: 95vw; + max-height: 90vh; +} + +.logs-modal .modal-body { + flex: 1; + overflow: hidden; + padding: 0; + display: flex; + flex-direction: column; +} + +.logs-loading, +.logs-error { + padding: 40px; + text-align: center; + color: var(--text-secondary); +} + +.logs-error { + color: var(--error); +} + +.logs-copy-button { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin: 16px; + padding: 10px 20px; + background: var(--bg-primary); + border: 1px solid var(--border-light); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + align-self: center; +} + +.logs-copy-button:hover { + background: var(--bg-hover); + border-color: var(--accent); + color: var(--accent); +} + +.logs-copy-button:active { + transform: scale(0.98); +} + +.logs-copy-button svg { + flex-shrink: 0; +} + +.logs-content { + flex: 1; + overflow-y: auto; + padding: 20px; + margin: 0; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', + 'Source Code Pro', monospace; + font-size: 12px; + line-height: 1.6; + background: var(--bg-secondary); + color: var(--text-primary); + white-space: pre-wrap; + word-wrap: break-word; +} + +/* ANSI color support from ansi-to-react */ +.logs-content span { + color: inherit; +} + +.logs-content .ansi-black { + color: #000; +} + +.logs-content .ansi-red { + color: #cd3131; +} + +.logs-content .ansi-green { + color: #0dbc79; +} + +.logs-content .ansi-yellow { + color: #e5e510; +} + +.logs-content .ansi-blue { + color: #2472c8; +} + +.logs-content .ansi-magenta { + color: #bc3fbc; +} + +.logs-content .ansi-cyan { + color: #11a8cd; +} + +.logs-content .ansi-white { + color: #e5e5e5; +} + +.logs-content .ansi-bright-black { + color: #666; +} + +.logs-content .ansi-bright-red { + color: #f14c4c; +} + +.logs-content .ansi-bright-green { + color: #23d18b; +} + +.logs-content .ansi-bright-yellow { + color: #f5f543; +} + +.logs-content .ansi-bright-blue { + color: #3b8eea; +} + +.logs-content .ansi-bright-magenta { + color: #d670d6; +} + +.logs-content .ansi-bright-cyan { + color: #29b8db; +} + +.logs-content .ansi-bright-white { + color: #ffffff; +} + +.logs-content .ansi-bold { + font-weight: bold; +} + +.logs-content .ansi-italic { + font-style: italic; +} + +/* Logs Modal Responsive */ +@media (max-width: 640px) { + .modal-content.logs-modal { + width: 100vw; + max-width: 100vw; + max-height: 95vh; + } + + .logs-content { + padding: 16px; + font-size: 11px; + } +} + +@media (min-width: 1400px) { + .modal-content.logs-modal { + max-width: 1100px; + } +} + +.logs-content .ansi-underline { + text-decoration: underline; +} + diff --git a/src/styles/theme.css b/src/styles/theme.css index 6702e47..22a42bb 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -4,12 +4,13 @@ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); -:root { +:root, +.theme-light { /* Armbian Brand */ --armbian-orange: #f26522; --armbian-red: #cc3333; - /* Light Theme (default) */ + /* Light Theme */ --bg-app: #f8f9fa; --bg-secondary: #eef0f2; --bg-card: #ffffff; @@ -41,8 +42,37 @@ } /* Dark Theme */ +.theme-dark { + --bg-app: #1a1a1a; + --bg-secondary: #252525; + --bg-card: #2a2a2a; + --bg-hover: #333333; + --bg-modal: #2a2a2a; + --bg-overlay: rgba(0, 0, 0, 0.7); + + --text-primary: #ffffff; + --text-secondary: #aaaaaa; + --text-muted: #777777; + + --border-color: #404040; + --border-light: #333333; + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4); + + /* Header - Dark */ + --header-bg: #1a1a1a; + --header-border: #333333; + --header-steps-bg: rgba(255, 255, 255, 0.05); + --header-steps-border: rgba(255, 255, 255, 0.1); + --header-step-bg: rgba(255, 255, 255, 0.1); + --header-step-color: #888888; +} + +/* Auto Theme - System Preference */ @media (prefers-color-scheme: dark) { - :root { + :root:not(.theme-light):not(.theme-dark) { --bg-app: #1a1a1a; --bg-secondary: #252525; --bg-card: #2a2a2a;