Compare commits

..
Author SHA1 Message Date
zhom a6af568d9e feat: custom theme 2025-08-15 00:04:31 +04:00
zhom 7c2ed1e0fc refactor: tags 2025-08-15 00:04:10 +04:00
zhom 334f894e68 chore: version bump 2025-08-14 23:06:17 +04:00
zhom a77b733a31 chore: version bump 2025-08-14 22:39:16 +04:00
zhom c10c3b0f95 refactor: creation modal cleanup 2025-08-14 22:37:03 +04:00
zhom 4b16341401 refactor: integrage rename of profile into row 2025-08-14 22:35:44 +04:00
zhom 016d423d2c refactor: revert global listener 2025-08-14 22:01:14 +04:00
zhom 0596cc4009 style: fix toast width 2025-08-14 21:46:36 +04:00
22 changed files with 2461 additions and 213 deletions
+5 -1
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.9.3",
"version": "0.9.4",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
@@ -46,9 +46,12 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.0",
"lucide-react": "^0.539.0",
"motion": "^12.23.12",
"next": "^15.4.6",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-icons": "^5.5.0",
@@ -60,6 +63,7 @@
"@biomejs/biome": "2.1.4",
"@tailwindcss/postcss": "^4.1.11",
"@tauri-apps/cli": "^2.7.1",
"@types/color": "^4.2.0",
"@types/node": "^24.2.1",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
+1197
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1021,7 +1021,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.9.3"
version = "0.9.4"
dependencies = [
"async-trait",
"base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.9.3"
version = "0.9.4"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
+1
View File
@@ -520,6 +520,7 @@ mod tests {
release_type: "stable".to_string(),
camoufox_config: None,
group_id: None,
tags: Vec::new(),
}
}
+49 -2
View File
@@ -140,12 +140,23 @@ impl BrowserRunner {
pub fn save_profile(&self, profile: &BrowserProfile) -> Result<(), Box<dyn std::error::Error>> {
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>> {
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(
@@ -251,6 +262,10 @@ impl BrowserRunner {
// Save the 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!(
"Updated profile with process info: {}",
updated_profile.name
@@ -450,6 +465,9 @@ impl BrowserRunner {
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
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)
if profile.proxy_id.is_some()
@@ -815,6 +833,11 @@ impl BrowserRunner {
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(())
}
@@ -1442,6 +1465,11 @@ impl BrowserRunner {
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)]
@@ -1603,6 +1631,17 @@ pub async fn update_profile_proxy(
.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]
pub async fn check_browser_status(
app_handle: tauri::AppHandle,
@@ -1738,6 +1777,14 @@ pub fn is_browser_downloaded(browser_str: String, version: String) -> bool {
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]
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
+3
View File
@@ -400,6 +400,7 @@ mod tests {
release_type: "stable".to_string(),
camoufox_config: None,
group_id: Some(group1.id.clone()),
tags: Vec::new(),
},
crate::profile::BrowserProfile {
id: uuid::Uuid::new_v4(),
@@ -412,6 +413,7 @@ mod tests {
release_type: "stable".to_string(),
camoufox_config: None,
group_id: Some(group1.id.clone()),
tags: Vec::new(),
},
crate::profile::BrowserProfile {
id: uuid::Uuid::new_v4(),
@@ -424,6 +426,7 @@ mod tests {
release_type: "stable".to_string(),
camoufox_config: None,
group_id: None, // Default group
tags: Vec::new(),
},
];
+5 -4
View File
@@ -26,6 +26,7 @@ mod profile_importer;
mod proxy_manager;
mod settings_manager;
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
mod tag_manager;
mod version_updater;
extern crate lazy_static;
@@ -34,10 +35,10 @@ use browser_runner::{
check_browser_exists, check_browser_status, check_missing_binaries, check_missing_geoip_database,
create_browser_profile_new, delete_profile, download_browser, ensure_all_binaries_exist,
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
fetch_browser_versions_with_count_cached_first, get_downloaded_browser_versions,
fetch_browser_versions_with_count_cached_first, get_all_tags, get_downloaded_browser_versions,
get_supported_browsers, is_browser_supported_on_platform, kill_browser_profile,
launch_browser_profile, list_browser_profiles, rename_profile, update_camoufox_config,
update_profile_proxy,
update_profile_proxy, update_profile_tags,
};
use settings_manager::{
@@ -62,8 +63,6 @@ use app_auto_updater::{
use profile_importer::{detect_existing_profiles, import_browser_profile};
// use theme_detector::get_system_theme;
use group_manager::{
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
@@ -506,8 +505,10 @@ pub fn run() {
fetch_browser_versions_cached_first,
fetch_browser_versions_with_count_cached_first,
get_downloaded_browser_versions,
get_all_tags,
get_browser_release_types,
update_profile_proxy,
update_profile_tags,
check_browser_status,
kill_browser_profile,
rename_profile,
+43
View File
@@ -151,6 +151,7 @@ impl ProfileManager {
release_type: release_type.to_string(),
camoufox_config: None,
group_id: group_id.clone(),
tags: Vec::new(),
};
match camoufox_launcher
@@ -191,6 +192,7 @@ impl ProfileManager {
release_type: release_type.to_string(),
camoufox_config: final_camoufox_config,
group_id: group_id.clone(),
tags: Vec::new(),
};
// Save profile info
@@ -284,6 +286,11 @@ impl ProfileManager {
// Save profile with new name
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)
}
@@ -321,6 +328,11 @@ impl ProfileManager {
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(())
}
@@ -395,9 +407,40 @@ impl ProfileManager {
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(())
}
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(
&self,
profile_names: Vec<String>,
+2
View File
@@ -20,6 +20,8 @@ pub struct BrowserProfile {
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
#[serde(default)]
pub group_id: Option<String>, // Reference to profile group
#[serde(default)]
pub tags: Vec<String>, // Free-form tags
}
pub fn default_release_type() -> String {
+1
View File
@@ -555,6 +555,7 @@ impl ProfileImporter {
release_type: "stable".to_string(),
camoufox_config: None,
group_id: None,
tags: Vec::new(),
};
// Save the profile metadata
+4
View File
@@ -27,6 +27,8 @@ pub struct AppSettings {
pub set_as_default_browser: bool,
#[serde(default = "default_theme")]
pub theme: String, // "light", "dark", or "system"
#[serde(default)]
pub custom_theme: Option<std::collections::HashMap<String, String>>, // CSS var name -> value (e.g., "--background": "#1a1b26")
}
fn default_theme() -> String {
@@ -38,6 +40,7 @@ impl Default for AppSettings {
Self {
set_as_default_browser: false,
theme: "system".to_string(),
custom_theme: None,
}
}
}
@@ -321,6 +324,7 @@ mod tests {
let test_settings = AppSettings {
set_as_default_browser: true,
theme: "dark".to_string(),
custom_theme: None,
};
// Save settings
+105
View File
@@ -0,0 +1,105 @@
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 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)
}
}
lazy_static::lazy_static! {
pub static ref TAG_MANAGER: std::sync::Mutex<TagManager> = std::sync::Mutex::new(TagManager::new());
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut Browser",
"version": "0.9.3",
"version": "0.9.4",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm dev",
+3
View File
@@ -21,6 +21,7 @@ import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications"
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
import { useVersionUpdater } from "@/hooks/use-version-updater";
import { showErrorToast, showToast } from "@/lib/toast-utils";
import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types";
@@ -40,6 +41,8 @@ interface PendingUrl {
}
export default function Home() {
// Mount global version update listener/toasts
useVersionUpdater();
const [isInitializing, setIsInitializing] = useState(true);
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
const [error, setError] = useState<string | null>(null);
+23 -18
View File
@@ -108,7 +108,7 @@ export function CreateProfileDialog({
const handleTabChange = (value: string) => {
if (value === "regular") {
setSelectedBrowser(null);
setSelectedBrowser("firefox");
} else if (value === "anti-detect") {
setSelectedBrowser("camoufox");
}
@@ -179,6 +179,8 @@ export function CreateProfileDialog({
{ browserStr: browser },
);
await loadDownloadedVersions(browser);
// Only update state if this browser is still the one we're loading
if (loadingBrowserRef.current === browser) {
// Filter to enforce stable-only creation, except Firefox Developer (nightly-only)
@@ -198,12 +200,29 @@ export function CreateProfileDialog({
filtered.stable = rawReleaseTypes.stable;
setReleaseTypes(filtered);
}
// Load downloaded versions for this browser
await loadDownloadedVersions(browser);
}
} catch (error) {
console.error(`Failed to load release types for ${browser}:`, error);
// Fallback: still load downloaded versions and derive release type from them if possible
try {
const downloaded = await loadDownloadedVersions(browser);
if (loadingBrowserRef.current === browser && downloaded.length > 0) {
const latest = downloaded[0];
const fallback: BrowserReleaseTypes = {};
if (browser === "firefox-developer") {
fallback.nightly = latest;
} else {
fallback.stable = latest;
}
setReleaseTypes(fallback);
}
} catch (e) {
console.error(
`Failed to load downloaded versions for ${browser}:`,
e,
);
}
} finally {
// Clear loading state only if we're still loading this browser
if (loadingBrowserRef.current === browser) {
@@ -387,20 +406,6 @@ export function CreateProfileDialog({
isBrowserVersionAvailable,
]);
useEffect(() => {
console.log(
selectedBrowser,
selectedBrowser && isBrowserCurrentlyDownloading(selectedBrowser),
selectedBrowser && isBrowserVersionAvailable(selectedBrowser),
selectedBrowser && getBestAvailableVersion(selectedBrowser),
);
}, [
selectedBrowser,
isBrowserCurrentlyDownloading,
isBrowserVersionAvailable,
getBestAvailableVersion,
]);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="w-full max-h-[90vh] flex flex-col">
+1 -1
View File
@@ -168,7 +168,7 @@ export function UnifiedToast(props: ToastProps) {
const progress = "progress" in props ? props.progress : undefined;
return (
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="flex items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold leading-tight text-foreground">
+364 -81
View File
@@ -15,6 +15,7 @@ import { CiCircleCheck } from "react-icons/ci";
import { IoEllipsisHorizontal } from "react-icons/io5";
import { LuCheck, LuChevronDown, LuChevronUp } from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -25,13 +26,6 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -67,10 +61,186 @@ import {
import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, StoredProxy } from "@/types";
import { LoadingButton } from "./loading-button";
import MultipleSelector, { type Option } from "./multiple-selector";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
import { RippleButton } from "./ui/ripple";
const TagsCell: React.FC<{
profile: BrowserProfile;
isDisabled: boolean;
tagsOverrides: Record<string, string[]>;
allTags: string[];
setAllTags: React.Dispatch<React.SetStateAction<string[]>>;
openTagsEditorFor: string | null;
setOpenTagsEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
setTagsOverrides: React.Dispatch<
React.SetStateAction<Record<string, string[]>>
>;
}> = ({
profile,
isDisabled,
tagsOverrides,
allTags,
setAllTags,
openTagsEditorFor,
setOpenTagsEditorFor,
setTagsOverrides,
}) => {
const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.name)
? tagsOverrides[profile.name]
: (profile.tags ?? []);
const valueOptions: Option[] = React.useMemo(
() => effectiveTags.map((t) => ({ value: t, label: t })),
[effectiveTags],
);
const allOptions: Option[] = React.useMemo(
() => allTags.map((t) => ({ value: t, label: t })),
[allTags],
);
const onSearch = React.useCallback(
async (q: string): Promise<Option[]> => {
const query = q.trim().toLowerCase();
if (!query) return allOptions;
return allOptions.filter((o) => o.value.toLowerCase().includes(query));
},
[allOptions],
);
const handleChange = React.useCallback(
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,
});
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);
}
},
[profile.name, setAllTags, setTagsOverrides],
);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const [visibleCount, setVisibleCount] = React.useState<number>(
effectiveTags.length,
);
React.useLayoutEffect(() => {
if (openTagsEditorFor === profile.name) return;
const container = containerRef.current;
if (!container) return;
const compute = () => {
const available = container.clientWidth;
if (available <= 0) return;
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (!ctx) return;
const style = window.getComputedStyle(container);
const font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
ctx.font = font;
const padding = 16;
const gap = 4;
let used = 0;
let count = 0;
for (let i = 0; i < effectiveTags.length; i++) {
const text = effectiveTags[i];
const width = Math.ceil(ctx.measureText(text).width) + padding;
const remaining = effectiveTags.length - (i + 1);
let extra = 0;
if (remaining > 0) {
const plusText = `+${remaining}`;
extra = Math.ceil(ctx.measureText(plusText).width) + padding;
}
const nextUsed =
used +
(used > 0 ? gap : 0) +
width +
(remaining > 0 ? gap + extra : 0);
if (nextUsed <= available) {
used += (used > 0 ? gap : 0) + width;
count = i + 1;
} else {
break;
}
}
setVisibleCount(count);
};
compute();
const ro = new ResizeObserver(() => compute());
ro.observe(container);
return () => ro.disconnect();
}, [effectiveTags, openTagsEditorFor, profile.name]);
if (openTagsEditorFor !== profile.name) {
const hiddenCount = Math.max(0, effectiveTags.length - visibleCount);
return (
<div className="w-48 h-full cursor-pointer">
<div
ref={containerRef}
className={cn(
"flex items-center gap-1 overflow-hidden",
isDisabled && "opacity-60",
)}
role="button"
tabIndex={0}
onClick={() => {
if (!isDisabled) setOpenTagsEditorFor(profile.name);
}}
onKeyDown={(e) => {
if (!isDisabled && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
setOpenTagsEditorFor(profile.name);
}
}}
onKeyUp={() => {}}
>
{effectiveTags.slice(0, visibleCount).map((t) => (
<Badge key={t} variant="secondary" className="px-2 py-0 text-xs">
{t}
</Badge>
))}
{hiddenCount > 0 && (
<Badge variant="outline" className="px-2 py-0 text-xs">
+{hiddenCount}
</Badge>
)}
</div>
</div>
);
}
return (
<div className={cn("w-48", isDisabled && "opacity-60 pointer-events-none")}>
<MultipleSelector
value={valueOptions}
options={allOptions}
onChange={(opts) => void handleChange(opts)}
onSearch={onSearch}
creatable
placeholder={effectiveTags.length === 0 ? "Add tags" : ""}
className="bg-transparent"
badgeClassName=""
inputProps={{
className: "py-1",
onKeyDown: (e) => {
if (e.key === "Escape") setOpenTagsEditorFor(null);
},
}}
/>
</div>
);
};
interface ProfilesDataTableProps {
data: BrowserProfile[];
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
@@ -107,6 +277,8 @@ export function ProfilesDataTable({
React.useState<BrowserProfile | null>(null);
const [newProfileName, setNewProfileName] = React.useState("");
const [renameError, setRenameError] = React.useState<string | null>(null);
const [isRenamingSaving, setIsRenamingSaving] = React.useState(false);
const renameContainerRef = React.useRef<HTMLDivElement | null>(null);
const [profileToDelete, setProfileToDelete] =
React.useState<BrowserProfile | null>(null);
const [isDeleting, setIsDeleting] = React.useState(false);
@@ -128,6 +300,22 @@ export function ProfilesDataTable({
new Set(externalSelectedProfiles),
);
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
const [tagsOverrides, setTagsOverrides] = React.useState<
Record<string, string[]>
>({});
const [allTags, setAllTags] = React.useState<string[]>([]);
const [openTagsEditorFor, setOpenTagsEditorFor] = React.useState<
string | null
>(null);
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(
async (profileName: string, proxyId: string | null) => {
@@ -191,6 +379,10 @@ export function ProfilesDataTable({
unlisten = await listen("stored-proxies-changed", () => {
void loadStoredProxies();
});
// Also refresh tags on profile updates
await listen("profile-updated", () => {
void loadAllTags();
});
} catch (_err) {
// Best-effort only
}
@@ -198,7 +390,7 @@ export function ProfilesDataTable({
return () => {
if (unlisten) unlisten();
};
}, [browserState.isClient, loadStoredProxies]);
}, [browserState.isClient, loadStoredProxies, loadAllTags]);
// Automatically deselect profiles that become running, updating, launching, or stopping
React.useEffect(() => {
@@ -265,10 +457,11 @@ export function ProfilesDataTable({
[browserState.isClient, sorting, updateSorting],
);
const handleRename = async () => {
const handleRename = React.useCallback(async () => {
if (!profileToRename || !newProfileName.trim()) return;
try {
setIsRenamingSaving(true);
await onRenameProfile(profileToRename.name, newProfileName.trim());
setProfileToRename(null);
setNewProfileName("");
@@ -277,8 +470,31 @@ export function ProfilesDataTable({
setRenameError(
error instanceof Error ? error.message : "Failed to rename profile",
);
} finally {
setIsRenamingSaving(false);
}
};
}, [profileToRename, newProfileName, onRenameProfile]);
// Cancel inline rename on outside click
React.useEffect(() => {
if (!profileToRename) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null;
if (
target &&
renameContainerRef.current &&
!renameContainerRef.current.contains(target)
) {
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [profileToRename]);
const handleDelete = async () => {
if (!profileToDelete) return;
@@ -330,6 +546,12 @@ export function ProfilesDataTable({
[filteredData, browserState.canSelectProfile, onSelectedProfilesChange],
);
React.useEffect(() => {
if (browserState.isClient) {
void loadAllTags();
}
}, [browserState.isClient, loadAllTags]);
// Handle checkbox change
const handleCheckboxChange = React.useCallback(
(profileName: string, checked: boolean) => {
@@ -622,20 +844,130 @@ export function ProfilesDataTable({
enableSorting: true,
sortingFn: "alphanumeric",
cell: ({ row }) => {
const profile = row.original as BrowserProfile;
const rawName: string = row.getValue("name");
const name = getBrowserDisplayName(rawName);
const isEditing = profileToRename?.name === profile.name;
if (name.length < 20) {
return <div className="font-medium text-left">{name}</div>;
if (isEditing) {
const isSaveDisabled =
isRenamingSaving ||
newProfileName.trim().length === 0 ||
newProfileName.trim() === profile.name;
return (
<div
ref={renameContainerRef}
className="overflow-visible relative"
>
<Input
autoFocus
value={newProfileName}
onChange={(e) => {
setNewProfileName(e.target.value);
if (renameError) setRenameError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
void handleRename();
} else if (e.key === "Escape") {
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
}
}}
className="inline-block w-full"
/>
<div className="flex absolute right-0 top-full z-50 gap-1 translate-y-[30%] bg-primary-foreground opacity-100">
<LoadingButton
isLoading={isRenamingSaving}
size="sm"
variant="default"
disabled={isSaveDisabled}
className="cursor-pointer"
onClick={() => void handleRename()}
>
Save
</LoadingButton>
</div>
</div>
);
}
const display =
name.length < 20 ? (
<div className="font-medium text-left">{name}</div>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span>{trimName(name, 20)}</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
);
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;
return (
<Tooltip>
<TooltipTrigger asChild>
<span>{trimName(name, 20)}</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
<button
type="button"
className={cn(
"p-2 mr-auto w-full text-left bg-transparent rounded border-none",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
)}
onClick={() => {
if (isDisabled) return;
setProfileToRename(profile);
setNewProfileName(profile.name);
setRenameError(null);
}}
onKeyDown={(e) => {
if (isDisabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setProfileToRename(profile);
setNewProfileName(profile.name);
setRenameError(null);
}
}}
>
{display}
</button>
);
},
},
{
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;
return (
<TagsCell
profile={profile}
isDisabled={isDisabled}
tagsOverrides={tagsOverrides}
allTags={allTags}
setAllTags={setAllTags}
openTagsEditorFor={openTagsEditorFor}
setOpenTagsEditorFor={setOpenTagsEditorFor}
setTagsOverrides={setTagsOverrides}
/>
);
},
},
@@ -750,7 +1082,7 @@ export function ProfilesDataTable({
<PopoverTrigger asChild>
<span
className={cn(
"flex gap-2 items-center px-1 rounded",
"flex gap-2 items-center p-2 rounded",
isDisabled
? "opacity-60 cursor-not-allowed pointer-events-none"
: "cursor-pointer hover:bg-accent/50",
@@ -872,15 +1204,7 @@ export function ProfilesDataTable({
Configure Fingerprint
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
setProfileToRename(profile);
setNewProfileName(profile.name);
}}
disabled={isDisabled}
>
Rename
</DropdownMenuItem>
{/* Rename removed from menu; inline on name click */}
<DropdownMenuItem
onClick={() => {
setProfileToDelete(profile);
@@ -916,7 +1240,15 @@ export function ProfilesDataTable({
storedProxies,
openProxySelectorFor,
proxyOverrides,
tagsOverrides,
allTags,
handleProxySelection,
profileToRename,
newProfileName,
renameError,
isRenamingSaving,
handleRename,
openTagsEditorFor,
],
);
@@ -944,7 +1276,7 @@ export function ProfilesDataTable({
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} className="overflow-visible">
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
@@ -966,10 +1298,10 @@ export function ProfilesDataTable({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="hover:bg-accent/50"
className="overflow-visible hover:bg-accent/50"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
<TableCell key={cell.id} className="overflow-visible">
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
@@ -991,55 +1323,6 @@ export function ProfilesDataTable({
</TableBody>
</Table>
</ScrollArea>
<Dialog
open={profileToRename !== null}
onOpenChange={(open) => {
if (!open) {
setProfileToRename(null);
setNewProfileName("");
setRenameError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename Profile</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 gap-4 items-center">
<Label htmlFor="name" className="text-right">
Name
</Label>
<Input
id="name"
value={newProfileName}
onChange={(e) => {
setNewProfileName(e.target.value);
}}
className="col-span-3"
/>
</div>
{renameError && (
<p className="text-sm text-red-600">{renameError}</p>
)}
</div>
<DialogFooter>
<RippleButton
variant="outline"
onClick={() => {
setProfileToRename(null);
}}
>
Cancel
</RippleButton>
<RippleButton onClick={() => void handleRename()}>
Save
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
<DeleteConfirmationDialog
isOpen={profileToDelete !== null}
onClose={() => setProfileToDelete(null)}
+156 -98
View File
@@ -1,13 +1,21 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import Color from "color";
import { useTheme } from "next-themes";
import { useCallback, useEffect, useState } from "react";
import { BsCamera, BsMic } from "react-icons/bs";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import {
ColorPicker,
ColorPickerAlpha,
ColorPickerEyeDropper,
ColorPickerFormat,
ColorPickerHue,
ColorPickerOutput,
ColorPickerSelection,
} from "@/components/ui/color-picker";
import {
Dialog,
DialogContent,
@@ -16,6 +24,11 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -25,18 +38,13 @@ import {
} from "@/components/ui/select";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import {
dismissToast,
showErrorToast,
showSuccessToast,
showUnifiedVersionUpdateToast,
} from "@/lib/toast-utils";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
set_as_default_browser: boolean;
theme: string;
custom_theme?: Record<string, string>;
}
interface PermissionInfo {
@@ -45,14 +53,7 @@ interface PermissionInfo {
description: string;
}
interface VersionUpdateProgress {
current_browser: string;
total_browsers: number;
completed_browsers: number;
new_versions_found: number;
browser_new_versions: number;
status: string; // "updating", "completed", "error"
}
// Version update progress toasts are handled globally via useVersionUpdater
interface SettingsDialogProps {
isOpen: boolean;
@@ -63,10 +64,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const [settings, setSettings] = useState<AppSettings>({
set_as_default_browser: false,
theme: "system",
custom_theme: undefined,
});
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
set_as_default_browser: false,
theme: "system",
custom_theme: undefined,
});
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
const [isLoading, setIsLoading] = useState(false);
@@ -123,12 +126,60 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
return "Access to camera for browser applications";
}
}, []);
const TOKYO_NIGHT_DEFAULTS: Record<string, string> = {
"--background": "#1a1b26",
"--foreground": "#c0caf5",
"--card": "#24283b",
"--card-foreground": "#c0caf5",
"--popover": "#24283b",
"--popover-foreground": "#c0caf5",
"--primary": "#7aa2f7",
"--primary-foreground": "#1a1b26",
"--secondary": "#2ac3de",
"--secondary-foreground": "#1a1b26",
"--muted": "#3b4261",
"--muted-foreground": "#a9b1d6",
"--accent": "#bb9af7",
"--accent-foreground": "#1a1b26",
"--destructive": "#f7768e",
"--destructive-foreground": "#1a1b26",
"--border": "#3b4261",
};
const THEME_VARIABLES: Array<{ key: string; label: string }> = [
{ key: "--background", label: "Background" },
{ key: "--foreground", label: "Foreground" },
{ key: "--card", label: "Card" },
{ key: "--card-foreground", label: "Card FG" },
{ key: "--popover", label: "Popover" },
{ key: "--popover-foreground", label: "Popover FG" },
{ key: "--primary", label: "Primary" },
{ key: "--primary-foreground", label: "Primary FG" },
{ key: "--secondary", label: "Secondary" },
{ key: "--secondary-foreground", label: "Secondary FG" },
{ key: "--muted", label: "Muted" },
{ key: "--muted-foreground", label: "Muted FG" },
{ key: "--accent", label: "Accent" },
{ key: "--accent-foreground", label: "Accent FG" },
{ key: "--destructive", label: "Destructive" },
{ key: "--destructive-foreground", label: "Destructive FG" },
{ key: "--border", label: "Border" },
];
const loadSettings = useCallback(async () => {
setIsLoading(true);
try {
const appSettings = await invoke<AppSettings>("get_app_settings");
setSettings(appSettings);
setOriginalSettings(appSettings);
const merged: AppSettings = {
...appSettings,
custom_theme:
appSettings.custom_theme &&
Object.keys(appSettings.custom_theme).length > 0
? appSettings.custom_theme
: TOKYO_NIGHT_DEFAULTS,
};
setSettings(merged);
setOriginalSettings(merged);
} catch (error) {
console.error("Failed to load settings:", error);
} finally {
@@ -229,7 +280,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
setIsSaving(true);
try {
await invoke("save_app_settings", { settings });
setTheme(settings.theme);
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
setOriginalSettings(settings);
onClose();
} catch (error) {
@@ -240,8 +291,11 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
}, [onClose, setTheme, settings]);
const updateSetting = useCallback(
(key: keyof AppSettings, value: boolean | string) => {
setSettings((prev) => ({ ...prev, [key]: value }));
(
key: keyof AppSettings,
value: boolean | string | Record<string, string> | undefined,
) => {
setSettings((prev) => ({ ...prev, [key]: value as unknown as never }));
},
[],
);
@@ -265,83 +319,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
checkDefaultBrowserStatus().catch(console.error);
}, 500); // Check every 500ms
// Listen for version update progress events
let unlistenFn: (() => void) | null = null;
const setupVersionUpdateListener = async () => {
try {
unlistenFn = await listen<VersionUpdateProgress>(
"version-update-progress",
(event) => {
const progress = event.payload;
if (progress.status === "updating") {
// Show unified progress toast
const currentBrowserName = progress.current_browser
? getBrowserDisplayName(progress.current_browser)
: undefined;
showUnifiedVersionUpdateToast(
"Checking for browser updates...",
{
description: currentBrowserName
? `Fetching ${currentBrowserName} release information...`
: "Initializing version check...",
progress: {
current: progress.completed_browsers,
total: progress.total_browsers,
found: progress.new_versions_found,
current_browser: currentBrowserName,
},
},
);
} else if (progress.status === "completed") {
dismissToast("unified-version-update");
if (progress.new_versions_found > 0) {
showSuccessToast("Browser versions updated successfully", {
duration: 5000,
description:
"Auto-downloads will start shortly for available updates.",
});
} else {
showSuccessToast("No new browser versions found", {
duration: 3000,
description: "All browser versions are up to date",
});
}
} else if (progress.status === "error") {
dismissToast("unified-version-update");
showErrorToast("Failed to update browser versions", {
duration: 6000,
description: "Check your internet connection and try again",
});
}
},
);
} catch (error) {
console.error(
"Failed to setup version update progress listener:",
error,
);
}
};
setupVersionUpdateListener();
// Cleanup interval and listener on component unmount or dialog close
// Cleanup interval on component unmount or dialog close
return () => {
clearInterval(intervalId);
if (unlistenFn) {
try {
unlistenFn();
} catch (error) {
console.error(
"Failed to cleanup version update progress listener:",
error,
);
}
}
};
}
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
@@ -373,7 +353,10 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
]);
// Check if settings have changed (excluding default browser setting)
const hasChanges = settings.theme !== originalSettings.theme;
const hasChanges =
settings.theme !== originalSettings.theme ||
JSON.stringify(settings.custom_theme ?? {}) !==
JSON.stringify(originalSettings.custom_theme ?? {});
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -395,6 +378,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
value={settings.theme}
onValueChange={(value) => {
updateSetting("theme", value);
if (value === "custom" && !settings.custom_theme) {
updateSetting("custom_theme", TOKYO_NIGHT_DEFAULTS);
}
}}
>
<SelectTrigger id="theme-select">
@@ -404,6 +390,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
@@ -411,6 +398,77 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
<p className="text-xs text-muted-foreground">
Choose your preferred theme or follow your system settings.
</p>
{settings.theme === "custom" && (
<div className="space-y-3">
<div className="text-sm font-medium">Custom theme</div>
<div className="grid grid-cols-4 gap-3">
{THEME_VARIABLES.map(({ key, label }) => {
const colorValue =
settings.custom_theme?.[key] ??
TOKYO_NIGHT_DEFAULTS[key] ??
"#000000";
return (
<div
key={key}
className="flex flex-col gap-1 items-center"
>
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={label}
className="w-8 h-8 rounded-md border shadow-sm"
style={{ backgroundColor: colorValue }}
/>
</PopoverTrigger>
<PopoverContent
className="w-[320px] p-3"
sideOffset={6}
>
<ColorPicker
className="p-3 rounded-md border shadow-sm bg-background"
value={colorValue}
onColorChange={([r, g, b, a]) => {
const next = Color({ r, g, b }).alpha(a);
const nextStr = next.hexa();
updateSetting("custom_theme", {
...(settings.custom_theme ?? {}),
[key]: nextStr,
});
// Live preview
try {
document.documentElement.style.setProperty(
key,
nextStr,
);
} catch {}
}}
>
<ColorPickerSelection className="h-36 rounded" />
<div className="flex gap-3 items-center mt-3">
<ColorPickerEyeDropper />
<div className="grid gap-1 w-full">
<ColorPickerHue />
<ColorPickerAlpha />
</div>
</div>
<div className="flex gap-2 items-center mt-3">
<ColorPickerOutput />
<ColorPickerFormat />
</div>
</ColorPicker>
</PopoverContent>
</Popover>
<div className="text-[10px] text-muted-foreground text-center leading-tight">
{label}
</div>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Default Browser Section */}
+16 -5
View File
@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
interface AppSettings {
set_as_default_browser: boolean;
theme: string;
custom_theme?: Record<string, string>;
}
interface CustomThemeProviderProps {
@@ -27,12 +28,22 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
// Lazy import to avoid pulling Tauri API on SSR
const { invoke } = await import("@tauri-apps/api/core");
const settings = await invoke<AppSettings>("get_app_settings");
if (
settings?.theme === "light" ||
settings?.theme === "dark" ||
settings?.theme === "system"
const themeValue = settings?.theme ?? "system";
if (themeValue === "custom") {
setDefaultTheme("light");
const vars = settings.custom_theme ?? {};
try {
const root = document.documentElement;
Object.entries(vars).forEach(([k, v]) => {
root.style.setProperty(k, v);
});
} catch {}
} else if (
themeValue === "light" ||
themeValue === "dark" ||
themeValue === "system"
) {
setDefaultTheme(settings.theme);
setDefaultTheme(themeValue);
} else {
setDefaultTheme("system");
}
+479
View File
@@ -0,0 +1,479 @@
"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 = Omit<
HTMLAttributes<HTMLDivElement>,
"onChange"
> & {
value?: Parameters<typeof Color>[0];
defaultValue?: Parameters<typeof Color>[0];
onColorChange?: (value: [number, number, number, number]) => void;
};
export const ColorPicker = ({
value,
defaultValue = "#000000",
onColorChange,
className,
children,
...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 (onColorChange) {
const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100);
const rgba = color.rgb().array();
onColorChange([rgba[0], rgba[1], rgba[2], alpha / 100]);
}
}, [hue, saturation, lightness, alpha, onColorChange]);
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}
>
{children}
</div>
</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;
};
+1
View File
@@ -22,6 +22,7 @@ export interface BrowserProfile {
release_type: string; // "stable" or "nightly"
camoufox_config?: CamoufoxConfig; // Camoufox configuration
group_id?: string; // Reference to profile group
tags?: string[];
}
export interface StoredProxy {