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
16 changed files with 2297 additions and 20 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(),
}
}
+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());
}
+234 -2
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 {
@@ -61,10 +62,185 @@ 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";
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>;
@@ -124,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) => {
@@ -187,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
}
@@ -194,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(() => {
@@ -350,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) => {
@@ -742,6 +944,33 @@ 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;
return (
<TagsCell
profile={profile}
isDisabled={isDisabled}
tagsOverrides={tagsOverrides}
allTags={allTags}
setAllTags={setAllTags}
openTagsEditorFor={openTagsEditorFor}
setOpenTagsEditorFor={setOpenTagsEditorFor}
setTagsOverrides={setTagsOverrides}
/>
);
},
},
{
accessorKey: "browser",
header: ({ column }) => {
@@ -1011,12 +1240,15 @@ export function ProfilesDataTable({
storedProxies,
openProxySelectorFor,
proxyOverrides,
tagsOverrides,
allTags,
handleProxySelection,
profileToRename,
newProfileName,
renameError,
isRenamingSaving,
handleRename,
openTagsEditorFor,
],
);
+153 -7
View File
@@ -1,12 +1,21 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
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,
@@ -15,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,
@@ -30,6 +44,7 @@ import { RippleButton } from "./ui/ripple";
interface AppSettings {
set_as_default_browser: boolean;
theme: string;
custom_theme?: Record<string, string>;
}
interface PermissionInfo {
@@ -49,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);
@@ -109,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 {
@@ -215,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) {
@@ -226,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 }));
},
[],
);
@@ -285,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}>
@@ -307,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">
@@ -316,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>
@@ -323,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 {