mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-30 16:09:00 +02:00
chore: version bump
This commit is contained in:
@@ -46,9 +46,12 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"color": "^5.0.0",
|
||||||
|
"lucide-react": "^0.539.0",
|
||||||
"motion": "^12.23.12",
|
"motion": "^12.23.12",
|
||||||
"next": "^15.4.6",
|
"next": "^15.4.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
@@ -60,6 +63,7 @@
|
|||||||
"@biomejs/biome": "2.1.4",
|
"@biomejs/biome": "2.1.4",
|
||||||
"@tailwindcss/postcss": "^4.1.11",
|
"@tailwindcss/postcss": "^4.1.11",
|
||||||
"@tauri-apps/cli": "^2.7.1",
|
"@tauri-apps/cli": "^2.7.1",
|
||||||
|
"@types/color": "^4.2.0",
|
||||||
"@types/node": "^24.2.1",
|
"@types/node": "^24.2.1",
|
||||||
"@types/react": "^19.1.9",
|
"@types/react": "^19.1.9",
|
||||||
"@types/react-dom": "^19.1.7",
|
"@types/react-dom": "^19.1.7",
|
||||||
|
|||||||
Generated
+1197
File diff suppressed because it is too large
Load Diff
@@ -520,6 +520,7 @@ mod tests {
|
|||||||
release_type: "stable".to_string(),
|
release_type: "stable".to_string(),
|
||||||
camoufox_config: None,
|
camoufox_config: None,
|
||||||
group_id: None,
|
group_id: None,
|
||||||
|
tags: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,12 +140,27 @@ impl BrowserRunner {
|
|||||||
|
|
||||||
pub fn save_profile(&self, profile: &BrowserProfile) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn save_profile(&self, profile: &BrowserProfile) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
profile_manager.save_profile(profile)
|
let result = profile_manager.save_profile(profile);
|
||||||
|
// Update tag suggestions after any save
|
||||||
|
let _ = crate::tag_manager::TAG_MANAGER
|
||||||
|
.lock()
|
||||||
|
.map(|tm| {
|
||||||
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
|
});
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_profiles(&self) -> Result<Vec<BrowserProfile>, Box<dyn std::error::Error>> {
|
pub fn list_profiles(&self) -> Result<Vec<BrowserProfile>, Box<dyn std::error::Error>> {
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
profile_manager.list_profiles()
|
let profiles = profile_manager.list_profiles();
|
||||||
|
if let Ok(ref ps) = profiles {
|
||||||
|
let _ = crate::tag_manager::TAG_MANAGER
|
||||||
|
.lock()
|
||||||
|
.map(|tm| {
|
||||||
|
let _ = tm.rebuild_from_profiles(ps);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn launch_browser(
|
pub async fn launch_browser(
|
||||||
@@ -249,8 +264,14 @@ impl BrowserRunner {
|
|||||||
println!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
println!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the updated profile
|
// Save the updated profile
|
||||||
self.save_process_info(&updated_profile)?;
|
self.save_process_info(&updated_profile)?;
|
||||||
|
// Ensure tag suggestions include any tags from this profile
|
||||||
|
let _ = crate::tag_manager::TAG_MANAGER
|
||||||
|
.lock()
|
||||||
|
.map(|tm| {
|
||||||
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
|
});
|
||||||
println!(
|
println!(
|
||||||
"Updated profile with process info: {}",
|
"Updated profile with process info: {}",
|
||||||
updated_profile.name
|
updated_profile.name
|
||||||
@@ -450,6 +471,11 @@ impl BrowserRunner {
|
|||||||
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
||||||
|
|
||||||
self.save_process_info(&updated_profile)?;
|
self.save_process_info(&updated_profile)?;
|
||||||
|
let _ = crate::tag_manager::TAG_MANAGER
|
||||||
|
.lock()
|
||||||
|
.map(|tm| {
|
||||||
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
|
});
|
||||||
|
|
||||||
// Apply proxy settings if needed (for Firefox-based browsers)
|
// Apply proxy settings if needed (for Firefox-based browsers)
|
||||||
if profile.proxy_id.is_some()
|
if profile.proxy_id.is_some()
|
||||||
@@ -815,6 +841,13 @@ impl BrowserRunner {
|
|||||||
println!("Warning: Failed to cleanup unused binaries: {e}");
|
println!("Warning: Failed to cleanup unused binaries: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild tags after deletion
|
||||||
|
let _ = crate::tag_manager::TAG_MANAGER
|
||||||
|
.lock()
|
||||||
|
.map(|tm| {
|
||||||
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1442,6 +1475,11 @@ impl BrowserRunner {
|
|||||||
|
|
||||||
files_exist
|
files_exist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_all_tags(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
let tag_manager = crate::tag_manager::TAG_MANAGER.lock().unwrap();
|
||||||
|
tag_manager.get_all_tags()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -1603,6 +1641,14 @@ pub async fn update_profile_proxy(
|
|||||||
.map_err(|e| format!("Failed to update profile: {e}"))
|
.map_err(|e| format!("Failed to update profile: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_profile_tags(profile_name: String, tags: Vec<String>) -> Result<BrowserProfile, String> {
|
||||||
|
let profile_manager = ProfileManager::instance();
|
||||||
|
profile_manager
|
||||||
|
.update_profile_tags(&profile_name, tags)
|
||||||
|
.map_err(|e| format!("Failed to update profile tags: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_browser_status(
|
pub async fn check_browser_status(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -1738,6 +1784,14 @@ pub fn is_browser_downloaded(browser_str: String, version: String) -> bool {
|
|||||||
browser_runner.is_browser_downloaded(&browser_str, &version)
|
browser_runner.is_browser_downloaded(&browser_str, &version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn get_all_tags() -> Result<Vec<String>, String> {
|
||||||
|
let browser_runner = BrowserRunner::instance();
|
||||||
|
browser_runner
|
||||||
|
.get_all_tags()
|
||||||
|
.map_err(|e| format!("Failed to get tags: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn check_browser_exists(browser_str: String, version: String) -> bool {
|
pub fn check_browser_exists(browser_str: String, version: String) -> bool {
|
||||||
// This is an alias for is_browser_downloaded to provide clearer semantics for auto-updates
|
// This is an alias for is_browser_downloaded to provide clearer semantics for auto-updates
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ mod tests {
|
|||||||
release_type: "stable".to_string(),
|
release_type: "stable".to_string(),
|
||||||
camoufox_config: None,
|
camoufox_config: None,
|
||||||
group_id: Some(group1.id.clone()),
|
group_id: Some(group1.id.clone()),
|
||||||
|
tags: Vec::new(),
|
||||||
},
|
},
|
||||||
crate::profile::BrowserProfile {
|
crate::profile::BrowserProfile {
|
||||||
id: uuid::Uuid::new_v4(),
|
id: uuid::Uuid::new_v4(),
|
||||||
@@ -412,6 +413,7 @@ mod tests {
|
|||||||
release_type: "stable".to_string(),
|
release_type: "stable".to_string(),
|
||||||
camoufox_config: None,
|
camoufox_config: None,
|
||||||
group_id: Some(group1.id.clone()),
|
group_id: Some(group1.id.clone()),
|
||||||
|
tags: Vec::new(),
|
||||||
},
|
},
|
||||||
crate::profile::BrowserProfile {
|
crate::profile::BrowserProfile {
|
||||||
id: uuid::Uuid::new_v4(),
|
id: uuid::Uuid::new_v4(),
|
||||||
@@ -424,6 +426,7 @@ mod tests {
|
|||||||
release_type: "stable".to_string(),
|
release_type: "stable".to_string(),
|
||||||
camoufox_config: None,
|
camoufox_config: None,
|
||||||
group_id: None, // Default group
|
group_id: None, // Default group
|
||||||
|
tags: Vec::new(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ mod proxy_manager;
|
|||||||
mod settings_manager;
|
mod settings_manager;
|
||||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||||
mod version_updater;
|
mod version_updater;
|
||||||
|
mod tag_manager;
|
||||||
|
|
||||||
extern crate lazy_static;
|
extern crate lazy_static;
|
||||||
|
|
||||||
@@ -68,6 +69,7 @@ use group_manager::{
|
|||||||
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
|
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
|
||||||
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
|
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
|
||||||
};
|
};
|
||||||
|
use tag_manager::TAG_MANAGER;
|
||||||
|
|
||||||
use geoip_downloader::GeoIPDownloader;
|
use geoip_downloader::GeoIPDownloader;
|
||||||
|
|
||||||
@@ -506,8 +508,10 @@ pub fn run() {
|
|||||||
fetch_browser_versions_cached_first,
|
fetch_browser_versions_cached_first,
|
||||||
fetch_browser_versions_with_count_cached_first,
|
fetch_browser_versions_with_count_cached_first,
|
||||||
get_downloaded_browser_versions,
|
get_downloaded_browser_versions,
|
||||||
|
get_all_tags,
|
||||||
get_browser_release_types,
|
get_browser_release_types,
|
||||||
update_profile_proxy,
|
update_profile_proxy,
|
||||||
|
update_profile_tags,
|
||||||
check_browser_status,
|
check_browser_status,
|
||||||
kill_browser_profile,
|
kill_browser_profile,
|
||||||
rename_profile,
|
rename_profile,
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ impl ProfileManager {
|
|||||||
release_type: release_type.to_string(),
|
release_type: release_type.to_string(),
|
||||||
camoufox_config: None,
|
camoufox_config: None,
|
||||||
group_id: group_id.clone(),
|
group_id: group_id.clone(),
|
||||||
|
tags: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
match camoufox_launcher
|
match camoufox_launcher
|
||||||
@@ -191,6 +192,7 @@ impl ProfileManager {
|
|||||||
release_type: release_type.to_string(),
|
release_type: release_type.to_string(),
|
||||||
camoufox_config: final_camoufox_config,
|
camoufox_config: final_camoufox_config,
|
||||||
group_id: group_id.clone(),
|
group_id: group_id.clone(),
|
||||||
|
tags: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save profile info
|
// Save profile info
|
||||||
@@ -284,6 +286,13 @@ impl ProfileManager {
|
|||||||
// Save profile with new name
|
// Save profile with new name
|
||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
|
// Keep tag suggestions up to date after name change (rebuild from all profiles)
|
||||||
|
let _ = crate::tag_manager::TAG_MANAGER
|
||||||
|
.lock()
|
||||||
|
.map(|tm| {
|
||||||
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
|
});
|
||||||
|
|
||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +330,13 @@ impl ProfileManager {
|
|||||||
|
|
||||||
println!("Profile '{profile_name}' deleted successfully");
|
println!("Profile '{profile_name}' deleted successfully");
|
||||||
|
|
||||||
|
// Rebuild tag suggestions after deletion
|
||||||
|
let _ = crate::tag_manager::TAG_MANAGER
|
||||||
|
.lock()
|
||||||
|
.map(|tm| {
|
||||||
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,9 +411,44 @@ impl ProfileManager {
|
|||||||
self.save_profile(&profile)?;
|
self.save_profile(&profile)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rebuild tag suggestions after group changes just in case
|
||||||
|
let _ = crate::tag_manager::TAG_MANAGER
|
||||||
|
.lock()
|
||||||
|
.map(|tm| {
|
||||||
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
|
});
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_profile_tags(
|
||||||
|
&self,
|
||||||
|
profile_name: &str,
|
||||||
|
tags: Vec<String>,
|
||||||
|
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||||
|
// Find the profile by name
|
||||||
|
let profiles = self.list_profiles()?;
|
||||||
|
let mut profile = profiles
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.name == profile_name)
|
||||||
|
.ok_or_else(|| format!("Profile {profile_name} not found"))?;
|
||||||
|
|
||||||
|
// Update tags as-is; preserve characters and order given by caller
|
||||||
|
profile.tags = tags;
|
||||||
|
|
||||||
|
// Save profile
|
||||||
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
|
// Update global tag suggestions from all profiles
|
||||||
|
let _ = crate::tag_manager::TAG_MANAGER
|
||||||
|
.lock()
|
||||||
|
.map(|tm| {
|
||||||
|
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(profile)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn delete_multiple_profiles(
|
pub fn delete_multiple_profiles(
|
||||||
&self,
|
&self,
|
||||||
profile_names: Vec<String>,
|
profile_names: Vec<String>,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ pub struct BrowserProfile {
|
|||||||
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
|
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub group_id: Option<String>, // Reference to profile group
|
pub group_id: Option<String>, // Reference to profile group
|
||||||
|
#[serde(default)]
|
||||||
|
pub tags: Vec<String>, // Free-form tags
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn default_release_type() -> String {
|
pub fn default_release_type() -> String {
|
||||||
|
|||||||
@@ -555,6 +555,7 @@ impl ProfileImporter {
|
|||||||
release_type: "stable".to_string(),
|
release_type: "stable".to_string(),
|
||||||
camoufox_config: None,
|
camoufox_config: None,
|
||||||
group_id: None,
|
group_id: None,
|
||||||
|
tags: Vec::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Save the profile metadata
|
// Save the profile metadata
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
use crate::profile::BrowserProfile;
|
||||||
|
use directories::BaseDirs;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||||
|
struct TagsData {
|
||||||
|
tags: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TagManager {
|
||||||
|
base_dirs: BaseDirs,
|
||||||
|
data_dir_override: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TagManager {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||||
|
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR").ok().map(PathBuf::from),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn with_data_dir_override(dir: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||||
|
data_dir_override: Some(dir.to_path_buf()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_tags_file_path(&self) -> PathBuf {
|
||||||
|
if let Some(dir) = &self.data_dir_override {
|
||||||
|
let mut override_path = dir.clone();
|
||||||
|
let _ = fs::create_dir_all(&override_path);
|
||||||
|
override_path.push("tags.json");
|
||||||
|
return override_path;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||||
|
path.push(if cfg!(debug_assertions) { "DonutBrowserDev" } else { "DonutBrowser" });
|
||||||
|
path.push("data");
|
||||||
|
path.push("tags.json");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_tags_data(&self) -> Result<TagsData, Box<dyn std::error::Error>> {
|
||||||
|
let file_path = self.get_tags_file_path();
|
||||||
|
if !file_path.exists() {
|
||||||
|
return Ok(TagsData::default());
|
||||||
|
}
|
||||||
|
let content = fs::read_to_string(file_path)?;
|
||||||
|
let data: TagsData = serde_json::from_str(&content)?;
|
||||||
|
Ok(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_tags_data(&self, data: &TagsData) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let file_path = self.get_tags_file_path();
|
||||||
|
if let Some(parent) = file_path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(data)?;
|
||||||
|
fs::write(file_path, json)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_tags(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
let mut all = self.load_tags_data()?.tags;
|
||||||
|
// Ensure deterministic order
|
||||||
|
all.sort();
|
||||||
|
all.dedup();
|
||||||
|
Ok(all)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn suggest_tags(&self, query: Option<&str>) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
let all = self.get_all_tags()?;
|
||||||
|
if let Some(q) = query {
|
||||||
|
let q_lower = q.to_lowercase();
|
||||||
|
Ok(
|
||||||
|
all
|
||||||
|
.into_iter()
|
||||||
|
.filter(|t| t.to_lowercase().contains(&q_lower))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Ok(all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rebuild_from_profiles(
|
||||||
|
&self,
|
||||||
|
profiles: &[BrowserProfile],
|
||||||
|
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||||
|
// Build a set of all tags currently used by any profile
|
||||||
|
let mut set: BTreeSet<String> = BTreeSet::new();
|
||||||
|
for profile in profiles {
|
||||||
|
for tag in &profile.tags {
|
||||||
|
// Store exactly as provided (no normalization) to preserve characters
|
||||||
|
set.insert(tag.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let combined: Vec<String> = set.into_iter().collect();
|
||||||
|
self.save_tags_data(&TagsData { tags: combined.clone() })?;
|
||||||
|
Ok(combined)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_tags(&self, tags: &[String]) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut data = self.load_tags_data()?;
|
||||||
|
data.tags.extend(tags.iter().cloned());
|
||||||
|
data.tags.sort();
|
||||||
|
data.tags.dedup();
|
||||||
|
self.save_tags_data(&data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
pub static ref TAG_MANAGER: std::sync::Mutex<TagManager> = std::sync::Mutex::new(TagManager::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -61,6 +61,7 @@ import { trimName } from "@/lib/name-utils";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { BrowserProfile, StoredProxy } from "@/types";
|
import type { BrowserProfile, StoredProxy } from "@/types";
|
||||||
import { LoadingButton } from "./loading-button";
|
import { LoadingButton } from "./loading-button";
|
||||||
|
import MultipleSelector, { type Option } from "./multiple-selector";
|
||||||
import { Input } from "./ui/input";
|
import { Input } from "./ui/input";
|
||||||
// Label no longer needed after removing dialog-based renaming
|
// Label no longer needed after removing dialog-based renaming
|
||||||
import { RippleButton } from "./ui/ripple";
|
import { RippleButton } from "./ui/ripple";
|
||||||
@@ -124,6 +125,19 @@ export function ProfilesDataTable({
|
|||||||
new Set(externalSelectedProfiles),
|
new Set(externalSelectedProfiles),
|
||||||
);
|
);
|
||||||
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
|
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
|
||||||
|
const [tagsOverrides, setTagsOverrides] = React.useState<
|
||||||
|
Record<string, string[]>
|
||||||
|
>({});
|
||||||
|
const [allTags, setAllTags] = React.useState<string[]>([]);
|
||||||
|
|
||||||
|
const loadAllTags = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const tags = await invoke<string[]>("get_all_tags");
|
||||||
|
setAllTags(tags);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load tags:", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleProxySelection = React.useCallback(
|
const handleProxySelection = React.useCallback(
|
||||||
async (profileName: string, proxyId: string | null) => {
|
async (profileName: string, proxyId: string | null) => {
|
||||||
@@ -187,6 +201,10 @@ export function ProfilesDataTable({
|
|||||||
unlisten = await listen("stored-proxies-changed", () => {
|
unlisten = await listen("stored-proxies-changed", () => {
|
||||||
void loadStoredProxies();
|
void loadStoredProxies();
|
||||||
});
|
});
|
||||||
|
// Also refresh tags on profile updates
|
||||||
|
await listen("profile-updated", () => {
|
||||||
|
void loadAllTags();
|
||||||
|
});
|
||||||
} catch (_err) {
|
} catch (_err) {
|
||||||
// Best-effort only
|
// Best-effort only
|
||||||
}
|
}
|
||||||
@@ -194,7 +212,7 @@ export function ProfilesDataTable({
|
|||||||
return () => {
|
return () => {
|
||||||
if (unlisten) unlisten();
|
if (unlisten) unlisten();
|
||||||
};
|
};
|
||||||
}, [browserState.isClient, loadStoredProxies]);
|
}, [browserState.isClient, loadStoredProxies, loadAllTags]);
|
||||||
|
|
||||||
// Automatically deselect profiles that become running, updating, launching, or stopping
|
// Automatically deselect profiles that become running, updating, launching, or stopping
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -928,6 +946,87 @@ export function ProfilesDataTable({
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
header: "Tags",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const profile = row.original;
|
||||||
|
const isRunning =
|
||||||
|
browserState.isClient && runningProfiles.has(profile.name);
|
||||||
|
const isLaunching = launchingProfiles.has(profile.name);
|
||||||
|
const isStopping = stoppingProfiles.has(profile.name);
|
||||||
|
const isBrowserUpdating = isUpdating(profile.browser);
|
||||||
|
const isDisabled =
|
||||||
|
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||||
|
|
||||||
|
const effectiveTags: string[] = Object.hasOwn(
|
||||||
|
tagsOverrides,
|
||||||
|
profile.name,
|
||||||
|
)
|
||||||
|
? tagsOverrides[profile.name]
|
||||||
|
: (profile.tags ?? []);
|
||||||
|
|
||||||
|
const valueOptions: Option[] = effectiveTags.map((t) => ({
|
||||||
|
value: t,
|
||||||
|
label: t,
|
||||||
|
}));
|
||||||
|
const defaultOptions: Option[] = (profile.tags ?? []).map((t) => ({
|
||||||
|
value: t,
|
||||||
|
label: t,
|
||||||
|
}));
|
||||||
|
const options: Option[] = allTags.map((t) => ({
|
||||||
|
value: t,
|
||||||
|
label: t,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onSearch = async (q: string) => {
|
||||||
|
const query = q.trim().toLowerCase();
|
||||||
|
if (!query) return options;
|
||||||
|
return options.filter((o) => o.value.toLowerCase().includes(query));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = async (opts: Option[]) => {
|
||||||
|
const newTags = opts.map((o) => o.value);
|
||||||
|
setTagsOverrides((prev) => ({ ...prev, [profile.name]: newTags }));
|
||||||
|
try {
|
||||||
|
await invoke<BrowserProfile>("update_profile_tags", {
|
||||||
|
profileName: profile.name,
|
||||||
|
tags: newTags,
|
||||||
|
});
|
||||||
|
// Optimistically merge new tags into suggestions list
|
||||||
|
setAllTags((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const t of newTags) next.add(t);
|
||||||
|
return Array.from(next).sort();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update tags:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"min-w-[220px]",
|
||||||
|
isDisabled && "opacity-60 pointer-events-none",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MultipleSelector
|
||||||
|
value={valueOptions}
|
||||||
|
defaultOptions={defaultOptions}
|
||||||
|
options={options}
|
||||||
|
onChange={(opts) => void handleChange(opts)}
|
||||||
|
onSearch={onSearch}
|
||||||
|
creatable
|
||||||
|
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
|
||||||
|
className="bg-transparent"
|
||||||
|
badgeClassName=""
|
||||||
|
inputProps={{ className: "py-1" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "settings",
|
id: "settings",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@@ -1011,6 +1110,8 @@ export function ProfilesDataTable({
|
|||||||
storedProxies,
|
storedProxies,
|
||||||
openProxySelectorFor,
|
openProxySelectorFor,
|
||||||
proxyOverrides,
|
proxyOverrides,
|
||||||
|
tagsOverrides,
|
||||||
|
allTags,
|
||||||
handleProxySelection,
|
handleProxySelection,
|
||||||
profileToRename,
|
profileToRename,
|
||||||
newProfileName,
|
newProfileName,
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Color from "color";
|
||||||
|
import { Slider } from "radix-ui";
|
||||||
|
import {
|
||||||
|
type ComponentProps,
|
||||||
|
createContext,
|
||||||
|
type HTMLAttributes,
|
||||||
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { LuPipette } from "react-icons/lu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ColorPickerContextValue {
|
||||||
|
hue: number;
|
||||||
|
saturation: number;
|
||||||
|
lightness: number;
|
||||||
|
alpha: number;
|
||||||
|
mode: string;
|
||||||
|
setHue: (hue: number) => void;
|
||||||
|
setSaturation: (saturation: number) => void;
|
||||||
|
setLightness: (lightness: number) => void;
|
||||||
|
setAlpha: (alpha: number) => void;
|
||||||
|
setMode: (mode: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ColorPickerContext = createContext<ColorPickerContextValue | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useColorPicker = () => {
|
||||||
|
const context = useContext(ColorPickerContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useColorPicker must be used within a ColorPickerProvider");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorPickerProps = HTMLAttributes<HTMLDivElement> & {
|
||||||
|
value?: Parameters<typeof Color>[0];
|
||||||
|
defaultValue?: Parameters<typeof Color>[0];
|
||||||
|
onChange?: (value: Parameters<typeof Color.rgb>[0]) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ColorPicker = ({
|
||||||
|
value,
|
||||||
|
defaultValue = "#000000",
|
||||||
|
onChange,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ColorPickerProps) => {
|
||||||
|
const selectedColor = Color(value);
|
||||||
|
const defaultColor = Color(defaultValue);
|
||||||
|
|
||||||
|
const [hue, setHue] = useState(
|
||||||
|
selectedColor.hue() || defaultColor.hue() || 0,
|
||||||
|
);
|
||||||
|
const [saturation, setSaturation] = useState(
|
||||||
|
selectedColor.saturationl() || defaultColor.saturationl() || 100,
|
||||||
|
);
|
||||||
|
const [lightness, setLightness] = useState(
|
||||||
|
selectedColor.lightness() || defaultColor.lightness() || 50,
|
||||||
|
);
|
||||||
|
const [alpha, setAlpha] = useState(
|
||||||
|
selectedColor.alpha() * 100 || defaultColor.alpha() * 100,
|
||||||
|
);
|
||||||
|
const [mode, setMode] = useState("hex");
|
||||||
|
|
||||||
|
// Update color when controlled value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
const color = Color.rgb(value).rgb().object();
|
||||||
|
|
||||||
|
setHue(color.r);
|
||||||
|
setSaturation(color.g);
|
||||||
|
setLightness(color.b);
|
||||||
|
setAlpha(color.a);
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
// Notify parent of changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (onChange) {
|
||||||
|
const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100);
|
||||||
|
const rgba = color.rgb().array();
|
||||||
|
|
||||||
|
onChange([rgba[0], rgba[1], rgba[2], alpha / 100]);
|
||||||
|
}
|
||||||
|
}, [hue, saturation, lightness, alpha, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ColorPickerContext.Provider
|
||||||
|
value={{
|
||||||
|
hue,
|
||||||
|
saturation,
|
||||||
|
lightness,
|
||||||
|
alpha,
|
||||||
|
mode,
|
||||||
|
setHue,
|
||||||
|
setSaturation,
|
||||||
|
setLightness,
|
||||||
|
setAlpha,
|
||||||
|
setMode,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn("flex flex-col gap-4 size-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</ColorPickerContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorPickerSelectionProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const ColorPickerSelection = memo(
|
||||||
|
({ className, ...props }: ColorPickerSelectionProps) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [positionX, setPositionX] = useState(0);
|
||||||
|
const [positionY, setPositionY] = useState(0);
|
||||||
|
const { hue, setSaturation, setLightness } = useColorPicker();
|
||||||
|
|
||||||
|
const backgroundGradient = useMemo(() => {
|
||||||
|
return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)),
|
||||||
|
hsl(${hue}, 100%, 50%)`;
|
||||||
|
}, [hue]);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(event: PointerEvent) => {
|
||||||
|
if (!(isDragging && containerRef.current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rect = containerRef.current.getBoundingClientRect();
|
||||||
|
const x = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, (event.clientX - rect.left) / rect.width),
|
||||||
|
);
|
||||||
|
const y = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(1, (event.clientY - rect.top) / rect.height),
|
||||||
|
);
|
||||||
|
setPositionX(x);
|
||||||
|
setPositionY(y);
|
||||||
|
setSaturation(x * 100);
|
||||||
|
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x);
|
||||||
|
const lightness = topLightness * (1 - y);
|
||||||
|
|
||||||
|
setLightness(lightness);
|
||||||
|
},
|
||||||
|
[isDragging, setSaturation, setLightness],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePointerUp = () => setIsDragging(false);
|
||||||
|
|
||||||
|
if (isDragging) {
|
||||||
|
window.addEventListener("pointermove", handlePointerMove);
|
||||||
|
window.addEventListener("pointerup", handlePointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove);
|
||||||
|
window.removeEventListener("pointerup", handlePointerUp);
|
||||||
|
};
|
||||||
|
}, [isDragging, handlePointerMove]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("relative rounded size-full cursor-crosshair", className)}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragging(true);
|
||||||
|
handlePointerMove(e.nativeEvent);
|
||||||
|
}}
|
||||||
|
ref={containerRef}
|
||||||
|
style={{
|
||||||
|
background: backgroundGradient,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="absolute w-4 h-4 rounded-full border-2 border-white -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||||
|
style={{
|
||||||
|
left: `${positionX * 100}%`,
|
||||||
|
top: `${positionY * 100}%`,
|
||||||
|
boxShadow: "0 0 0 1px rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ColorPickerSelection.displayName = "ColorPickerSelection";
|
||||||
|
|
||||||
|
export type ColorPickerHueProps = ComponentProps<typeof Slider.Root>;
|
||||||
|
|
||||||
|
export const ColorPickerHue = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ColorPickerHueProps) => {
|
||||||
|
const { hue, setHue } = useColorPicker();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slider.Root
|
||||||
|
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||||
|
max={360}
|
||||||
|
onValueChange={([hue]) => setHue(hue)}
|
||||||
|
step={1}
|
||||||
|
value={[hue]}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Slider.Track className="relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]">
|
||||||
|
<Slider.Range className="absolute h-full" />
|
||||||
|
</Slider.Track>
|
||||||
|
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</Slider.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorPickerAlphaProps = ComponentProps<typeof Slider.Root>;
|
||||||
|
|
||||||
|
export const ColorPickerAlpha = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ColorPickerAlphaProps) => {
|
||||||
|
const { alpha, setAlpha } = useColorPicker();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slider.Root
|
||||||
|
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||||
|
max={100}
|
||||||
|
onValueChange={([alpha]) => setAlpha(alpha)}
|
||||||
|
step={1}
|
||||||
|
value={[alpha]}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Slider.Track
|
||||||
|
className="relative my-0.5 h-3 w-full grow rounded-full"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
|
||||||
|
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
|
||||||
|
</Slider.Track>
|
||||||
|
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||||
|
</Slider.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorPickerEyeDropperProps = ComponentProps<typeof Button>;
|
||||||
|
|
||||||
|
export const ColorPickerEyeDropper = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ColorPickerEyeDropperProps) => {
|
||||||
|
const { setHue, setSaturation, setLightness, setAlpha } = useColorPicker();
|
||||||
|
|
||||||
|
const handleEyeDropper = async () => {
|
||||||
|
try {
|
||||||
|
// @ts-expect-error - EyeDropper API is experimental
|
||||||
|
const eyeDropper = new EyeDropper();
|
||||||
|
const result = await eyeDropper.open();
|
||||||
|
const color = Color(result.sRGBHex);
|
||||||
|
const [h, s, l] = color.hsl().array();
|
||||||
|
|
||||||
|
setHue(h);
|
||||||
|
setSaturation(s);
|
||||||
|
setLightness(l);
|
||||||
|
setAlpha(100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("EyeDropper failed:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn("shrink-0 text-muted-foreground", className)}
|
||||||
|
onClick={handleEyeDropper}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<LuPipette size={16} />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorPickerOutputProps = ComponentProps<typeof SelectTrigger>;
|
||||||
|
|
||||||
|
const formats = ["hex", "rgb", "css", "hsl"];
|
||||||
|
|
||||||
|
export const ColorPickerOutput = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ColorPickerOutputProps) => {
|
||||||
|
const { mode, setMode } = useColorPicker();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select onValueChange={setMode} value={mode}>
|
||||||
|
<SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}>
|
||||||
|
<SelectValue placeholder="Mode" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{formats.map((format) => (
|
||||||
|
<SelectItem className="text-xs" key={format} value={format}>
|
||||||
|
{format.toUpperCase()}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type PercentageInputProps = ComponentProps<typeof Input>;
|
||||||
|
|
||||||
|
const PercentageInput = ({ className, ...props }: PercentageInputProps) => {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
type="text"
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-[3.25rem] rounded-l-none bg-secondary px-2 text-xs shadow-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="absolute right-2 top-1/2 text-xs -translate-y-1/2 text-muted-foreground">
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ColorPickerFormatProps = HTMLAttributes<HTMLDivElement>;
|
||||||
|
|
||||||
|
export const ColorPickerFormat = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: ColorPickerFormatProps) => {
|
||||||
|
const { hue, saturation, lightness, alpha, mode } = useColorPicker();
|
||||||
|
const color = Color.hsl(hue, saturation, lightness, alpha / 100);
|
||||||
|
|
||||||
|
if (mode === "hex") {
|
||||||
|
const hex = color.hex();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex relative items-center -space-x-px w-full rounded-md shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
className="px-2 h-8 text-xs rounded-r-none shadow-none bg-secondary"
|
||||||
|
readOnly
|
||||||
|
type="text"
|
||||||
|
value={hex}
|
||||||
|
/>
|
||||||
|
<PercentageInput value={alpha} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "rgb") {
|
||||||
|
const rgb = color
|
||||||
|
.rgb()
|
||||||
|
.array()
|
||||||
|
.map((value) => Math.round(value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center -space-x-px rounded-md shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{rgb.map((value, index) => (
|
||||||
|
<Input
|
||||||
|
className={cn(
|
||||||
|
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
|
||||||
|
index && "rounded-l-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
key={`rgb-${value.toString()}`}
|
||||||
|
readOnly
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<PercentageInput value={alpha} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "css") {
|
||||||
|
const rgb = color
|
||||||
|
.rgb()
|
||||||
|
.array()
|
||||||
|
.map((value) => Math.round(value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("w-full rounded-md shadow-sm", className)} {...props}>
|
||||||
|
<Input
|
||||||
|
className="px-2 w-full h-8 text-xs shadow-none bg-secondary"
|
||||||
|
readOnly
|
||||||
|
type="text"
|
||||||
|
value={`rgba(${rgb.join(", ")}, ${alpha}%)`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "hsl") {
|
||||||
|
const hsl = color
|
||||||
|
.hsl()
|
||||||
|
.array()
|
||||||
|
.map((value) => Math.round(value));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center -space-x-px rounded-md shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{hsl.map((value, index) => (
|
||||||
|
<Input
|
||||||
|
className={cn(
|
||||||
|
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
|
||||||
|
index && "rounded-l-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
key={`hsl-${value.toString()}`}
|
||||||
|
readOnly
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<PercentageInput value={alpha} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ export interface BrowserProfile {
|
|||||||
release_type: string; // "stable" or "nightly"
|
release_type: string; // "stable" or "nightly"
|
||||||
camoufox_config?: CamoufoxConfig; // Camoufox configuration
|
camoufox_config?: CamoufoxConfig; // Camoufox configuration
|
||||||
group_id?: string; // Reference to profile group
|
group_id?: string; // Reference to profile group
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StoredProxy {
|
export interface StoredProxy {
|
||||||
|
|||||||
Reference in New Issue
Block a user