chore: version bump

This commit is contained in:
zhom
2025-08-14 23:06:17 +04:00
parent a77b733a31
commit 334f894e68
13 changed files with 2019 additions and 5 deletions
+4
View File
@@ -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
View File
@@ -520,6 +520,7 @@ mod tests {
release_type: "stable".to_string(),
camoufox_config: None,
group_id: None,
tags: Vec::new(),
}
}
+58 -4
View File
@@ -140,12 +140,27 @@ 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(
@@ -249,8 +264,14 @@ impl BrowserRunner {
println!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
}
// Save the updated profile
self.save_process_info(&updated_profile)?;
// 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 +471,11 @@ 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 +841,13 @@ 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 +1475,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 +1641,14 @@ 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 +1784,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(),
},
];
+4
View File
@@ -27,6 +27,7 @@ mod proxy_manager;
mod settings_manager;
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
mod version_updater;
mod tag_manager;
extern crate lazy_static;
@@ -68,6 +69,7 @@ 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,
};
use tag_manager::TAG_MANAGER;
use geoip_downloader::GeoIPDownloader;
@@ -506,8 +508,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,
+51
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,13 @@ 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 +330,13 @@ 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 +411,44 @@ 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
+122
View File
@@ -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());
}
+102 -1
View File
@@ -61,6 +61,7 @@ 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";
// Label no longer needed after removing dialog-based renaming
import { RippleButton } from "./ui/ripple";
@@ -124,6 +125,19 @@ 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 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) => {
@@ -187,6 +201,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
}
@@ -194,7 +212,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(() => {
@@ -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",
cell: ({ row }) => {
@@ -1011,6 +1110,8 @@ export function ProfilesDataTable({
storedProxies,
openProxySelectorFor,
proxyOverrides,
tagsOverrides,
allTags,
handleProxySelection,
profileToRename,
newProfileName,
+473
View File
@@ -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;
};
+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 {