support custom userdata
This commit is contained in:
@@ -1,30 +1,47 @@
|
|||||||
use std::{path::PathBuf, process::Command};
|
use std::{path::PathBuf, process::Command};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
browsers::{browser_definition_by_id, resolve_browser_executable},
|
config_store,
|
||||||
models::ScanResponse,
|
models::{BrowserConfigListResponse, CreateCustomBrowserConfigInput, ScanResponse},
|
||||||
scanner,
|
scanner,
|
||||||
utils::local_app_data_dir,
|
|
||||||
};
|
};
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn scan_browsers() -> Result<ScanResponse, String> {
|
pub fn scan_browsers(app: AppHandle) -> Result<ScanResponse, String> {
|
||||||
scanner::scan_browsers()
|
scanner::scan_browsers(&app)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn open_browser_profile(browser_id: String, profile_id: String) -> Result<(), String> {
|
pub fn list_browser_configs(app: AppHandle) -> Result<BrowserConfigListResponse, String> {
|
||||||
let definition = browser_definition_by_id(&browser_id)
|
config_store::load_browser_config_list(&app)
|
||||||
.ok_or_else(|| format!("Unsupported browser: {browser_id}"))?;
|
}
|
||||||
let executable_path = resolve_browser_executable(&browser_id)
|
|
||||||
.ok_or_else(|| format!("Unable to locate executable for browser: {browser_id}"))?;
|
#[tauri::command]
|
||||||
let local_app_data = local_app_data_dir().ok_or_else(|| {
|
pub fn create_custom_browser_config(
|
||||||
"Unable to resolve the LOCALAPPDATA directory for the current user.".to_string()
|
app: AppHandle,
|
||||||
})?;
|
input: CreateCustomBrowserConfigInput,
|
||||||
let user_data_dir = definition
|
) -> Result<BrowserConfigListResponse, String> {
|
||||||
.local_app_data_segments
|
config_store::create_custom_browser_config(&app, input)
|
||||||
.iter()
|
}
|
||||||
.fold(local_app_data, |path, segment| path.join(segment));
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_custom_browser_config(
|
||||||
|
app: AppHandle,
|
||||||
|
config_id: String,
|
||||||
|
) -> Result<BrowserConfigListResponse, String> {
|
||||||
|
config_store::delete_custom_browser_config(&app, &config_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn open_browser_profile(
|
||||||
|
app: AppHandle,
|
||||||
|
browser_id: String,
|
||||||
|
profile_id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let config = config_store::find_browser_config(&app, &browser_id)?;
|
||||||
|
let executable_path = PathBuf::from(&config.executable_path);
|
||||||
|
let user_data_dir = PathBuf::from(&config.user_data_path);
|
||||||
let profile_directory = user_data_dir.join(&profile_id);
|
let profile_directory = user_data_dir.join(&profile_id);
|
||||||
|
|
||||||
if !user_data_dir.is_dir() {
|
if !user_data_dir.is_dir() {
|
||||||
|
|||||||
188
src-tauri/src/config_store.rs
Normal file
188
src-tauri/src/config_store.rs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
|
};
|
||||||
|
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
browsers::{browser_definitions, resolve_browser_executable},
|
||||||
|
models::{
|
||||||
|
BrowserConfigEntry, BrowserConfigListResponse, BrowserConfigSource,
|
||||||
|
CreateCustomBrowserConfigInput, CustomBrowserConfigRecord, StoredBrowserConfigs,
|
||||||
|
},
|
||||||
|
utils::local_app_data_dir,
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONFIG_FILE_NAME: &str = "browser-configs.json";
|
||||||
|
|
||||||
|
pub fn load_browser_config_list(app: &AppHandle) -> Result<BrowserConfigListResponse, String> {
|
||||||
|
Ok(BrowserConfigListResponse {
|
||||||
|
configs: resolve_browser_configs(app)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_browser_configs(app: &AppHandle) -> Result<Vec<BrowserConfigEntry>, String> {
|
||||||
|
let mut configs = default_browser_configs()?;
|
||||||
|
let stored = load_stored_configs(app)?;
|
||||||
|
|
||||||
|
configs.extend(
|
||||||
|
stored
|
||||||
|
.custom_configs
|
||||||
|
.into_iter()
|
||||||
|
.map(|config| BrowserConfigEntry {
|
||||||
|
id: config.id,
|
||||||
|
source: BrowserConfigSource::Custom,
|
||||||
|
browser_family_id: None,
|
||||||
|
name: config.name,
|
||||||
|
executable_path: config.executable_path,
|
||||||
|
user_data_path: config.user_data_path,
|
||||||
|
deletable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(configs)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_custom_browser_config(
|
||||||
|
app: &AppHandle,
|
||||||
|
input: CreateCustomBrowserConfigInput,
|
||||||
|
) -> Result<BrowserConfigListResponse, String> {
|
||||||
|
let name = input.name.trim();
|
||||||
|
let executable_path = input.executable_path.trim();
|
||||||
|
let user_data_path = input.user_data_path.trim();
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Name is required.".to_string());
|
||||||
|
}
|
||||||
|
if executable_path.is_empty() {
|
||||||
|
return Err("Executable path is required.".to_string());
|
||||||
|
}
|
||||||
|
if user_data_path.is_empty() {
|
||||||
|
return Err("User data path is required.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stored = load_stored_configs(app)?;
|
||||||
|
stored.custom_configs.push(CustomBrowserConfigRecord {
|
||||||
|
id: generate_custom_config_id(),
|
||||||
|
name: name.to_string(),
|
||||||
|
executable_path: executable_path.to_string(),
|
||||||
|
user_data_path: user_data_path.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
save_stored_configs(app, &stored)?;
|
||||||
|
load_browser_config_list(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_custom_browser_config(
|
||||||
|
app: &AppHandle,
|
||||||
|
config_id: &str,
|
||||||
|
) -> Result<BrowserConfigListResponse, String> {
|
||||||
|
let mut stored = load_stored_configs(app)?;
|
||||||
|
let original_len = stored.custom_configs.len();
|
||||||
|
stored
|
||||||
|
.custom_configs
|
||||||
|
.retain(|config| config.id != config_id);
|
||||||
|
|
||||||
|
if stored.custom_configs.len() == original_len {
|
||||||
|
return Err(format!("Custom browser config not found: {config_id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
save_stored_configs(app, &stored)?;
|
||||||
|
load_browser_config_list(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_browser_config(app: &AppHandle, config_id: &str) -> Result<BrowserConfigEntry, String> {
|
||||||
|
resolve_browser_configs(app)?
|
||||||
|
.into_iter()
|
||||||
|
.find(|config| config.id == config_id)
|
||||||
|
.ok_or_else(|| format!("Browser config not found: {config_id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_browser_configs() -> Result<Vec<BrowserConfigEntry>, String> {
|
||||||
|
let local_app_data = local_app_data_dir().ok_or_else(|| {
|
||||||
|
"Unable to resolve the LOCALAPPDATA directory for the current user.".to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(browser_definitions()
|
||||||
|
.into_iter()
|
||||||
|
.map(|definition| {
|
||||||
|
let user_data_path = definition
|
||||||
|
.local_app_data_segments
|
||||||
|
.iter()
|
||||||
|
.fold(local_app_data.clone(), |path, segment| path.join(segment));
|
||||||
|
|
||||||
|
BrowserConfigEntry {
|
||||||
|
id: definition.id.to_string(),
|
||||||
|
source: BrowserConfigSource::Default,
|
||||||
|
browser_family_id: Some(definition.id.to_string()),
|
||||||
|
name: definition.name.to_string(),
|
||||||
|
executable_path: resolve_browser_executable(definition.id)
|
||||||
|
.map(|path| path.display().to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
user_data_path: user_data_path.display().to_string(),
|
||||||
|
deletable: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_stored_configs(app: &AppHandle) -> Result<StoredBrowserConfigs, String> {
|
||||||
|
let path = config_file_path(app)?;
|
||||||
|
if !path.is_file() {
|
||||||
|
return Ok(StoredBrowserConfigs {
|
||||||
|
custom_configs: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"Failed to read browser config file {}: {error}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
serde_json::from_str(&content).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"Failed to parse browser config file {}: {error}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_stored_configs(app: &AppHandle, stored: &StoredBrowserConfigs) -> Result<(), String> {
|
||||||
|
let path = config_file_path(app)?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"Failed to create browser config directory {}: {error}",
|
||||||
|
parent.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = serde_json::to_string_pretty(stored)
|
||||||
|
.map_err(|error| format!("Failed to serialize browser configs: {error}"))?;
|
||||||
|
fs::write(&path, content).map_err(|error| {
|
||||||
|
format!(
|
||||||
|
"Failed to write browser config file {}: {error}",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_file_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let app_data_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| format!("Failed to resolve app data directory: {error}"))?;
|
||||||
|
Ok(app_data_dir.join(CONFIG_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_custom_config_id() -> String {
|
||||||
|
let timestamp = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.as_millis())
|
||||||
|
.unwrap_or(0);
|
||||||
|
format!("custom-{timestamp}")
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod browsers;
|
mod browsers;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
mod config_store;
|
||||||
mod models;
|
mod models;
|
||||||
mod scanner;
|
mod scanner;
|
||||||
mod utils;
|
mod utils;
|
||||||
@@ -10,6 +11,9 @@ pub fn run() {
|
|||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::scan_browsers,
|
commands::scan_browsers,
|
||||||
|
commands::list_browser_configs,
|
||||||
|
commands::create_custom_browser_config,
|
||||||
|
commands::delete_custom_browser_config,
|
||||||
commands::open_browser_profile
|
commands::open_browser_profile
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@@ -12,6 +12,7 @@ pub struct ScanResponse {
|
|||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct BrowserView {
|
pub struct BrowserView {
|
||||||
pub browser_id: String,
|
pub browser_id: String,
|
||||||
|
pub browser_family_id: Option<String>,
|
||||||
pub browser_name: String,
|
pub browser_name: String,
|
||||||
pub data_root: String,
|
pub data_root: String,
|
||||||
pub profiles: Vec<ProfileSummary>,
|
pub profiles: Vec<ProfileSummary>,
|
||||||
@@ -57,6 +58,54 @@ pub struct BookmarkSummary {
|
|||||||
pub profile_ids: Vec<String>,
|
pub profile_ids: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BrowserConfigListResponse {
|
||||||
|
pub configs: Vec<BrowserConfigEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct BrowserConfigEntry {
|
||||||
|
pub id: String,
|
||||||
|
pub source: BrowserConfigSource,
|
||||||
|
pub browser_family_id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub executable_path: String,
|
||||||
|
pub user_data_path: String,
|
||||||
|
pub deletable: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum BrowserConfigSource {
|
||||||
|
Default,
|
||||||
|
Custom,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateCustomBrowserConfigInput {
|
||||||
|
pub name: String,
|
||||||
|
pub executable_path: String,
|
||||||
|
pub user_data_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct StoredBrowserConfigs {
|
||||||
|
pub custom_configs: Vec<CustomBrowserConfigRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CustomBrowserConfigRecord {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub executable_path: String,
|
||||||
|
pub user_data_path: String,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct BrowserDefinition {
|
pub struct BrowserDefinition {
|
||||||
pub id: &'static str,
|
pub id: &'static str,
|
||||||
pub name: &'static str,
|
pub name: &'static str,
|
||||||
|
|||||||
@@ -1,43 +1,32 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{BTreeMap, BTreeSet},
|
collections::{BTreeMap, BTreeSet},
|
||||||
fs,
|
fs,
|
||||||
path::Path,
|
path::{Path, PathBuf},
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
browsers::browser_definitions,
|
config_store,
|
||||||
models::{
|
models::{
|
||||||
BookmarkSummary, BrowserDefinition, BrowserStats, BrowserView, ExtensionSummary,
|
BookmarkSummary, BrowserConfigEntry, BrowserStats, BrowserView, ExtensionSummary,
|
||||||
ProfileSummary, ScanResponse, TempBookmark, TempExtension,
|
ProfileSummary, ScanResponse, TempBookmark, TempExtension,
|
||||||
},
|
},
|
||||||
utils::{
|
utils::{first_non_empty, load_image_as_data_url, pick_latest_subdirectory, read_json_file},
|
||||||
first_non_empty, load_image_as_data_url, local_app_data_dir, pick_latest_subdirectory,
|
|
||||||
read_json_file,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn scan_browsers() -> Result<ScanResponse, String> {
|
pub fn scan_browsers(app: &AppHandle) -> Result<ScanResponse, String> {
|
||||||
let local_app_data = local_app_data_dir().ok_or_else(|| {
|
let browsers = config_store::resolve_browser_configs(app)?
|
||||||
"Unable to resolve the LOCALAPPDATA directory for the current user.".to_string()
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let browsers = browser_definitions()
|
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|definition| scan_browser(&local_app_data, definition))
|
.filter_map(scan_browser)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
Ok(ScanResponse { browsers })
|
Ok(ScanResponse { browsers })
|
||||||
}
|
}
|
||||||
|
|
||||||
fn scan_browser(local_app_data: &Path, definition: BrowserDefinition) -> Option<BrowserView> {
|
fn scan_browser(config: BrowserConfigEntry) -> Option<BrowserView> {
|
||||||
let root = definition
|
let root = PathBuf::from(&config.user_data_path);
|
||||||
.local_app_data_segments
|
|
||||||
.iter()
|
|
||||||
.fold(local_app_data.to_path_buf(), |path, segment| {
|
|
||||||
path.join(segment)
|
|
||||||
});
|
|
||||||
|
|
||||||
if !root.is_dir() {
|
if !root.is_dir() {
|
||||||
return None;
|
return None;
|
||||||
@@ -99,8 +88,9 @@ fn scan_browser(local_app_data: &Path, definition: BrowserDefinition) -> Option<
|
|||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
Some(BrowserView {
|
Some(BrowserView {
|
||||||
browser_id: definition.id.to_string(),
|
browser_id: config.id,
|
||||||
browser_name: definition.name.to_string(),
|
browser_family_id: config.browser_family_id,
|
||||||
|
browser_name: config.name,
|
||||||
data_root: root.display().to_string(),
|
data_root: root.display().to_string(),
|
||||||
stats: BrowserStats {
|
stats: BrowserStats {
|
||||||
profile_count: profiles.len(),
|
profile_count: profiles.len(),
|
||||||
|
|||||||
157
src/App.vue
157
src/App.vue
@@ -6,20 +6,30 @@ const {
|
|||||||
activeSection,
|
activeSection,
|
||||||
bookmarkProfilesExpanded,
|
bookmarkProfilesExpanded,
|
||||||
bookmarkSortKey,
|
bookmarkSortKey,
|
||||||
|
browserConfigs,
|
||||||
browserMonogram,
|
browserMonogram,
|
||||||
browsers,
|
browsers,
|
||||||
|
configError,
|
||||||
|
configMonogram,
|
||||||
|
configsLoading,
|
||||||
|
createConfigForm,
|
||||||
|
createCustomBrowserConfig,
|
||||||
currentBrowser,
|
currentBrowser,
|
||||||
|
deleteCustomBrowserConfig,
|
||||||
domainFromUrl,
|
domainFromUrl,
|
||||||
error,
|
error,
|
||||||
extensionMonogram,
|
extensionMonogram,
|
||||||
extensionProfilesExpanded,
|
extensionProfilesExpanded,
|
||||||
extensionSortKey,
|
extensionSortKey,
|
||||||
|
isDeletingConfig,
|
||||||
isOpeningProfile,
|
isOpeningProfile,
|
||||||
loading,
|
loading,
|
||||||
openProfileError,
|
openProfileError,
|
||||||
openBrowserProfile,
|
openBrowserProfile,
|
||||||
|
page,
|
||||||
profileSortKey,
|
profileSortKey,
|
||||||
scanBrowsers,
|
refreshAll,
|
||||||
|
savingConfig,
|
||||||
sectionCount,
|
sectionCount,
|
||||||
selectedBrowserId,
|
selectedBrowserId,
|
||||||
sortedBookmarks,
|
sortedBookmarks,
|
||||||
@@ -45,9 +55,9 @@ const {
|
|||||||
v-for="browser in browsers"
|
v-for="browser in browsers"
|
||||||
:key="browser.browserId"
|
:key="browser.browserId"
|
||||||
class="browser-nav-item"
|
class="browser-nav-item"
|
||||||
:class="[browser.browserId, { active: browser.browserId === currentBrowser?.browserId }]"
|
:class="[browser.browserFamilyId ?? browser.browserId, { active: browser.browserId === currentBrowser?.browserId }]"
|
||||||
type="button"
|
type="button"
|
||||||
@click="selectedBrowserId = browser.browserId"
|
@click="selectedBrowserId = browser.browserId; page = 'browserData'"
|
||||||
>
|
>
|
||||||
<div class="browser-nav-icon">{{ browserMonogram(browser.browserId) }}</div>
|
<div class="browser-nav-icon">{{ browserMonogram(browser.browserId) }}</div>
|
||||||
<div class="browser-nav-body">
|
<div class="browser-nav-body">
|
||||||
@@ -61,23 +71,140 @@ const {
|
|||||||
<p>No supported Chromium browser data was found yet.</p>
|
<p>No supported Chromium browser data was found yet.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="refresh-button sidebar-refresh" type="button" @click="scanBrowsers">
|
<button class="refresh-button sidebar-refresh" type="button" @click="refreshAll">
|
||||||
{{ loading ? "Scanning..." : "Refresh" }}
|
{{ loading || configsLoading ? "Refreshing..." : "Refresh" }}
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="content-panel">
|
<main class="content-panel">
|
||||||
<section v-if="loading" class="state-panel">
|
<section class="page-tabs">
|
||||||
|
<button
|
||||||
|
class="page-tab"
|
||||||
|
:class="{ active: page === 'browserData' }"
|
||||||
|
type="button"
|
||||||
|
@click="page = 'browserData'"
|
||||||
|
>
|
||||||
|
Browser Data
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="page-tab"
|
||||||
|
:class="{ active: page === 'configuration' }"
|
||||||
|
type="button"
|
||||||
|
@click="page = 'configuration'"
|
||||||
|
>
|
||||||
|
Configuration
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<template v-if="page === 'configuration'">
|
||||||
|
<section class="content-scroll-area">
|
||||||
|
<section class="content-section">
|
||||||
|
<div v-if="configError" class="inline-error">
|
||||||
|
{{ configError }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-form-card">
|
||||||
|
<div class="config-form-header">
|
||||||
|
<h3>Add Custom Browser</h3>
|
||||||
|
<p>Provide a name, executable path, and Chromium user data path.</p>
|
||||||
|
</div>
|
||||||
|
<div class="config-form-grid">
|
||||||
|
<label class="field-group">
|
||||||
|
<span>Name</span>
|
||||||
|
<input v-model="createConfigForm.name" placeholder="Work Chrome" />
|
||||||
|
</label>
|
||||||
|
<label class="field-group">
|
||||||
|
<span>Executable Path</span>
|
||||||
|
<input
|
||||||
|
v-model="createConfigForm.executablePath"
|
||||||
|
placeholder="C:\Program Files\...\chrome.exe"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="field-group field-span">
|
||||||
|
<span>User Data Path</span>
|
||||||
|
<input
|
||||||
|
v-model="createConfigForm.userDataPath"
|
||||||
|
placeholder="C:\Users\...\User Data"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="config-form-actions">
|
||||||
|
<button
|
||||||
|
class="primary-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="savingConfig"
|
||||||
|
@click="createCustomBrowserConfig"
|
||||||
|
>
|
||||||
|
{{ savingConfig ? "Saving..." : "Add Config" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="configsLoading" class="empty-card">
|
||||||
|
<p>Loading browser configs...</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="stack-list">
|
||||||
|
<article
|
||||||
|
v-for="config in browserConfigs"
|
||||||
|
:key="config.id"
|
||||||
|
class="config-card"
|
||||||
|
>
|
||||||
|
<div class="config-card-header">
|
||||||
|
<div class="config-card-lead">
|
||||||
|
<div class="browser-nav-icon config-icon">
|
||||||
|
{{ configMonogram(config) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="config-title-row">
|
||||||
|
<h4>{{ config.name }}</h4>
|
||||||
|
<span class="badge neutral">
|
||||||
|
{{ config.source === "default" ? "Default" : "Custom" }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="config-id">{{ config.id }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="config.deletable"
|
||||||
|
class="danger-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="isDeletingConfig(config.id)"
|
||||||
|
@click="deleteCustomBrowserConfig(config.id)"
|
||||||
|
>
|
||||||
|
{{ isDeletingConfig(config.id) ? "Deleting..." : "Delete" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="config-meta">
|
||||||
|
<div class="config-meta-row">
|
||||||
|
<span class="config-label">Executable</span>
|
||||||
|
<p :title="config.executablePath">{{ config.executablePath || "Not resolved" }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="config-meta-row">
|
||||||
|
<span class="config-label">User Data</span>
|
||||||
|
<p :title="config.userDataPath">{{ config.userDataPath }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="loading">
|
||||||
|
<section class="state-panel">
|
||||||
<p class="eyebrow">Scanning</p>
|
<p class="eyebrow">Scanning</p>
|
||||||
<h2>Reading local browser data</h2>
|
<h2>Reading local browser data</h2>
|
||||||
<p>Profiles, installed extensions, and bookmarks are being collected.</p>
|
<p>Profiles, installed extensions, and bookmarks are being collected.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<section v-else-if="error" class="state-panel error">
|
<template v-else-if="error">
|
||||||
|
<section class="state-panel error">
|
||||||
<p class="eyebrow">Error</p>
|
<p class="eyebrow">Error</p>
|
||||||
<h2>Scan failed</h2>
|
<h2>Scan failed</h2>
|
||||||
<p>{{ error }}</p>
|
<p>{{ error }}</p>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
<template v-else-if="currentBrowser">
|
<template v-else-if="currentBrowser">
|
||||||
<section class="section-tabs">
|
<section class="section-tabs">
|
||||||
@@ -129,11 +256,7 @@ const {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="sortedProfiles.length" class="stack-list">
|
<div v-if="sortedProfiles.length" class="stack-list">
|
||||||
<article
|
<article v-for="profile in sortedProfiles" :key="profile.id" class="profile-card">
|
||||||
v-for="profile in sortedProfiles"
|
|
||||||
:key="profile.id"
|
|
||||||
class="profile-card"
|
|
||||||
>
|
|
||||||
<div class="profile-avatar">
|
<div class="profile-avatar">
|
||||||
<img
|
<img
|
||||||
v-if="profile.avatarDataUrl"
|
v-if="profile.avatarDataUrl"
|
||||||
@@ -247,11 +370,7 @@ const {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="sortedBookmarks.length" class="bookmark-list">
|
<div v-if="sortedBookmarks.length" class="bookmark-list">
|
||||||
<article
|
<article v-for="bookmark in sortedBookmarks" :key="bookmark.url" class="bookmark-card">
|
||||||
v-for="bookmark in sortedBookmarks"
|
|
||||||
:key="bookmark.url"
|
|
||||||
class="bookmark-card"
|
|
||||||
>
|
|
||||||
<div class="bookmark-body">
|
<div class="bookmark-body">
|
||||||
<div class="bookmark-topline">
|
<div class="bookmark-topline">
|
||||||
<h4>{{ bookmark.title }}</h4>
|
<h4>{{ bookmark.title }}</h4>
|
||||||
@@ -290,11 +409,13 @@ const {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<section v-else class="state-panel">
|
<template v-else>
|
||||||
|
<section class="state-panel">
|
||||||
<p class="eyebrow">No Data</p>
|
<p class="eyebrow">No Data</p>
|
||||||
<h2>No supported browser was detected</h2>
|
<h2>No supported browser was detected</h2>
|
||||||
<p>Install or sign in to Chrome, Edge, or Brave and refresh the scan.</p>
|
<p>Install or sign in to Chrome, Edge, or Brave and refresh the scan.</p>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -31,9 +31,32 @@ export type ProfileSortKey = "name" | "email" | "id";
|
|||||||
export type ExtensionSortKey = "name" | "id";
|
export type ExtensionSortKey = "name" | "id";
|
||||||
export type BookmarkSortKey = "title" | "url";
|
export type BookmarkSortKey = "title" | "url";
|
||||||
export type ActiveSection = "profiles" | "extensions" | "bookmarks";
|
export type ActiveSection = "profiles" | "extensions" | "bookmarks";
|
||||||
|
export type AppPage = "browserData" | "configuration";
|
||||||
|
export type BrowserConfigSource = "default" | "custom";
|
||||||
|
|
||||||
|
export type BrowserConfigEntry = {
|
||||||
|
id: string;
|
||||||
|
source: BrowserConfigSource;
|
||||||
|
browserFamilyId: string | null;
|
||||||
|
name: string;
|
||||||
|
executablePath: string;
|
||||||
|
userDataPath: string;
|
||||||
|
deletable: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BrowserConfigListResponse = {
|
||||||
|
configs: BrowserConfigEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreateCustomBrowserConfigInput = {
|
||||||
|
name: string;
|
||||||
|
executablePath: string;
|
||||||
|
userDataPath: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type BrowserView = {
|
export type BrowserView = {
|
||||||
browserId: string;
|
browserId: string;
|
||||||
|
browserFamilyId: string | null;
|
||||||
browserName: string;
|
browserName: string;
|
||||||
dataRoot: string;
|
dataRoot: string;
|
||||||
profiles: ProfileSummary[];
|
profiles: ProfileSummary[];
|
||||||
|
|||||||
@@ -4,19 +4,34 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import { sortBookmarks, sortExtensions, sortProfiles } from "./sort";
|
import { sortBookmarks, sortExtensions, sortProfiles } from "./sort";
|
||||||
import type {
|
import type {
|
||||||
ActiveSection,
|
ActiveSection,
|
||||||
|
AppPage,
|
||||||
BookmarkSortKey,
|
BookmarkSortKey,
|
||||||
|
BrowserConfigEntry,
|
||||||
|
BrowserConfigListResponse,
|
||||||
BrowserView,
|
BrowserView,
|
||||||
|
CreateCustomBrowserConfigInput,
|
||||||
ExtensionSortKey,
|
ExtensionSortKey,
|
||||||
ProfileSortKey,
|
ProfileSortKey,
|
||||||
ScanResponse,
|
ScanResponse,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export function useBrowserAssistant() {
|
export function useBrowserAssistant() {
|
||||||
|
const page = ref<AppPage>("browserData");
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const openProfileError = ref("");
|
const openProfileError = ref("");
|
||||||
const openingProfileKey = ref("");
|
const openingProfileKey = ref("");
|
||||||
const response = ref<ScanResponse>({ browsers: [] });
|
const response = ref<ScanResponse>({ browsers: [] });
|
||||||
|
const browserConfigs = ref<BrowserConfigEntry[]>([]);
|
||||||
|
const configsLoading = ref(true);
|
||||||
|
const configError = ref("");
|
||||||
|
const savingConfig = ref(false);
|
||||||
|
const deletingConfigId = ref("");
|
||||||
|
const createConfigForm = ref<CreateCustomBrowserConfigInput>({
|
||||||
|
name: "",
|
||||||
|
executablePath: "",
|
||||||
|
userDataPath: "",
|
||||||
|
});
|
||||||
const selectedBrowserId = ref("");
|
const selectedBrowserId = ref("");
|
||||||
const activeSection = ref<ActiveSection>("profiles");
|
const activeSection = ref<ActiveSection>("profiles");
|
||||||
const expandedExtensionIds = ref<string[]>([]);
|
const expandedExtensionIds = ref<string[]>([]);
|
||||||
@@ -65,8 +80,24 @@ export function useBrowserAssistant() {
|
|||||||
watch(selectedBrowserId, () => {
|
watch(selectedBrowserId, () => {
|
||||||
expandedExtensionIds.value = [];
|
expandedExtensionIds.value = [];
|
||||||
expandedBookmarkUrls.value = [];
|
expandedBookmarkUrls.value = [];
|
||||||
|
openProfileError.value = "";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function loadBrowserConfigs() {
|
||||||
|
configsLoading.value = true;
|
||||||
|
configError.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<BrowserConfigListResponse>("list_browser_configs");
|
||||||
|
browserConfigs.value = result.configs;
|
||||||
|
} catch (loadError) {
|
||||||
|
configError.value =
|
||||||
|
loadError instanceof Error ? loadError.message : "Failed to load browser configs.";
|
||||||
|
} finally {
|
||||||
|
configsLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function scanBrowsers() {
|
async function scanBrowsers() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
@@ -83,6 +114,10 @@ export function useBrowserAssistant() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([loadBrowserConfigs(), scanBrowsers()]);
|
||||||
|
}
|
||||||
|
|
||||||
async function openBrowserProfile(browserId: string, profileId: string) {
|
async function openBrowserProfile(browserId: string, profileId: string) {
|
||||||
const profileKey = `${browserId}:${profileId}`;
|
const profileKey = `${browserId}:${profileId}`;
|
||||||
openingProfileKey.value = profileKey;
|
openingProfileKey.value = profileKey;
|
||||||
@@ -103,17 +138,89 @@ export function useBrowserAssistant() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createCustomBrowserConfig() {
|
||||||
|
savingConfig.value = true;
|
||||||
|
configError.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<BrowserConfigListResponse>("create_custom_browser_config", {
|
||||||
|
input: createConfigForm.value,
|
||||||
|
});
|
||||||
|
browserConfigs.value = result.configs;
|
||||||
|
createConfigForm.value = {
|
||||||
|
name: "",
|
||||||
|
executablePath: "",
|
||||||
|
userDataPath: "",
|
||||||
|
};
|
||||||
|
await scanBrowsers();
|
||||||
|
} catch (saveError) {
|
||||||
|
configError.value =
|
||||||
|
saveError instanceof Error ? saveError.message : "Failed to create browser config.";
|
||||||
|
} finally {
|
||||||
|
savingConfig.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCustomBrowserConfig(configId: string) {
|
||||||
|
deletingConfigId.value = configId;
|
||||||
|
configError.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await invoke<BrowserConfigListResponse>("delete_custom_browser_config", {
|
||||||
|
configId,
|
||||||
|
});
|
||||||
|
browserConfigs.value = result.configs;
|
||||||
|
await scanBrowsers();
|
||||||
|
} catch (deleteError) {
|
||||||
|
configError.value =
|
||||||
|
deleteError instanceof Error ? deleteError.message : "Failed to delete browser config.";
|
||||||
|
} finally {
|
||||||
|
deletingConfigId.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDeletingConfig(configId: string) {
|
||||||
|
return deletingConfigId.value === configId;
|
||||||
|
}
|
||||||
|
|
||||||
function isOpeningProfile(browserId: string, profileId: string) {
|
function isOpeningProfile(browserId: string, profileId: string) {
|
||||||
return openingProfileKey.value === `${browserId}:${profileId}`;
|
return openingProfileKey.value === `${browserId}:${profileId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function browserMonogram(browserId: string) {
|
function browserMonogram(browserId: string) {
|
||||||
if (browserId === "chrome") return "CH";
|
const current = browsers.value.find((browser) => browser.browserId === browserId);
|
||||||
if (browserId === "edge") return "ED";
|
const familyId = current?.browserFamilyId;
|
||||||
if (browserId === "brave") return "BR";
|
if (familyId === "chrome") return "CH";
|
||||||
|
if (familyId === "edge") return "ED";
|
||||||
|
if (familyId === "brave") return "BR";
|
||||||
|
|
||||||
|
const name = current?.browserName?.trim() ?? "";
|
||||||
|
if (name) {
|
||||||
|
const letters = name
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]);
|
||||||
|
if (letters.length) return letters.join("").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
return browserId.slice(0, 2).toUpperCase();
|
return browserId.slice(0, 2).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configMonogram(config: BrowserConfigEntry) {
|
||||||
|
if (config.browserFamilyId === "chrome") return "CH";
|
||||||
|
if (config.browserFamilyId === "edge") return "ED";
|
||||||
|
if (config.browserFamilyId === "brave") return "BR";
|
||||||
|
|
||||||
|
const letters = config.name
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part[0]);
|
||||||
|
return (letters.join("") || config.id.slice(0, 2)).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
function extensionMonogram(name: string) {
|
function extensionMonogram(name: string) {
|
||||||
return name.trim().slice(0, 1).toUpperCase() || "?";
|
return name.trim().slice(0, 1).toUpperCase() || "?";
|
||||||
}
|
}
|
||||||
@@ -154,27 +261,37 @@ export function useBrowserAssistant() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void scanBrowsers();
|
void refreshAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSection,
|
activeSection,
|
||||||
bookmarkProfilesExpanded,
|
bookmarkProfilesExpanded,
|
||||||
bookmarkSortKey,
|
bookmarkSortKey,
|
||||||
|
browserConfigs,
|
||||||
browserMonogram,
|
browserMonogram,
|
||||||
browsers,
|
browsers,
|
||||||
|
configError,
|
||||||
|
configMonogram,
|
||||||
|
configsLoading,
|
||||||
|
createConfigForm,
|
||||||
|
createCustomBrowserConfig,
|
||||||
currentBrowser,
|
currentBrowser,
|
||||||
|
deleteCustomBrowserConfig,
|
||||||
domainFromUrl,
|
domainFromUrl,
|
||||||
error,
|
error,
|
||||||
extensionMonogram,
|
extensionMonogram,
|
||||||
extensionProfilesExpanded,
|
extensionProfilesExpanded,
|
||||||
extensionSortKey,
|
extensionSortKey,
|
||||||
loading,
|
isDeletingConfig,
|
||||||
isOpeningProfile,
|
isOpeningProfile,
|
||||||
|
loading,
|
||||||
openProfileError,
|
openProfileError,
|
||||||
openBrowserProfile,
|
openBrowserProfile,
|
||||||
|
page,
|
||||||
profileSortKey,
|
profileSortKey,
|
||||||
scanBrowsers,
|
refreshAll,
|
||||||
|
savingConfig,
|
||||||
sectionCount,
|
sectionCount,
|
||||||
selectedBrowserId,
|
selectedBrowserId,
|
||||||
sortedBookmarks,
|
sortedBookmarks,
|
||||||
|
|||||||
163
src/styles.css
163
src/styles.css
@@ -276,6 +276,30 @@ button {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-tab {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.96), rgba(232, 240, 255, 0.92));
|
||||||
|
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.section-tabs,
|
.section-tabs,
|
||||||
.content-section,
|
.content-section,
|
||||||
.state-panel {
|
.state-panel {
|
||||||
@@ -299,6 +323,137 @@ button {
|
|||||||
font-size: 0.86rem;
|
font-size: 0.86rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-form-card,
|
||||||
|
.config-card {
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||||
|
background: var(--panel-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form-header h3,
|
||||||
|
.config-title-row h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form-header p,
|
||||||
|
.config-id,
|
||||||
|
.config-meta-row p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group span,
|
||||||
|
.config-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.94);
|
||||||
|
color: var(--text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group input:focus {
|
||||||
|
border-color: rgba(47, 111, 237, 0.42);
|
||||||
|
box-shadow: 0 0 0 3px rgba(47, 111, 237, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-span {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button,
|
||||||
|
.danger-button {
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
background: linear-gradient(135deg, #10213f 0%, #213f75 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger-button {
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||||
|
background: rgba(254, 242, 242, 0.96);
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:disabled,
|
||||||
|
.danger-button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card-header,
|
||||||
|
.config-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-card-lead {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-meta-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-meta-row p {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.sort-bar {
|
.sort-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -695,6 +850,14 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
|
.config-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-span {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.sort-bar {
|
.sort-bar {
|
||||||
justify-content: stretch;
|
justify-content: stretch;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user